Upload train_and_generate.py with huggingface_hub
Browse files- 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}")
|