sankath commited on
Commit
e0dbc76
·
verified ·
1 Parent(s): 83c84a4

Upload train_and_generate.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. train_and_generate.py +353 -0
train_and_generate.py ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
166
+ result = score_line(model, tokenizer, line, device, max_seq_len)
167
+ if result is not None:
168
+ loss, ppl = result
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.
188
+ [ERROR] out of memory. Fix: upgrade ram capacity
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",
328
+ "kjshdf!! TOTALLY RANDOM gibberish $$$ 999 not-a-real-log ~~~~",
329
+ ]
330
+ for line in test_lines:
331
+ result = score_line(model, tokenizer, line, device, max_seq_len=max_len)
332
+ if result is None:
333
+ print(f" [skipped: too short] {line}")
334
+ continue
335
+ loss, ppl = result
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}")