简介:这套基于LSTM的日志异常检测系统资源包,适合计算机相关专业的学生用于课程设计、期末大作业,也适合需要完整项目练习的开发者参考,帮助理解并复现日志数据的异常检测流程。资源共115个文件,压缩包大小约82.22MB,内含14个Python源码脚本、20个npy格式训练数据、13个CSV标签/样本文件、12个pkl模型文件及若干log日志样例,同时附有多篇日志异常检测、服务器故障分析、入侵检测等主题的PDF/CAJ文献,便于由理论到实践对照学习。数据文件覆盖HDFS日志结构化数据、异常标签与测试集,可直接驱动模型完成训练与验证;源码包含数据预处理、LSTM模型构建、异常检测等模块,结构清晰,便于二次修改与复现。目前已有232人学习下载,项目经过调试可运行,适合作为期末设计或项目实战的参考实现。
1. 用LSTM做日志异常检测,期末大作业到底在检测什么
日志异常检测和普通分类任务有个根本区别:你不知道异常长什么样。规则匹配只能覆盖见过的故障,新故障一来就失效。主流做法因此变成只学正常日志的规律,把偏离正常的序列挑出来。LSTM恰好擅长时间依赖,"打开→写入→关闭"的正常顺序一旦反转,模型就会给出低概率。
这个期末大作业的完整形态,是一份能跑通的Python工程:数据解析、窗口切分、LSTM模型训练、阈值选取、指标评估,源码和数据集都齐。难点不在网络结构,单层LSTM加全连接就够;真正的坑在数据处理和阈值选取,这两步直接决定F1能不能看。
下面按数据链路、模型搭建、异常判定、交付排错四步展开,基于Python 3.8+与PyTorch。想交一份能答辩的作业,照着参数表和排错清单走完,比翻别人的报告有用。
2. LSTM日志异常检测的数据链路:从原始日志到滑动窗口样本
2.1 日志解析:把文本转成事件ID
LSTM吃的是数值序列,不是字符串。直接把日志文本按词切分再进Embedding,会出现一个非常现实的问题:IP地址、端口号、时间戳、数字参数,这些字段的取值几乎每条日志都不一样,词汇表会被撑到几十万,Embedding和全连接层的参数随之失控,而训练集往往只有几万条日志,模型根本学不动。常见做法是先做日志解析(log parsing),把同一条日志模板归成一个事件,给每个事件分配整数ID。例如"Block 123 removed"和"Block 456 removed",解析后都是"Block * removed"这个模板,对应同一个事件ID。这一步做完,原始日志被压缩成几十到几百种事件,序列建模才有意义。
import re from collections import Counter LOG_PATTERN = re.compile( r'(?P<time>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}) ' r'(?P<level>\w+) (?P<component>\S+) (?P<content>.*)' ) def extract_template(content: str) -> str: # 把日志正文里的可变参数替换为占位符,剩下的就是事件模板 content = re.sub(r'\b\d+\b', '*', content) # 纯数字 -> * content = re.sub(r'0x[0-9a-fA-F]+', 'HEX', content) # 十六进制 -> HEX content = re.sub(r'([\w.-]+\.)+[\w.-]+', 'HOST', content) # IP/域名 -> HOST return content def parse_to_events(lines: list[str], min_count: int = 10): templates = [] for line in lines: m = LOG_PATTERN.match(line.strip()) if not m: continue templates.append(extract_template(m.group('content'))) vc = Counter(templates) keep = {t for t, c in vc.items() if c >= min_count} event2id = {'UNK': 0} for t in keep: event2id[t] = len(event2id) events = [event2id.get(t, 0) for t in templates] return events, event2id正则先拆出时间、级别、组件、正文四个字段,正文再做三处替换。替换顺序不能反:先处理十六进制再处理纯数字,0x1F会先变成HEX,没问题;反过来先提纯数字,0x1F会变成0x*,模板就错了。min_count过滤只出现几次的稀有模板,它们大概率来自拼接错误或一次性噪音,统一归到UNK事件0,避免模型为噪音单独开一类。event2id这个字典要随模型一起保存,推理阶段预测出的ID需要靠它反向翻译回模板文本,丢了它整个系统就没法解释输出。
注意:UNK事件必须固定在0,Embedding里也要设置padding_idx=0。否则序列补齐用的0会被当成一个真实事件参与训练。
2.2 滑动窗口:序列样本怎么切
事件序列要切成定长样本才能进LSTM。模型的任务是"看到前面seq_len个事件,预测下一个事件是谁"。seq_len和滑动步长stride是两个直接影响样本量和时间分辨率的参数。stride=1时相邻窗口重叠seq_len-1个事件,样本量最大但训练慢;stride=seq_len时窗口互不重叠,样本少但每个窗口信息独立。日志异常检测我一般先用stride=1把数据吃透,调完seq_len再考虑加大步长减少训练时间。这套滑窗思路和LSTM时间序列预测python里处理数值序列的窗口平移是同源的,只是这里滑的是事件ID。
def make_windows(events: list[int], seq_len: int = 10, stride: int = 1): windows, targets = [], [] for i in range(0, len(events) - seq_len, stride): windows.append(events[i:i + seq_len]) targets.append(events[i + seq_len]) # 下一个事件ID是标签 return windows, targets # 先按时间排序再切分,不要先shuffle events, event2id = parse_to_events(log_lines, min_count=10) split = int(len(events) * 0.8) train_x, train_y = make_windows(events[:split], seq_len=12, stride=1)这个切法有一个隐含前提:日志必须按时间戳排序,且序列在时间轴上连续。HDFS这类日志里每个Block是一条独立会话,不同Block之间没有时序关系,跨会话滑窗会把两个无关流程拼成一个假序列,模型学到的依赖全是幻觉。正确做法是先用会话ID分组,在会话内部滑窗,然后做会话级别的数据划分。
2.3 数据集划分与参数速查表
序列模型的数据划分和表格数据不同,随机切分是大忌。随机切分会把同一会话的相邻窗口同时丢进训练集和测试集,模型等于开卷考试,测试F1虚高到0.95以上,答辩时一问会话边界就穿。常见做法是按时间或按会话划分:前80%时间段的会话进训练集,中间10%进验证集,最后10%进测试集。验证集只用来选阈值和调超参数,测试集只在最终评估时碰一次。
| 参数 | 推荐取值 | 设置理由 |
|---|---|---|
| seq_len | 10~20 | 覆盖日志中最常见的正常流程长度 |
| stride | 1~5 | 1样本最多,5省训练时间 |
| min_count | 5~20 | 过滤冷门模板,控制词汇表规模 |
| 训练/验证/测试 | 8:1:1 按时间 | 避免时序泄露 |
| UNK编号 | 0 | 配合Embedding的padding_idx=0 |
seq_len选小了看不到完整流程,长依赖学不到;选大了样本量下降,训练变慢,还会把两个独立流程粘在一起。数值特征如果也要进模型,比如相邻日志的时间间隔、会话内事件计数,归一化只能用训练集统计出来的均值和方差,验证集和测试集套同一组统计量,各自归一化会泄露分布信息。事件ID走Embedding,数值特征拼到LSTM输出之后,两者不能混进同一个输入张量。
3. 搭建LSTM日志异常检测模型:PyTorch实现与关键参数
3.1 建模路线:为什么是下一事件预测而不是二分类
拿到带标签的数据集,第一反应往往是训练一个正常/异常二分类模型。实际做下来会发现这条路很难走:异常日志在真实数据里占比通常不到5%,类别极不平衡,而且异常模式五花八门,有限的异常样本喂不饱分类器。换个任务就顺了。正常日志的流程是有规律的,用前面seq_len个事件预测下一个事件,正常数据的预测概率高;异常日志打乱了流程,下一个事件的概率会掉到低值区,甚至根本不是这条流程的正常后继。训练阶段只用正常日志,预测偏差本身当作异常分数。这种基于LSTM的日志异常检测模式,2017年DeepLog那篇工作就叫日志键预测(log key prediction),期末复现不需要完整论文实现,Embedding加LSTM加全连接就够。
还有个工程上的好处:模型的输出是"这个事件出现得有多反常",而不是生硬的正常/异常。判定阈值可以按告警量需求随时调整,告警太多就把阈值调高,漏报变多就调低,不需要重新训练。这对日志这类噪音数据特别重要,因为系统里总有少量合法但低频的事件,二分类模型很难区分它们和真异常。
3.2 模型结构:Embedding + LSTM + 全连接
import torch import torch.nn as nn class LstmLogAnomaly(nn.Module): def __init__(self, vocab_size: int, embedding_dim: int = 64, hidden_size: int = 128, num_layers: int = 2, dropout: float = 0.3, padding_idx: int = 0): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=padding_idx) self.lstm = nn.LSTM(embedding_dim, hidden_size, num_layers, batch_first=True, dropout=dropout) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_size, vocab_size) def forward(self, x: torch.Tensor) -> torch.Tensor: emb = self.embedding(x) # (B, L, D):事件ID -> 稠密向量 out, _ = self.lstm(emb) # (B, L, H):每个时间步一个隐状态 last = out[:, -1, :] # 只用最后一个时间步 logits = self.fc(self.dropout(last)) # (B, V):下一个事件的分数 return logitsforward里每一步的维度变化都标在注释里。Embedding是vocab_size行乘embedding_dim列的表,每行对应一个事件ID;padding_idx=0的行不会被梯度更新,始终是零向量,这样补齐位的信号就传不进去。LSTM输入是序列长度L个embedding向量,输出每个时间步的隐状态,hidden_size是隐状态维度。取最后一步的隐状态送入全连接,得到vocab_size个logits,过softmax就是下一个事件的概率分布。num_layers=2表示堆两层LSTM,dropout参数在层与层之间生效;最后一层到全连接之间再用一个Dropout防过拟合。
3.3 训练代码与超参数联动
训练部分最需要注意的是数据加载和梯度裁剪。日志序列比较短,batch内部几乎不需要padding,但为了统一形状还是要把窗口补成等长;用padding_idx=0之后补齐位不参与损失计算。CrossEntropyLoss的ignore_index=0直接跳过UNK和补齐位,一举两得。
from torch.utils.data import DataLoader, TensorDataset def build_loader(events: list[int], seq_len: int = 12, batch_size: int = 128, shuffle: bool = True): x, y = make_windows(events, seq_len=seq_len) ds = TensorDataset(torch.tensor(x), torch.tensor(y)) return DataLoader(ds, batch_size=batch_size, shuffle=shuffle) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LstmLogAnomaly(vocab_size=len(event2id)).to(device) criterion = nn.CrossEntropyLoss(ignore_index=0) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(20): model.train() total_loss = 0.0 for bx, by in build_loader(train_events, shuffle=True): bx, by = bx.to(device), by.to(device) optimizer.zero_grad() logits = model(bx) # (B, V) loss = criterion(logits, by) # 交叉熵 loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss += loss.item() * bx.size(0) print(f'epoch {epoch+1:02d} loss {total_loss / len(train_events):.4f}')梯度裁剪在LSTM训练里是必需品而不是可选项。日志序列虽然短,但logits的类别数等于词汇表大小,某些batch里可能出现极端softmax输出,梯度范数一下冲到几十,loss直接飞掉。clip_grad_norm_把梯度范数限制在5.0以内,训练曲线会平稳很多。
| 超参数 | 常见取值 | 设置原则 |
|---|---|---|
| embedding_dim | 64~128 | 事件类别几百个时64够用 |
| hidden_size | 64~256 | 和seq_len正相关,窗长20建议128以上 |
| num_layers | 1~2 | 日志序列短,2层到头 |
| dropout | 0.2~0.5 | 训练过拟合就调大 |
| learning_rate | 1e-3(Adam) | 曲线震荡就降到3e-4 |
| batch_size | 64~256 | 内存够就偏大,梯度更平稳 |
| clip_grad_norm | 5.0 | 防梯度爆炸 |
embedding_dim和hidden_size的联动关系是:事件类别数决定Embedding维度下限,seq_len决定LSTM容量需求。窗长加到20以上而hidden_size还停在64,模型的记忆容量不够,训练loss降不下去;反过来窗长12配256维hidden,多数情况下是浪费。num_layers超过2层在这个任务里收益很小,反而引入更难调的梯度问题。
def save_checkpoint(model, event2id, path='checkpoint.pt'): torch.save({ 'state_dict': model.state_dict(), 'event2id': event2id, 'vocab_size': model.embedding.num_embeddings, 'embedding_dim': model.embedding.embedding_dim, 'hidden_size': model.lstm.hidden_size, 'num_layers': model.lstm.num_layers, }, path)checkpoint里存的是重建模型所需的全部参数,不只是权重。评审拿到的源码往往只有训练脚本,模型是在脚本里动态构造的,如果checkpoint不记录结构参数,换个环境就只能重新训练,复现性就打折了。这几个字段与LstmLogAnomaly的构造函数一一对应,加载时直接还原。
4. 异常判定与评估:阈值、混淆矩阵和F1
4.1 打分方式:top-k命中与负对数概率
模型对每个窗口输出一个事件词汇表上的概率分布。把真实下一个事件的概率取出来,就是这条窗口的异常程度:概率越低越可疑。为了数值可读,实际打分用负对数分数score = -log P(真实事件),正常窗口趋向0,异常窗口明显偏大。分数是连续的,可以画直方图,也可以按百分位取阈值。
top-k命中是另一条判定路线:真实事件落在预测概率最高的k个事件里,就判正常,否则判异常。DeepLog用的就是这个策略。它的好处是不需要标定阈值,k=10是常用默认值;缺点是粒度粗,k调大漏报增加,k调小误报增加,而且解释不了"到底多异常"。期末报告我建议用负对数概率加阈值,因为可以在分数分布图上直观展示阈值位置,答辩时一张图就能讲清楚。
4.2 阈值选取:在验证集上搜索F1
阈值不能看着训练集的分数分布拍脑袋定,也不能直接在测试集上选——在测试集上选出的阈值会让测试F1失去说服力。正确顺序是:验证集窗口分数全部算出来,在验证集上搜索使F1最高的阈值,固定这个阈值,再在测试集上算最终指标。搜索网格用分数分布的1到99百分位就够了,不用枚举所有实数。
import numpy as np from sklearn.metrics import f1_score def compute_scores(model, loader, device): model.eval() scores, labels = [], [] with torch.no_grad(): for bx, by in loader: bx, by = bx.to(device), by.to(device) logits = model(bx) prob = torch.softmax(logits, dim=-1) real_prob = prob.gather(1, by.unsqueeze(1)).squeeze(1) scores.extend((-torch.log(real_prob + 1e-9)).cpu().tolist()) labels.extend(by.tolist()) return np.array(scores), np.array(labels) def search_threshold(scores, labels, grid=None): if grid is None: grid = np.percentile(scores, np.arange(1, 100, 1)) best_th, best_f1 = None, -1.0 for th in grid: preds = (scores >= th).astype(int) f1 = f1_score(labels, preds, zero_division=0) if f1 > best_f1: best_th, best_f1 = th, f1 return best_th, best_f1compute_scores逐个窗口算负对数分数,labels来自数据集自带的窗口或会话标签。这里有一个容易被忽略的粒度问题:很多数据集只提供会话级标签,不提供窗口级标签。这时先按会话聚合,比如取会话内所有窗口分数的最大值作为会话分数,再用会话分数与会话标签搜索阈值。聚合函数用max而不是mean,因为一个异常会话只要有一个窗口分数很高就该被抓出来,平均会把异常稀释掉。
4.3 评估指标与混淆矩阵解读
类别不平衡下accuracy没有参考价值:异常会话占5%时全判正常也有95%的accuracy。期末报告必须以精确率、召回率、F1为核心指标,再配一个混淆矩阵说明误报和漏报的绝对数量。精确率对应告警正确率,误报多会拖低它;召回率对应异常检出率,漏报多会拖低它;F1是两者的调和平均。
| 指标 | 含义 | 报告里的解释口径 |
|---|---|---|
| 精确率 Precision | TP / (TP+FP) | 告警里真的异常占多少,衡量误报 |
| 召回率 Recall | TP / (TP+FN) | 真正的异常被抓出多少,衡量漏报 |
| F1 | 2PR / (P+R) | 类别不平衡时的综合指标 |
| Top-10命中率 | 正常窗口真实事件在top-10的比例 | 正常流程建模的准确度 |
threshold, _ = search_threshold(val_scores, val_labels) test_preds = (test_scores >= threshold).astype(int) from sklearn.metrics import confusion_matrix, precision_score, recall_score, f1_score cm = confusion_matrix(test_labels, test_preds) tp, fp, fn, tn = cm.ravel() print(f'TP={tp} FP={fp} FN={fn} TN={tn}') print(f'Precision={precision_score(test_labels, test_preds):.3f} ' f'Recall={recall_score(test_labels, test_preds):.3f} ' f'F1={f1_score(test_labels, test_preds):.3f}')拿到数字后最值得做的检查:把FN对应的会话翻出来,看它们的窗口分数分布。如果异常会话里大部分窗口分数都很低,只是个别窗口高,说明seq_len太短,异常事件在窗口里占比太少,被正常事件掩盖;把seq_len调大或者改回按会话聚合,F1往往有明显提升。如果FP大量存在,说明阈值压低时模型把正常流程里的偶发事件也当成异常,这时候优先怀疑数据划分是否泄露,而不是急着加复杂模型。
5. 期末大作业交付时最该检查的五个细节
代码能跑和能复现是两回事。交付前把下面这些点过一遍,省得到答辩现场才发现日志解析规则漏了、checkpoint加载报错、或者F1高得连自己都不信。下面这张排错对照表,每行都是实际项目里反复出现的场景。
5.1 一张排错对照表
| 现象 | 直接原因 | 处理方式 |
|---|---|---|
| 训练loss不降 | 学习率过大或事件ID从1开始导致padding冲突 | lr降到3e-4,确认UNK=0且padding_idx=0 |
| 测试F1远低于验证 | 随机切分导致同一会话的窗口跨集 | 改成按会话切分,重新训练 |
| 所有会话全判异常 | 阈值取在分数分布右尾之外 | 在验证集上按百分位网格重新搜索 |
| 召回率高但误报爆炸 | 会话聚合用了mean,异常被正常窗口稀释 | 聚合改max,或调大seq_len |
| 换机器加载模型报错 | checkpoint只存了state_dict | 恢复时用save_checkpoint里记录的config重建 |
5.2 端到端推理验证
最后一步是验证源码离开训练脚本后能不能独立跑。推理脚本必须和训练共用同一个parse_to_events,两个脚本各写一份模板替换逻辑是最容易翻车的做法:训练时把0x1F归到HEX,推理时正则没写全,事件ID整个错位,预测结果全乱。推理时的min_count要设成1,因为线上新日志里可能出现训练集没见过的事件,它会被归到UNK,而UNK本身就是一个合理的异常信号。
def infer_one(model, event2id, logs: list[str], seq_len=12, k=10): events, _ = parse_to_events(logs, min_count=1) x = torch.tensor([events[-seq_len:]], dtype=torch.long) model.eval() with torch.no_grad(): prob = torch.softmax(model(x), dim=-1) topk_ids = torch.topk(prob, k).indices[0].tolist() id2event = {v: k for k, v in event2id.items()} return [id2event.get(i, 'UNK') for i in topk_ids]拿到top-k事件候选后,把其中不在训练会话正常后继里的事件列出来,人工看一眼模板文本,多半能直接定位故障类型。要判断模型真的学到了时序依赖而不是记住了事件频率,做一个对照实验:把训练集事件随机打乱顺序,其他参数一律不动,重新训练。如果F1明显下降,说明模型依赖的是事件顺序信息;如果F1几乎不变,说明LSTM部分只是摆设,seq_len设多大都没区别。这个对照实验放进报告里,比贴十张训练曲线都有说服力。
本文还有配套的精品资源,点击获取