Upload train_and_generate.py with huggingface_hub
Browse files- 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
|
| 14 |
-
from torch.utils.data import DataLoader
|
| 15 |
-
from architecture import TransformerBlock
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 133 |
|
| 134 |
-
The model
|
| 135 |
-
|
| 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
|
| 144 |
-
ids = ids[: max_seq_len + 1]
|
| 145 |
-
x = torch.tensor([ids[:-1]], dtype=torch.long, device=device)
|
| 146 |
-
y = torch.tensor([ids[1:]], dtype=torch.long, device=device)
|
| 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())
|
| 152 |
|
| 153 |
|
| 154 |
def fit_anomaly_threshold(model, tokenizer, lines, device, max_seq_len=128, k=2.0):
|
| 155 |
-
"""
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 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)
|
| 173 |
-
var = sum((l - mean) ** 2 for l in losses) / len(losses)
|
| 174 |
std = math.sqrt(var)
|
| 175 |
-
threshold = mean + k * std
|
| 176 |
-
scored.sort(key=lambda s: s[0], reverse=True)
|
| 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
|
| 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 |
-
#
|
| 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"
|
| 214 |
-
TOKENIZER_FILE = "tokenizer/log_tokenizer.json"
|
| 215 |
|
| 216 |
-
# ---
|
| 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
|
| 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 |
-
#
|
| 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 |
-
# ---
|
| 259 |
-
EPOCHS = 5
|
| 260 |
n_batches = len(dataloader)
|
| 261 |
-
print("Starting
|
| 262 |
-
model.train()
|
| 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 |
-
|
| 270 |
-
|
| 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()
|
| 275 |
-
optimizer.step()
|
| 276 |
total_loss += loss.item()
|
| 277 |
|
| 278 |
-
#
|
| 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 |
-
|
| 285 |
-
|
| 286 |
-
f"{total_loss/n_batches:.4f} ==", flush=True)
|
| 287 |
|
| 288 |
-
# ---
|
| 289 |
-
|
| 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())
|
| 299 |
|
| 300 |
-
print(f"
|
| 301 |
-
print(f"
|
| 302 |
|
| 303 |
-
# ---
|
| 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()]
|
| 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"
|
| 315 |
|
| 316 |
-
|
| 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 |
-
#
|
| 323 |
-
|
| 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 |
-
# ---
|
| 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(),
|
| 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"\
|
|
|
|
| 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}")
|