| |
| import os |
| import math |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| from torch.utils.data import DataLoader |
| from architecture import TransformerBlock |
| from dataset import build_or_load_tokenizer, AutoregressiveLogDataset |
|
|
|
|
| class LogSentryLM(nn.Module): |
| """Decoder-only (GPT-style) LM. Defaults are deliberately tiny (~2M params) |
| so it trains in under a minute on a laptop GPU.""" |
|
|
| def __init__(self, vocab_size, d_model=128, n_heads=4, n_layers=3, max_seq_len=64): |
| super().__init__() |
| |
| |
| self.max_seq_len = max_seq_len |
| self.token_embeddings = nn.Embedding(vocab_size, d_model) |
| self.position_embeddings = nn.Embedding(max_seq_len, d_model) |
|
|
| self.blocks = nn.ModuleList([ |
| TransformerBlock(d_model=d_model, n_heads=n_heads) for _ in range(n_layers) |
| ]) |
| self.ln_final = nn.LayerNorm(d_model) |
| self.lm_head = nn.Linear(d_model, vocab_size, bias=False) |
|
|
| def forward(self, idx): |
| b, s = idx.shape |
| positions = torch.arange(0, s, device=idx.device).unsqueeze(0) |
| |
| x = self.token_embeddings(idx) + self.position_embeddings(positions) |
|
|
| for block in self.blocks: |
| x = block(x, is_causal=True) |
|
|
| return self.lm_head(self.ln_final(x)) |
|
|
| @torch.no_grad() |
| def generate(self, idx, max_new_tokens, temperature=0.7): |
| """Autoregressive sampling — predict one token, append, repeat. |
| |
| temperature < 1 sharpens toward the likely token (safe/repetitive), |
| > 1 flattens it (more diverse, more mistakes). |
| """ |
| self.eval() |
| for _ in range(max_new_tokens): |
| idx_cond = idx[:, -self.max_seq_len:] |
| logits = self(idx_cond) |
| logits = logits[:, -1, :] / temperature |
| probs = torch.softmax(logits, dim=-1) |
| next_token = torch.multinomial(probs, num_samples=1) |
| idx = torch.cat((idx, next_token), dim=1) |
| return idx |
|
|
|
|
| @torch.no_grad() |
| def score_line(model, tokenizer, text, device, max_seq_len=128): |
| """Anomaly score for one line = average next-token loss. |
| |
| The model learned normal logs, so a line it predicts well scores LOW and a |
| surprising line scores HIGH. Returns (loss, perplexity); None if too short. |
| """ |
| model.eval() |
| ids = tokenizer.encode(text).ids |
| if len(ids) < 2: |
| return None |
| ids = ids[: max_seq_len + 1] |
| x = torch.tensor([ids[:-1]], dtype=torch.long, device=device) |
| y = torch.tensor([ids[1:]], dtype=torch.long, device=device) |
| logits = model(x) |
| loss = F.cross_entropy(logits.view(-1, logits.size(-1)), y.view(-1)) |
| return loss.item(), math.exp(loss.item()) |
|
|
|
|
| def fit_anomaly_threshold(model, tokenizer, lines, device, max_seq_len=128, k=2.0): |
| """Derive the normal-vs-anomalous cutoff from the corpus itself. |
| |
| Most lines are normal, so threshold = mean + k*std of the scores. Lower k = |
| more sensitive (more flags/false positives); higher k = stricter. |
| """ |
| scored = [] |
| for line in lines: |
| result = score_line(model, tokenizer, line, device, max_seq_len) |
| if result is not None: |
| loss, ppl = result |
| scored.append((loss, ppl, line)) |
|
|
| losses = [s[0] for s in scored] |
| mean = sum(losses) / len(losses) |
| var = sum((l - mean) ** 2 for l in losses) / len(losses) |
| std = math.sqrt(var) |
| threshold = mean + k * std |
| scored.sort(key=lambda s: s[0], reverse=True) |
| return threshold, mean, std, scored |
|
|
|
|
| def generate_mock_logs(file_path): |
| """Fallback synthetic corpus. Lines are duplicated so the tiny model has an |
| obvious pattern to latch onto if the real logs are missing.""" |
| os.makedirs(os.path.dirname(file_path), exist_ok=True) |
| mock_data = """[ERROR] nginx failed. Fix: run systemctl restart nginx |
| [INFO] database connection secure. |
| [ERROR] out of memory. Fix: upgrade ram capacity |
| [ERROR] nginx failed. Fix: run systemctl restart nginx |
| [INFO] disk space clean. |
| [ERROR] out of memory. Fix: upgrade ram capacity |
| """ * 50 |
| with open(file_path, "w") as f: |
| f.write(mock_data) |
|
|
|
|
| if __name__ == "__main__": |
| |
| if torch.cuda.is_available(): |
| device = "cuda" |
| elif torch.backends.mps.is_available(): |
| device = "mps" |
| else: |
| device = "cpu" |
| print(f"[SETUP] Using device: {device}") |
|
|
| LOG_FILE = "data/raw_logs.txt" |
| TOKENIZER_FILE = "tokenizer/log_tokenizer.json" |
|
|
| |
| if not os.path.exists(LOG_FILE): |
| print("[DATA] raw_logs.txt not found — generating synthetic mock logs as fallback.") |
| generate_mock_logs(LOG_FILE) |
| tokenizer = build_or_load_tokenizer(LOG_FILE, TOKENIZER_FILE, vocab_size=10000) |
|
|
| max_len = 128 |
| dataset = AutoregressiveLogDataset(LOG_FILE, tokenizer, max_seq_len=max_len, stride=64) |
| dataloader = DataLoader(dataset, batch_size=64, shuffle=True) |
| print(f"[DATA] {len(dataset)} windows | {len(dataloader)} batches/epoch") |
|
|
| |
| model = LogSentryLM(vocab_size=tokenizer.get_vocab_size(), max_seq_len=max_len).to(device) |
| optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3) |
| loss_criterion = nn.CrossEntropyLoss() |
|
|
| |
| EPOCHS = 5 |
| n_batches = len(dataloader) |
| print("[TRAIN] Starting pre-training pass...", flush=True) |
| model.train() |
| for epoch in range(EPOCHS): |
| total_loss = 0 |
| for step, (x, y) in enumerate(dataloader, start=1): |
| x, y = x.to(device), y.to(device) |
| optimizer.zero_grad() |
| logits = model(x) |
| |
| loss = loss_criterion(logits.view(-1, logits.size(-1)), y.view(-1)) |
| loss.backward() |
| optimizer.step() |
| total_loss += loss.item() |
|
|
| |
| if step % 20 == 0 or step == n_batches: |
| print(f"[TRAIN] Epoch {epoch+1}/{EPOCHS} | batch {step}/{n_batches} " |
| f"| loss {loss.item():.4f}", flush=True) |
|
|
| print(f"[TRAIN] Epoch {epoch+1}/{EPOCHS} done | avg CE loss " |
| f"{total_loss/n_batches:.4f}", flush=True) |
|
|
| |
| prompt_text = "Invalid user" |
| prompt_tokens = tokenizer.encode(prompt_text).ids |
| input_tensor = torch.tensor([prompt_tokens], dtype=torch.long, device=device) |
|
|
| generated_ids = model.generate(input_tensor, max_new_tokens=6, temperature=0.5) |
| decoded_output = tokenizer.decode(generated_ids[0].tolist()) |
|
|
| print(f"\n[GEN] Seed : {prompt_text}") |
| print(f"[GEN] Output: {decoded_output}") |
|
|
| |
| with open(LOG_FILE, "r") as f: |
| corpus_lines = [ln.strip() for ln in f if ln.strip()] |
|
|
| threshold, mean, std, scored = fit_anomaly_threshold( |
| model, tokenizer, corpus_lines, device, max_seq_len=max_len, k=2.0 |
| ) |
| print(f"\n[ANOMALY] Score stats: mean={mean:.3f} std={std:.3f} | " |
| f"threshold (mean+2*std)={threshold:.3f}", flush=True) |
|
|
| print("[ANOMALY] Top 5 most anomalous lines in the corpus:") |
| for loss, ppl, line in scored[:5]: |
| flag = "ANOMALY" if loss > threshold else "normal " |
| print(f" [{flag}] loss={loss:.3f} ppl={ppl:8.1f} | {line[:90]}") |
|
|
| |
| print("[ANOMALY] Predicting on new lines:") |
| test_lines = [ |
| "Dec 10 06:55:46 LabSZ sshd[24200]: Invalid user webmaster from 173.234.31.186", |
| "Failed password for root from 112.95.230.3 port 49204 ssh2", |
| "kjshdf!! TOTALLY RANDOM gibberish $$$ 999 not-a-real-log ~~~~", |
| ] |
| for line in test_lines: |
| result = score_line(model, tokenizer, line, device, max_seq_len=max_len) |
| if result is None: |
| print(f" [skipped: too short] {line}") |
| continue |
| loss, ppl = result |
| flag = "ANOMALY" if loss > threshold else "normal " |
| print(f" [{flag}] loss={loss:.3f} ppl={ppl:8.1f} | {line[:90]}") |
|
|
| |
| MODEL_FILE = "model/logsentry_lm.pt" |
| os.makedirs(os.path.dirname(MODEL_FILE), exist_ok=True) |
| torch.save({ |
| "model_state": model.state_dict(), |
| "vocab_size": tokenizer.get_vocab_size(), |
| "max_seq_len": max_len, |
| "threshold": threshold, |
| "score_mean": mean, |
| "score_std": std, |
| }, MODEL_FILE) |
| print(f"\n[SAVE] Saved model + anomaly threshold to {MODEL_FILE}") |
|
|