sankath commited on
Commit
cf08e63
·
verified ·
1 Parent(s): 511fd40

Upload train_and_generate.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. train_and_generate.py +80 -211
train_and_generate.py CHANGED
@@ -1,165 +1,85 @@
1
- # train_and_generate.py
2
- # ============================================================================
3
- # The "main" file: it assembles the model, trains it on log data, generates
4
- # new log text, and scores log lines for anomalies. Read this alongside
5
- # TUTORIAL.md. Every block is heavily commented to explain WHAT it does and
6
- # WHY the values are chosen.
7
- # ============================================================================
8
-
9
  import os
10
  import math
11
  import torch
12
  import torch.nn as nn
13
- import torch.nn.functional as F # functional API (cross_entropy, softmax, etc.)
14
- from torch.utils.data import DataLoader # batches + shuffles the dataset for us
15
- from architecture import TransformerBlock # one Transformer layer
16
  from dataset import build_or_load_tokenizer, AutoregressiveLogDataset
17
 
18
 
19
- # ============================================================================
20
- # THE MODEL
21
- # A decoder-only (GPT-style) language model. It maps a sequence of token IDs
22
- # to, for every position, a probability distribution over "what token comes
23
- # next". That single ability powers training, generation, and anomaly scoring.
24
- # ============================================================================
25
  class LogSentryLM(nn.Module):
 
 
 
26
  def __init__(self, vocab_size, d_model=128, n_heads=4, n_layers=3, max_seq_len=64):
27
- # vocab_size : how many distinct tokens the tokenizer knows. The model
28
- # must output one score per token, so this sets the size of
29
- # both the embedding table and the final output layer.
30
- # d_model : the "width" of the model — the length of the vector used
31
- # to represent each token internally. Bigger = more capacity
32
- # but slower and more memory. 128 is small/fast for a demo.
33
- # n_heads : number of parallel attention "heads" (see architecture.py).
34
- # Must divide d_model evenly.
35
- # n_layers : how many Transformer blocks are stacked. More = deeper
36
- # reasoning, but slower. 3 is tiny by design.
37
- # max_seq_len: the longest sequence (in tokens) the model can ever see at
38
- # once — its "context window" / memory span. We need this up
39
- # front because positions are LEARNED (see below), so we must
40
- # allocate one position vector per slot. 64 is just the class
41
- # default; the training script overrides it to 128.
42
  super().__init__()
 
 
43
  self.max_seq_len = max_seq_len
44
-
45
- # Token embedding: a lookup table turning each integer token ID into a
46
- # learned d_model-length vector. Shape: (vocab_size, d_model).
47
  self.token_embeddings = nn.Embedding(vocab_size, d_model)
 
48
 
49
- # Position embedding: attention has NO built-in sense of order, so we add
50
- # a separate learned vector for "this is position 0, 1, 2, ...". This is
51
- # why max_seq_len is fixed: there are exactly max_seq_len position slots.
52
- self.position_embeddings = nn.Embedding(max_seq_len, d_model)
53
-
54
- # The stack of Transformer blocks — the actual "thinking" layers.
55
  self.blocks = nn.ModuleList([
56
  TransformerBlock(d_model=d_model, n_heads=n_heads) for _ in range(n_layers)
57
  ])
58
-
59
- # A final normalization for training stability before the output layer.
60
  self.ln_final = nn.LayerNorm(d_model)
61
-
62
- # The "language-model head": projects each d_model vector to vocab_size
63
- # logits (one raw score per possible next token). bias=False is the
64
- # common GPT convention.
65
- self.lm_head = nn.Linear(d_model, vocab_size, bias=False)
66
 
67
  def forward(self, idx):
68
- # idx: a batch of token-ID sequences, shape (batch, seq_len).
69
  b, s = idx.shape
70
-
71
- # Build [0, 1, 2, ..., s-1] and shape it (1, s) so it broadcasts over the
72
- # batch — these index into the position embedding table.
73
  positions = torch.arange(0, s, device=idx.device).unsqueeze(0)
74
-
75
- # Input representation = "what the token is" + "where it sits". Adding
76
- # them lets the model use both identity and order.
77
  x = self.token_embeddings(idx) + self.position_embeddings(positions)
78
 
79
- # Pass through every Transformer block. is_causal=True forbids a position
80
- # from peeking at future tokens (required for next-token prediction).
81
  for block in self.blocks:
82
  x = block(x, is_causal=True)
83
 
84
- # Normalize, then project to vocabulary logits.
85
- # Output shape: (batch, seq_len, vocab_size).
86
  return self.lm_head(self.ln_final(x))
87
 
88
- # ------------------------------------------------------------------------
89
- # GENERATION: write new log-like text, one token at a time.
90
- # @torch.no_grad() disables gradient tracking (faster, less memory) — we are
91
- # not training here, just predicting.
92
- # ------------------------------------------------------------------------
93
  @torch.no_grad()
94
  def generate(self, idx, max_new_tokens, temperature=0.7):
95
- # idx : the seed/prompt token IDs, shape (1, prompt_len).
96
- # max_new_tokens: how many tokens to append.
97
- # temperature : randomness dial. <1 sharpens toward the most likely
98
- # token (safe, repetitive); >1 flattens it (diverse,
99
- # riskier); 1.0 = the model's true probabilities.
100
- self.eval() # switch off dropout etc. for deterministic inference
101
- for _ in range(max_new_tokens):
102
- # The model can only handle max_seq_len tokens, so keep just the most
103
- # recent window if the sequence has grown beyond it.
104
- idx_cond = idx[:, -self.max_seq_len:]
105
-
106
- logits = self(idx_cond) # (1, seq_len, vocab_size)
107
-
108
- # We only care about the prediction at the LAST position (the next
109
- # token). Divide by temperature to scale confidence before softmax.
110
- logits = logits[:, -1, :] / temperature
111
 
112
- # Turn logits into a probability distribution over the vocabulary.
 
 
 
 
 
 
 
113
  probs = torch.softmax(logits, dim=-1)
114
-
115
- # Randomly SAMPLE the next token from that distribution (vs. always
116
- # taking the argmax) so generations have natural variety.
117
- next_token = torch.multinomial(probs, num_samples=1)
118
-
119
- # Append the new token and loop — the model now conditions on it too.
120
  idx = torch.cat((idx, next_token), dim=1)
121
  return idx
122
 
123
 
124
- # ============================================================================
125
- # ANOMALY SCORING
126
- # Because the model learned what NORMAL logs look like, a line it cannot
127
- # predict well is, by definition, unusual. We quantify "surprise" as the
128
- # average next-token loss; high surprise => likely anomaly.
129
- # ============================================================================
130
  @torch.no_grad()
131
  def score_line(model, tokenizer, text, device, max_seq_len=128):
132
- """Score one log line by its average next-token cross-entropy loss.
133
 
134
- The model was trained on normal logs, so a line it can predict well scores
135
- LOW (familiar / normal) and a line it cannot predict scores HIGH (surprising
136
- / anomalous). Returns (avg_loss, perplexity), or None if the line is too
137
- short to score. perplexity = exp(avg_loss) is just a more intuitive scale
138
- ("how many tokens the model was effectively choosing between").
139
  """
140
  model.eval()
141
  ids = tokenizer.encode(text).ids
142
  if len(ids) < 2:
143
- return None # need at least one (context -> target) pair to score
144
- ids = ids[: max_seq_len + 1] # respect context window
145
- x = torch.tensor([ids[:-1]], dtype=torch.long, device=device) # inputs t0..t(n-1)
146
- y = torch.tensor([ids[1:]], dtype=torch.long, device=device) # targets t1..tn
147
  logits = model(x)
148
- # cross_entropy compares predicted distributions against the true next tokens
149
- # and averages the per-token "surprise". Lower = the model expected this line.
150
  loss = F.cross_entropy(logits.view(-1, logits.size(-1)), y.view(-1))
151
- return loss.item(), math.exp(loss.item()) # (avg loss, perplexity)
152
 
153
 
154
  def fit_anomaly_threshold(model, tokenizer, lines, device, max_seq_len=128, k=2.0):
155
- """Learn a normal-vs-anomalous cutoff from the corpus itself.
156
-
157
- Scores every line, then sets threshold = mean + k * std of the scores. The
158
- idea: most lines are normal, so their scores form a "normal" distribution;
159
- anything k standard deviations above the average is unusually surprising.
160
- Lower k = more sensitive (more flags, more false positives); higher k =
161
- stricter. Returns (threshold, mean, std, scored_lines) where scored_lines is
162
- a list of (loss, perplexity, line) sorted from most to least anomalous.
163
  """
164
  scored = []
165
  for line in lines:
@@ -169,19 +89,17 @@ def fit_anomaly_threshold(model, tokenizer, lines, device, max_seq_len=128, k=2.
169
  scored.append((loss, ppl, line))
170
 
171
  losses = [s[0] for s in scored]
172
- mean = sum(losses) / len(losses) # average surprise
173
- var = sum((l - mean) ** 2 for l in losses) / len(losses) # spread of surprise
174
  std = math.sqrt(var)
175
- threshold = mean + k * std # the cutoff line
176
- scored.sort(key=lambda s: s[0], reverse=True) # most anomalous first
177
  return threshold, mean, std, scored
178
 
179
 
180
- # ============================================================================
181
- # HELPER: synthetic logs, used only as a fallback if the real corpus is missing.
182
- # The lines are duplicated so the tiny model has an obvious pattern to learn.
183
- # ============================================================================
184
  def generate_mock_logs(file_path):
 
 
185
  os.makedirs(os.path.dirname(file_path), exist_ok=True)
186
  mock_data = """[ERROR] nginx failed. Fix: run systemctl restart nginx
187
  [INFO] database connection secure.
@@ -189,139 +107,93 @@ def generate_mock_logs(file_path):
189
  [ERROR] nginx failed. Fix: run systemctl restart nginx
190
  [INFO] disk space clean.
191
  [ERROR] out of memory. Fix: upgrade ram capacity
192
- """ * 50 # Duplicate entries to create a recognizable token distribution pattern
193
  with open(file_path, "w") as f:
194
  f.write(mock_data)
195
 
196
 
197
- # ============================================================================
198
- # MAIN PROGRAM: data -> model -> train -> generate -> detect anomalies -> save.
199
- # Only runs when you execute `python train_and_generate.py` directly.
200
- # ============================================================================
201
  if __name__ == "__main__":
202
- # --- Pick the fastest available compute device ---
203
- # CUDA = NVIDIA GPU; MPS = Apple Silicon GPU (Metal); else CPU. We train on
204
- # whichever is present, preferring the GPU for speed.
205
  if torch.cuda.is_available():
206
  device = "cuda"
207
  elif torch.backends.mps.is_available():
208
  device = "mps"
209
  else:
210
  device = "cpu"
211
- print(f"Using device: {device}")
212
 
213
- LOG_FILE = "data/raw_logs.txt" # the training corpus
214
- TOKENIZER_FILE = "tokenizer/log_tokenizer.json" # cached BPE tokenizer
215
 
216
- # --- 1. Setup data ---
217
- # raw_logs.txt is the combined real-world corpus (Linux + Apache + OpenSSH
218
- # samples from Loghub). Fall back to synthetic mock logs if it is missing.
219
  if not os.path.exists(LOG_FILE):
220
- print("raw_logs.txt not found - generating synthetic mock logs as fallback.")
221
  generate_mock_logs(LOG_FILE)
 
222
 
223
- # Train (or load) a Byte-Pair Encoding tokenizer. vocab_size=10000 is a
224
- # ceiling; the small corpus only fills ~5,500 of it, which is fine.
225
- tokenizer = build_or_load_tokenizer(LOG_FILE, TOKENIZER_FILE, vocab_size=10000)
226
-
227
- # max_len is THE context window: the model reads up to 128 tokens at a time
228
- # and learns dependencies within that span. Larger = sees more context but
229
- # costs more compute (attention grows with the square of this length);
230
- # smaller = cheaper but shorter "memory". 128 comfortably covers a full log
231
- # line. (This value is passed to the model as max_seq_len.)
232
  max_len = 128
233
-
234
- # stride=64 (half the window) slides the window 64 tokens at a time. stride=1
235
- # would create ~187k near-duplicate windows (very slow); 64 gives ~2,900
236
- # windows that still cover the corpus. Smaller stride = more/overlapping data.
237
  dataset = AutoregressiveLogDataset(LOG_FILE, tokenizer, max_seq_len=max_len, stride=64)
 
 
238
 
239
- # DataLoader batches windows together and shuffles them each epoch. batch_size
240
- # =64 means 64 windows are processed per step — bigger batches use the GPU
241
- # more efficiently and give smoother gradients, but use more memory.
242
- dataloader = DataLoader(dataset, batch_size=64, shuffle=True)
243
- print(f"Dataset: {len(dataset)} windows | {len(dataloader)} batches/epoch")
244
-
245
- # --- 2. Build the model ---
246
- # vocab_size comes from the trained tokenizer; max_seq_len must match the data
247
- # window so the position embeddings line up. .to(device) moves weights to GPU.
248
  model = LogSentryLM(vocab_size=tokenizer.get_vocab_size(), max_seq_len=max_len).to(device)
249
-
250
- # AdamW: a robust optimizer. lr=1e-3 (the learning rate) is the step size for
251
- # each weight update — too high diverges (loss -> nan), too low learns slowly.
252
- optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
253
-
254
- # Cross-entropy is the standard loss for predicting one class (here: the next
255
- # token) out of many. Training minimizes it.
256
  loss_criterion = nn.CrossEntropyLoss()
257
 
258
- # --- 3. Training loop ---
259
- EPOCHS = 5 # one epoch = one full pass over the dataset
260
  n_batches = len(dataloader)
261
- print("Starting Model Pre-Training Optimization Pass...", flush=True)
262
- model.train() # enable training behaviors (e.g. dropout)
263
  for epoch in range(EPOCHS):
264
  total_loss = 0
265
  for step, (x, y) in enumerate(dataloader, start=1):
266
- # x = input tokens, y = the same tokens shifted by one (the targets).
267
  x, y = x.to(device), y.to(device)
268
-
269
- optimizer.zero_grad() # clear gradients from last step
270
- logits = model(x) # forward pass -> predictions
271
- # Flatten (batch, seq, vocab) -> (batch*seq, vocab) and targets to
272
- # (batch*seq,) so loss is averaged over every predicted position.
273
  loss = loss_criterion(logits.view(-1, logits.size(-1)), y.view(-1))
274
- loss.backward() # backprop: compute gradients
275
- optimizer.step() # update the weights
276
  total_loss += loss.item()
277
 
278
- # Live, flushed progress so the run is never blind (helps with stdout
279
- # buffering when output is piped to a file).
280
  if step % 20 == 0 or step == n_batches:
281
- print(f"Epoch {epoch+1}/{EPOCHS} | batch {step}/{n_batches} "
282
  f"| loss {loss.item():.4f}", flush=True)
283
 
284
- # Average loss per epoch should fall steadily — that means it's learning.
285
- print(f"== Epoch {epoch+1}/{EPOCHS} done | Avg Cross-Entropy Loss: "
286
- f"{total_loss/n_batches:.4f} ==", flush=True)
287
 
288
- # --- 4. Generation demo ---
289
- print("\n--- Running Inference Engine Generation Phase ---")
290
- prompt_text = "Invalid user" # Seed phrase that appears in the real OpenSSH/Linux logs
291
  prompt_tokens = tokenizer.encode(prompt_text).ids
292
- # Wrap in a batch of 1 and move to the device. dtype=long because token IDs
293
- # are integers used to index the embedding table.
294
  input_tensor = torch.tensor([prompt_tokens], dtype=torch.long, device=device)
295
 
296
- # Generate 6 new tokens following the seed. temperature=0.5 keeps it crisp.
297
  generated_ids = model.generate(input_tensor, max_new_tokens=6, temperature=0.5)
298
- decoded_output = tokenizer.decode(generated_ids[0].tolist()) # IDs -> text
299
 
300
- print(f"User Input Seed Text : {prompt_text}")
301
- print(f"Model Predicted Output: {decoded_output}")
302
 
303
- # --- 5. Anomaly detection ---
304
- print("\n--- Anomaly Detection Phase ---", flush=True)
305
  with open(LOG_FILE, "r") as f:
306
- corpus_lines = [ln.strip() for ln in f if ln.strip()] # non-empty lines
307
 
308
- # Learn the normal-score distribution from the training corpus and derive the
309
- # anomaly threshold (mean + 2*std of per-line scores).
310
  threshold, mean, std, scored = fit_anomaly_threshold(
311
  model, tokenizer, corpus_lines, device, max_seq_len=max_len, k=2.0
312
  )
313
- print(f"Score stats: mean={mean:.3f} std={std:.3f} | "
314
- f"anomaly threshold (mean+2*std)={threshold:.3f}")
315
 
316
- # The lines the model found hardest to predict (usually the rarest log types).
317
- print("\nTop 5 most anomalous lines in the corpus:")
318
  for loss, ppl, line in scored[:5]:
319
  flag = "ANOMALY" if loss > threshold else "normal "
320
  print(f" [{flag}] loss={loss:.3f} ppl={ppl:8.1f} | {line[:90]}")
321
 
322
- # Predict on fresh lines: a couple of real-looking logs plus an injected
323
- # off-distribution line that the model has never seen (should score huge).
324
- print("\nPredicting anomalies on new lines:")
325
  test_lines = [
326
  "Dec 10 06:55:46 LabSZ sshd[24200]: Invalid user webmaster from 173.234.31.186",
327
  "Failed password for root from 112.95.230.3 port 49204 ssh2",
@@ -336,18 +208,15 @@ if __name__ == "__main__":
336
  flag = "ANOMALY" if loss > threshold else "normal "
337
  print(f" [{flag}] loss={loss:.3f} ppl={ppl:8.1f} | {line[:90]}")
338
 
339
- # --- 6. Save everything the standalone predictor needs ---
340
- # We store the weights AND the learned threshold + config, so
341
- # detect_anomalies.py can rebuild the exact model and judge new lines without
342
- # retraining.
343
  MODEL_FILE = "model/logsentry_lm.pt"
344
  os.makedirs(os.path.dirname(MODEL_FILE), exist_ok=True)
345
  torch.save({
346
- "model_state": model.state_dict(), # the learned weights
347
  "vocab_size": tokenizer.get_vocab_size(),
348
  "max_seq_len": max_len,
349
  "threshold": threshold,
350
  "score_mean": mean,
351
  "score_std": std,
352
  }, MODEL_FILE)
353
- print(f"\nSaved model + anomaly threshold to {MODEL_FILE}")
 
1
+ # file: train_and_generate.py
 
 
 
 
 
 
 
2
  import os
3
  import math
4
  import torch
5
  import torch.nn as nn
6
+ import torch.nn.functional as F
7
+ from torch.utils.data import DataLoader
8
+ from architecture import TransformerBlock
9
  from dataset import build_or_load_tokenizer, AutoregressiveLogDataset
10
 
11
 
 
 
 
 
 
 
12
  class LogSentryLM(nn.Module):
13
+ """Decoder-only (GPT-style) LM. Defaults are deliberately tiny (~2M params)
14
+ so it trains in under a minute on a laptop GPU."""
15
+
16
  def __init__(self, vocab_size, d_model=128, n_heads=4, n_layers=3, max_seq_len=64):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  super().__init__()
18
+ # max_seq_len is the context window. It's fixed up front because positions
19
+ # are LEARNED embeddings (one vector per slot), not computed on the fly.
20
  self.max_seq_len = max_seq_len
 
 
 
21
  self.token_embeddings = nn.Embedding(vocab_size, d_model)
22
+ self.position_embeddings = nn.Embedding(max_seq_len, d_model) # attention has no order sense on its own
23
 
 
 
 
 
 
 
24
  self.blocks = nn.ModuleList([
25
  TransformerBlock(d_model=d_model, n_heads=n_heads) for _ in range(n_layers)
26
  ])
 
 
27
  self.ln_final = nn.LayerNorm(d_model)
28
+ self.lm_head = nn.Linear(d_model, vocab_size, bias=False) # project back to vocab logits
 
 
 
 
29
 
30
  def forward(self, idx):
 
31
  b, s = idx.shape
 
 
 
32
  positions = torch.arange(0, s, device=idx.device).unsqueeze(0)
33
+ # token identity + position — the model needs both
 
 
34
  x = self.token_embeddings(idx) + self.position_embeddings(positions)
35
 
 
 
36
  for block in self.blocks:
37
  x = block(x, is_causal=True)
38
 
 
 
39
  return self.lm_head(self.ln_final(x))
40
 
 
 
 
 
 
41
  @torch.no_grad()
42
  def generate(self, idx, max_new_tokens, temperature=0.7):
43
+ """Autoregressive sampling — predict one token, append, repeat.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
+ temperature < 1 sharpens toward the likely token (safe/repetitive),
46
+ > 1 flattens it (more diverse, more mistakes).
47
+ """
48
+ self.eval()
49
+ for _ in range(max_new_tokens):
50
+ idx_cond = idx[:, -self.max_seq_len:] # keep only what fits the context window
51
+ logits = self(idx_cond)
52
+ logits = logits[:, -1, :] / temperature # only the last position predicts the next token
53
  probs = torch.softmax(logits, dim=-1)
54
+ next_token = torch.multinomial(probs, num_samples=1) # sample rather than argmax for variety
 
 
 
 
 
55
  idx = torch.cat((idx, next_token), dim=1)
56
  return idx
57
 
58
 
 
 
 
 
 
 
59
  @torch.no_grad()
60
  def score_line(model, tokenizer, text, device, max_seq_len=128):
61
+ """Anomaly score for one line = average next-token loss.
62
 
63
+ The model learned normal logs, so a line it predicts well scores LOW and a
64
+ surprising line scores HIGH. Returns (loss, perplexity); None if too short.
 
 
 
65
  """
66
  model.eval()
67
  ids = tokenizer.encode(text).ids
68
  if len(ids) < 2:
69
+ return None # need at least one (context -> target) pair
70
+ ids = ids[: max_seq_len + 1]
71
+ x = torch.tensor([ids[:-1]], dtype=torch.long, device=device)
72
+ y = torch.tensor([ids[1:]], dtype=torch.long, device=device)
73
  logits = model(x)
 
 
74
  loss = F.cross_entropy(logits.view(-1, logits.size(-1)), y.view(-1))
75
+ return loss.item(), math.exp(loss.item()) # perplexity is the intuitive scale
76
 
77
 
78
  def fit_anomaly_threshold(model, tokenizer, lines, device, max_seq_len=128, k=2.0):
79
+ """Derive the normal-vs-anomalous cutoff from the corpus itself.
80
+
81
+ Most lines are normal, so threshold = mean + k*std of the scores. Lower k =
82
+ more sensitive (more flags/false positives); higher k = stricter.
 
 
 
 
83
  """
84
  scored = []
85
  for line in lines:
 
89
  scored.append((loss, ppl, line))
90
 
91
  losses = [s[0] for s in scored]
92
+ mean = sum(losses) / len(losses)
93
+ var = sum((l - mean) ** 2 for l in losses) / len(losses)
94
  std = math.sqrt(var)
95
+ threshold = mean + k * std
96
+ scored.sort(key=lambda s: s[0], reverse=True) # most anomalous first
97
  return threshold, mean, std, scored
98
 
99
 
 
 
 
 
100
  def generate_mock_logs(file_path):
101
+ """Fallback synthetic corpus. Lines are duplicated so the tiny model has an
102
+ obvious pattern to latch onto if the real logs are missing."""
103
  os.makedirs(os.path.dirname(file_path), exist_ok=True)
104
  mock_data = """[ERROR] nginx failed. Fix: run systemctl restart nginx
105
  [INFO] database connection secure.
 
107
  [ERROR] nginx failed. Fix: run systemctl restart nginx
108
  [INFO] disk space clean.
109
  [ERROR] out of memory. Fix: upgrade ram capacity
110
+ """ * 50
111
  with open(file_path, "w") as f:
112
  f.write(mock_data)
113
 
114
 
 
 
 
 
115
  if __name__ == "__main__":
116
+ # Prefer GPU: cuda -> apple mps -> cpu
 
 
117
  if torch.cuda.is_available():
118
  device = "cuda"
119
  elif torch.backends.mps.is_available():
120
  device = "mps"
121
  else:
122
  device = "cpu"
123
+ print(f"[SETUP] Using device: {device}")
124
 
125
+ LOG_FILE = "data/raw_logs.txt"
126
+ TOKENIZER_FILE = "tokenizer/log_tokenizer.json"
127
 
128
+ # --- Data ---
 
 
129
  if not os.path.exists(LOG_FILE):
130
+ print("[DATA] raw_logs.txt not found — generating synthetic mock logs as fallback.")
131
  generate_mock_logs(LOG_FILE)
132
+ tokenizer = build_or_load_tokenizer(LOG_FILE, TOKENIZER_FILE, vocab_size=10000) # ceiling; small corpus fills ~5.5k
133
 
 
 
 
 
 
 
 
 
 
134
  max_len = 128
 
 
 
 
135
  dataset = AutoregressiveLogDataset(LOG_FILE, tokenizer, max_seq_len=max_len, stride=64)
136
+ dataloader = DataLoader(dataset, batch_size=64, shuffle=True) # bigger batch = better GPU use
137
+ print(f"[DATA] {len(dataset)} windows | {len(dataloader)} batches/epoch")
138
 
139
+ # --- Model ---
 
 
 
 
 
 
 
 
140
  model = LogSentryLM(vocab_size=tokenizer.get_vocab_size(), max_seq_len=max_len).to(device)
141
+ optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3) # 1e-3 is fine at this size; drop to 3e-4 if it diverges
 
 
 
 
 
 
142
  loss_criterion = nn.CrossEntropyLoss()
143
 
144
+ # --- Train ---
145
+ EPOCHS = 5
146
  n_batches = len(dataloader)
147
+ print("[TRAIN] Starting pre-training pass...", flush=True)
148
+ model.train()
149
  for epoch in range(EPOCHS):
150
  total_loss = 0
151
  for step, (x, y) in enumerate(dataloader, start=1):
 
152
  x, y = x.to(device), y.to(device)
153
+ optimizer.zero_grad()
154
+ logits = model(x)
155
+ # flatten (B, T, vocab) -> (B*T, vocab) so loss covers every position
 
 
156
  loss = loss_criterion(logits.view(-1, logits.size(-1)), y.view(-1))
157
+ loss.backward()
158
+ optimizer.step()
159
  total_loss += loss.item()
160
 
161
+ # flush so progress shows even when piped to a file (stdout buffering)
 
162
  if step % 20 == 0 or step == n_batches:
163
+ print(f"[TRAIN] Epoch {epoch+1}/{EPOCHS} | batch {step}/{n_batches} "
164
  f"| loss {loss.item():.4f}", flush=True)
165
 
166
+ print(f"[TRAIN] Epoch {epoch+1}/{EPOCHS} done | avg CE loss "
167
+ f"{total_loss/n_batches:.4f}", flush=True)
 
168
 
169
+ # --- Generate (quick sanity check) ---
170
+ prompt_text = "Invalid user" # a phrase that actually appears in the corpus
 
171
  prompt_tokens = tokenizer.encode(prompt_text).ids
 
 
172
  input_tensor = torch.tensor([prompt_tokens], dtype=torch.long, device=device)
173
 
 
174
  generated_ids = model.generate(input_tensor, max_new_tokens=6, temperature=0.5)
175
+ decoded_output = tokenizer.decode(generated_ids[0].tolist())
176
 
177
+ print(f"\n[GEN] Seed : {prompt_text}")
178
+ print(f"[GEN] Output: {decoded_output}")
179
 
180
+ # --- Anomaly detection ---
 
181
  with open(LOG_FILE, "r") as f:
182
+ corpus_lines = [ln.strip() for ln in f if ln.strip()]
183
 
 
 
184
  threshold, mean, std, scored = fit_anomaly_threshold(
185
  model, tokenizer, corpus_lines, device, max_seq_len=max_len, k=2.0
186
  )
187
+ print(f"\n[ANOMALY] Score stats: mean={mean:.3f} std={std:.3f} | "
188
+ f"threshold (mean+2*std)={threshold:.3f}", flush=True)
189
 
190
+ print("[ANOMALY] Top 5 most anomalous lines in the corpus:")
 
191
  for loss, ppl, line in scored[:5]:
192
  flag = "ANOMALY" if loss > threshold else "normal "
193
  print(f" [{flag}] loss={loss:.3f} ppl={ppl:8.1f} | {line[:90]}")
194
 
195
+ # A couple of real lines + one injected off-distribution line (should score huge)
196
+ print("[ANOMALY] Predicting on new lines:")
 
197
  test_lines = [
198
  "Dec 10 06:55:46 LabSZ sshd[24200]: Invalid user webmaster from 173.234.31.186",
199
  "Failed password for root from 112.95.230.3 port 49204 ssh2",
 
208
  flag = "ANOMALY" if loss > threshold else "normal "
209
  print(f" [{flag}] loss={loss:.3f} ppl={ppl:8.1f} | {line[:90]}")
210
 
211
+ # --- Save weights + threshold so detect_anomalies.py can run standalone ---
 
 
 
212
  MODEL_FILE = "model/logsentry_lm.pt"
213
  os.makedirs(os.path.dirname(MODEL_FILE), exist_ok=True)
214
  torch.save({
215
+ "model_state": model.state_dict(),
216
  "vocab_size": tokenizer.get_vocab_size(),
217
  "max_seq_len": max_len,
218
  "threshold": threshold,
219
  "score_mean": mean,
220
  "score_std": std,
221
  }, MODEL_FILE)
222
+ print(f"\n[SAVE] Saved model + anomaly threshold to {MODEL_FILE}")