尧图精选

基于LSTM的KPI异常检测:从数据预处理到阈值调优的源码解析

🕒 发布时间:2026/10/1 3:49:35 📁 来源:尧图网络
简介这是一份基于LSTM的异常检测大赛项目源码包面向人工智能、数据挖掘方向的在校学生和开发者解决AIOps场景中KPI曲线异常检测问题。项目源自真实竞赛数据集覆盖5家互联网公司的26个KPIs包含带标记的训练集与无标记测试集配套数据预处理、LSTM模型训练与预测三大Python脚本并附有训练/测试CSV数据、结果可视化PNG图及README说明文档可完整复现从数据清洗到异常判别的流程。ZIP压缩包内共14个文件以Python脚本、CSV数据集、PNG图表和Markdown文档为主整体大小约56.41MB结构清晰便于直接运行与二次修改。目前已有94人学习/下载适合用于毕设、课程设计或作为LSTM入门进阶的实战参考。代码已经过测试稳定性较有保障文档说明与KPI指标图、评分图等可视化结果能帮助快速理解模型效果与调优方向也可在此基础上继续扩展其他检测算法。1. 基于LSTM的异常检测这份参赛源码值不值得下载在 AIOps 竞赛和实际运维监控里KPI 异常检测是最常见也最刚需的场景线上成百上千条曲线靠人盯是不现实的总得有个模型自动告诉你哪一分钟出问题了。这份基于 LSTM 的异常检测源码正好把一条完整的链路摆在你面前——从 5 家互联网公司收集的 26 个 KPI 时间序列、带标记的 train.csv、无标记的 test.csv到已经插值好的 train_interpolate.csv / test_interpolate.csv再到预处理、训练、预测、评估的整套 python 实现。换句话说它不是一个孤立的算法 demo而是能直接跑的参赛工程。如果你是刚接触时间序列异常检测的工程师或者正在做相关毕设、课程设计这份资源比看论文动手复现要省事得多它对应的就是那类“用 LSTM 预测下一个点再用残差判断异常”的经典方案。下载下来后先花半小时把数据结构和每个脚本的输入输出搞清楚下面我按数据、训练、预测、避坑的顺序把它拆开讲你照着走完一遍基本就能在别的时间序列数据集上迁移。2. 从数据到特征KPI 插值、归一化和滑动窗口的处理链2.1 数据集结构26 个 KPI 在 CSV 里长什么样拿到解压后的 anomaly_detection-master先别急着跑代码看一眼文件清单。data 目录下放着 train.csv、test.csv以及 train_interpolate.csv、test_interpolate.csv 两个插值版本根目录有 preprocessing.py、lstm_train.py、predict.pyimg 目录里则是 data.png、metric.png、score.png 和 kpi_1.png、kpi_2.png、kpi_3.png 这类可视化结果。从“interpolate”这个命名就能推断作者在把数据喂给模型之前已经用预处理脚本生成了一套补全过缺失值的版本后续训练脚本大概率直接读的是插值后的 CSV。打开 train.csv典型的竞赛格式是下面这个结构文件内容是否带 labeltrain.csv训练 KPI 序列有0/1test.csv测试 KPI 序列无train_interpolate.csv插值补全后的训练序列有test_interpolate.csv插值补全后的测试序列无train.csv 里一般就是三列timestamp、value、label。timestamp 是 Unix 时间戳或可解析的日期时间value 是这个 KPI 在对应时刻的取值可能是响应时间毫秒数、CPU 使用率、流量 QPS 等label 只有训练集才有0 表示正常1 表示异常点。这里有个容易被忽略的点26 个 KPI 来自 5 家不同公司形态差异极大。有的 KPI 是强周期性的每天固定时间出现业务高峰有的是缓慢漂移型的比如存储空间使用率还有的是纯高频噪声叠加偶尔尖峰。把这些 KPI 混合在一起做全局归一化或者统一训练一个多变量模型结果通常不会好。更合理的做法是每个 KPI 独立走一遍预处理、训练、预测流程这也是很多参赛队伍的标准策略。你可以在 img 里看到不同 KPI 的曲线图如果 kpi_1.png 和 kpi_3.png 长得完全不像这个判断就成立了。2.2 预处理代码线性插值、min-max 归一化、滑动窗口切分preprocessing.py 要解决三件事缺失值、量纲不一致、模型输入格式。KPI 数据在采集端经常因为重启、网络抖动、agent 挂掉出现空洞LSTM 一旦遇到 NaNloss 直接变成 NaN整个训练就废了。最常见的处理是先对时间轴重采样到固定频率再做线性插值。import pandas as pd def fill_missing(df, methodlinear): # 确保 timestamp 被解析成 datetime 并设为索引 df df.set_index(pd.to_datetime(df[timestamp])) # 原始采集不一定等间隔重采样到分钟级这一步会产生新的 NaN df df.resample(1min).mean() # 线性插值补洞limit_directionboth 让首尾缺失也一起补完 df[value] df[value].interpolate(methodmethod, limit_directionboth) return df.reset_index()逻辑说明先重采样到 1 分钟间隔是为了让序列长度和时间跨度一一对应后面切滑动窗口时每个窗口都代表相同的真实时间长度。interpolate 的 limit_direction 参数很容易被忽略默认只向后插值序列开头的缺失会一直保留到第一个有效值出现LSTM 一读到就报错所以必须显式设置成 both。method 默认 linear 对大多数 KPI 够用如果曲线有明显的非线性趋势可以换 time 试一下它是按时间间隔加权的结果会更平滑。归一化这一步单独拿出来说。不同 KPI 的 value 可能一个在 0.1 附近波动另一个动辄几千不归一化直接进 LSTM梯度更新会非常不稳定。异常检测场景我习惯用 min-max 归一化把值压到 [0, 1]而不是 z-score因为 z-score 会被少量极端异常点拉偏均值和方差而异常点恰恰是我们要检测的对象不能让它们把基准线带歪。def normalize_with_train_stats(train, test): # 只从训练集统计 min 和 max测试集直接用同一套参数 v_min, v_max train[value].min(), train[value].max() train[value] (train[value] - v_min) / (v_max - v_min 1e-9) test[value] (test[value] - v_min) / (v_max - v_min 1e-9) return train, test, v_min, v_max逻辑说明v_min 和 v_max 必须返回到调用方保存下来预测阶段反算原始尺度时要用。1e-9 是防分母为 0 的保险遇到某个 KPI 在训练集里是常数时不会直接除以 0 抛异常。有了连续等间隔、归一化后的序列接下来切窗口。LSTM 的输入要求是 (batch, seq_len, input_size)seq_len 表示用过去多少个点预测下一个点。这个窗口长度直接决定模型能捕获的时间依赖范围项目里常见的是 24也就是用过去 24 分钟预测下一分钟对分钟级 KPI 来说是一个兼顾模式和样本量的选择。import numpy as np def make_windows(values, seq_len24): X, y [], [] for i in range(len(values) - seq_len): X.append(values[i:i seq_len]) y.append(values[i seq_len]) # reshape 成 LSTM 期望的输入形状样本、窗口长度、单变量特征 return np.array(X).reshape(-1, seq_len, 1), np.array(y).reshape(-1, 1)逻辑说明窗口每次只滑动 1 个点样本之间高度重叠。第一次接触时你会觉得这是在浪费数据但逐点预测是异常检测的刚需只有每个时间点都生成一个预测值才能逐点计算残差并判断它是不是异常。如果改成窗口步长 24预测点数会少 24 倍异常定位就失去了意义。第三个维度 1 代表单变量如果以后要多变量输入可以在这一步把多个 KPI 的窗口拼起来。预处理做完你可以随手画一条曲线看看结果。data.png 里如果看到序列首尾都是连续的、没有明显断档说明插值生效了如果曲线里仍然有大段平坦直线那可能是插值把原本尖锐的波动抹平了这种样本喂给模型异常点基本学不到。3. 训练 LSTM 模型网络结构、超参和训练循环怎么搭3.1 网络结构单层 LSTM 加一个输出层为什么够用进入 lstm_train.py 的核心部分模型定义并不复杂。对于分钟级 KPI 这种数据规模每个 KPI 往往只有几千到几万个时间点单层 LSTM 加一个全连接输出层已经足够拟合正常模式了。很多论文喜欢讲深层 LSTM 多厉害但在这种小样本时间序列上层数加深的第一个副作用就是过拟合训练集 loss 一路往下掉验证集却在几个 epoch 后开始反弹。一个常见的 PyTorch 模型结构是这样import torch.nn as nn class LSTMDetector(nn.Module): def __init__(self, input_size1, hidden_size64, num_layers1): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue ) self.dropout nn.Dropout(0.2) # 防止过拟合只训练时生效 self.fc nn.Linear(hidden_size, 1) def forward(self, x): out, (h_n, c_n) self.lstm(x) # 返回每个时间步的输出和最后状态 last out[:, -1, :] # 取最后一个时间步的隐藏状态 last self.dropout(last) return self.fc(last) # 输出单个预测值逻辑说明batch_firstTrue 让输入的 shape 变成 (batch, seq_len, input_size)调试的时候更直观。out[:, -1, :] 取的是每个序列最后一个时刻的 LSTM 输出因为我们要用过去的 seq_len 个点预测下一个点整个窗口的信息已经被压缩进了最后一个时间步的状态里。dropout 放在全连接层前默认识别阶段会自动关闭不需要手动切换。hidden_size 控制记忆容量64 是一个折中值周期特别长的 KPI 可以加到 128但再往上收益就很小了。注意一个容易踩的坑输出层不要接 sigmoid 这类激活函数。lstm 本身的输出已经经过了 tanh 变换预测层保持线性即可。如果硬加激活函数预测值被压缩在 [0, 1]残差分布会被扭曲后面阈值扫描全乱套。3.2 超参数seq_len、hidden_size、batch_size、learning_rate 怎么调这部分经常被当成玄学其实可以按数据特征推导。seq_len 默认 24如果 KPI 有明显的天级周期性比如每天固定时间线上会有一个业务高峰我会加大到 96 甚至 144让模型看到一个完整的高峰形态如果是噪声主导的秒级指标窗口太长反而引入无关注意力调回 12 更稳。hidden_size 和 seq_len 联动窗口变长意味着要压缩的信息更多hidden_size 也要跟着涨但不要超过 128。batch_size 影响梯度稳定性时间序列样本量不大32 足够。下面是我在这个项目上常用的起点参数参数推荐起点调整方向seq_len24周期性强调大噪声强调小hidden_size64数据量大调 128数据小调 32num_layers1非必要不加深batch_size32顺滑时可试 64learning_rate1e-3loss 震荡时降为 1e-4epochs50不硬跑配早停这些参数不是独立的。seq_len 翻倍hidden_size 也要往上调一档因为更长序列要记忆的信息更多hidden_size 提升后dropout 比例最好同步从 0.1 提到 0.2 或 0.3否则测试集 F1 会下降。learning_rate 是最敏感的参数Adam 默认 1e-3 前期收敛很快但后期会在 loss 平台期来回震荡这时候降到 1e-4 往往能再下探一截。3.3 训练循环MSE 损失、early stopping、模型保存训练阶段的目标是让模型学会预测正常模式。我们用 MSE 作为损失函数它对大的残差更敏感能让模型优先拟合那些明显偏离规律的波动。训练代码里最重要的是 early stopping 和保存最优权重很多人训练完直接拿最后一个 epoch 的模型去预测结果往往不是最好的那版。def train_model(model, train_loader, val_loader, epochs50, lr1e-3, patience5): optimizer torch.optim.Adam(model.parameters(), lrlr) criterion nn.MSELoss() best_val float(inf) wait 0 for epoch in range(epochs): model.train() train_loss 0.0 for x_batch, y_batch in train_loader: optimizer.zero_grad() pred model(x_batch) loss criterion(pred, y_batch) loss.backward() optimizer.step() train_loss loss.item() model.eval() with torch.no_grad(): val_loss 0.0 for x_val, y_val in val_loader: val_loss criterion(model(x_val), y_val).item() val_loss / len(val_loader) print(fepoch {epoch1}: train_loss{train_loss:.4f}, val_loss{val_loss:.4f}) if val_loss best_val: best_val val_loss torch.save(model.state_dict(), best_model.pt) wait 0 else: wait 1 if wait patience: break逻辑说明每个 epoch 先用训练集做前向计算和反向传播再用验证集算一次当前模型的平均损失。只有当验证集 loss 降到历史最低时才把模型权重保存下来确保后面加载的是泛化能力最好的一版。patience5 表示连续 5 个 epoch 验证集不下降就提前终止训练实际跑下来通常 20~30 个 epoch 就会触发早停硬跑满 50 反而可能会过拟合。验证集怎么切是有讲究的这里先埋个伏笔如果随机打乱再切LSTM 会通过时间依赖偷看到未来信息验证集 loss 会失真。正确做法是保留最后一段连续时间作为验证集。这个问题会在避坑章节详细展开。4. 预测与评估把 LSTM 残差变成异常标签4.1 预测流程逐点预测、残差计算和反归一化训练阶段在找最优参数预测阶段则完全不同模型已经固定输入历史窗口得到下一个点的预测值然后用真实值和预测值的差来判断异常。这个差就是残差残差越大说明当前点越不符合模型学到的正常模式越可能是异常。predict.py 的核心逻辑大致如下def predict(kpi_values, model, seq_len24, v_min0, v_max1): model.eval() preds [] with torch.no_grad(): for i in range(seq_len, len(kpi_values)): window kpi_values[i - seq_len:i].reshape(1, seq_len, 1) pred model(torch.tensor(window, dtypetorch.float32)) preds.append(pred.item()) actual kpi_values[seq_len:] residuals np.array(preds) - actual # 把归一化空间的残差反算回原始单位画 score 图时更直观 residuals residuals * (v_max - v_min) return residuals逻辑说明循环从 seq_len 开始保证每个窗口都有完整的历史数据。预测的时候关闭梯度计算除了节省内存也可以禁止模型在推理阶段更新任何统计量。残差保留正负号而不是取绝对值因为异常既可能是预测值远高于真实值的突然下跌也可能是远低于真实值的突然飙升只保留正残差会漏掉一半异常。最后乘上 (v_max - v_min)是为了把残差从归一化空间还原到原始值域这样设置阈值时有物理意义比如响应时间偏移超过 200 毫秒就算异常。这里有个隐藏细节predict 输出的残差长度比原始序列少了 seq_len 个点因为最前面那 seq_len 个点没有足够的历史窗口来预测。后面评估时label 也要从 seq_len 位置开始切否则两个数组长度不一致直接就崩了。4.2 评估指标precision、recall、F1 和阈值调整策略训练集里带 label就是为了本地评估模型效果。异常检测本质上是正负样本极不平衡的分类问题假设 1% 的异常率全猜正常也有 99% 准确率所以准确率在这里没有参考价值竞赛和实际监控里更常用 F1。precision 衡量你标出的异常里有多少是真异常recall 衡量所有真异常里有多少被你标出来了F1 是两者的调和平均。评估代码很直接from sklearn.metrics import precision_score, recall_score, f1_score # threshold 从残差分位数取而不是固定 3σ threshold np.percentile(np.abs(residuals), 95) pred_label (np.abs(residuals) threshold).astype(int) true_label test_label[seq_len:] # 对齐 predict 丢掉的头部 print(precision:, precision_score(true_label, pred_label)) print(recall:, recall_score(true_label, pred_label)) print(F1:, f1_score(true_label, pred_label))逻辑说明threshold 用 95 分位数只是一个起点表示把残差最大的 5% 点判为异常这个比例参考了异常点在 KPI 里的大致占比。真正确定阈值时不要只看这一档。我一般会扫描 90 到 99 分位之间的所有百分位每个阈值算一次 F1取 F1 最大时对应的阈值作为最终参数。这个过程代码上就是一个 for 循环却是整条流程里对最终分数影响最大的一个环节。还有一点容易翻车残差分布不是所有 KPI 都长一样。有的 KPI 残差接近正态分布3σ 阈值好用有的残差是偏态的正常波动就经常超过 3σ。所以在多 KPI 场景下每个 KPI 的阈值都应该单独扫描不要用一个全局阈值套全部 26 个序列。5. 复现避坑指南这份源码最常见的 5 个坑下载源码只是第一步能在自己机器上复现并拿到接近原版的分数才是目的。这一章我把实际跑过程中最容易翻车的 5 个问题列出来按现象、原因、解决三步写你照着排查能省下不少时间。5.1 一启动就报 FileNotFoundError路径到底怎么放现象按 README 提示运行 preprocessing.py 或 lstm_train.py控制台立刻报FileNotFoundError: [Errno 2] No such file or directory: data/train.csv。原因代码里的数据路径是相对路径而相对路径的基准是当前终端的工作目录。不少同学下载后先解压再把 data 文件夹单独拖到别处或者直接在 IDE 的默认工作目录里运行脚本路径就找不到了。这类问题看起来小儿科但在实际提交作业、换机器演示时非常常见。解决统一在项目根目录 anomaly_detection-master 下执行脚本更保险的做法是在脚本入口加一行os.chdir(os.path.dirname(os.path.abspath(__file__)))让脚本运行时的当前工作目录固定为脚本自身所在的目录。这样无论你用 pycharm、vscode 还是命令行走都不会因为起始目录不同而翻车。5.2 插值把异常点也补掉了模型学不到异常现象训练 loss 很低但测试集 F1 也很低残差曲线看不到明显的尖峰。原因线性插值是对缺失区域做的平滑补偿如果异常恰好出现在一段连续缺失里插值会把这个真实异常点当成普通缺口填平等于把带 label 的异常样本从训练数据里悄悄删掉了。模型没见过异常自然学不会识别异常。解决做缺失值填充前先用 label 检查缺失窗口里是否包含异常点。如果某个缺失区间内有 label1不要用线性插值改成前向填充或者近邻填充至少保留突变的轮廓。还可以按 KPI 分别统计缺失比例缺失超过 5% 的序列单独选插值策略而不是所有 KPI 套同一个逻辑。5.3 归一化把测试集也纳入计算F1 虚高现象本地调参时 F1 能到 0.9一换数据或提交就掉到 0.6 以下。原因把 train.csv 和 test.csv 拼在一起计算 min、max等于让测试集参与了归一化参数的估计。测试集里的极端值会被拉回正常范围阈值也跟着被优化本地评估结果虚高但真实预测场景拿不到测试集的信息模型自然扛不住分布变化。解决归一化参数只从训练集统计测试集原样复用训练集的 v_min、v_max。写代码时把统计和转换拆成两步训练阶段返回归一化器测试阶段只做变换不要用同一段代码从头到尾处理两个数据集。这也是预处理脚本里最容易悄悄出错的地方review 代码时要格外注意。5.4 验证集随机切分时间顺序被打乱现象验证集 loss 一直明显高于训练集或者 val_loss 曲线在几个 epoch 后开始周期性震荡。原因用了 sklearn 的 train_test_split 默认随机切分LSTM 是时间序列模型随机打乱等于让模型在验证集里看到了本不属于它的未来信息。这种信息泄露会让验证集给出一厢情愿的评估和真实预测场景完全脱节。解决按时间顺序切验证集比如留最后 20% 的连续段当验证集前面的 80% 当训练集。序列比较长的话训练集末尾和验证集开头之间最好留一点缝隙避免验证集第一个窗口依赖了训练集尾部数据。这个缝隙可以小到 24 个点也就是一个 seq_len但别忽略。5.5 默认 3σ 阈值在异常占比低的 KPI 上失灵现象第一个 KPI 评估效果很好第二个 KPI 一个异常都抓不到precision 高但 recall 低得吓人。原因3σ 阈值有一个前提假设就是残差近似正态分布。但不同 KPI 的残差分布差异很大有的偏态严重正常波动就会超过 3σ有的异常点占比极低3σ 对它们等于把阈值抬到了天上。全局固定阈值等于用同一个标准衡量所有 KPI异常检测里这种一刀切很容易失灵。解决对每个 KPI 单独做阈值扫描。如果竞赛只给了无标记测试集就从训练集切一块带 label 的验证段出来用验证段选阈值再应用到测试集。从那以后我养成了一个习惯先画残差分布直方图再选阈值策略而不是一上来就套统计经验值。6. 进阶从残差阈值到更泛化的异常检测技巧6.1 给残差做指数平滑减少尖峰误报KPI 曲线里经常有瞬时抖动单点残差可能猛超阈值但它只是采集噪声不是真正需要告警的异常。一个低成本改进是给残差做指数移动平均EMA把相邻时间点的误差累计起来再判断。对突发型异常EMA 会平滑掉尖峰可能降低 recall但对缓慢漂移型异常比如磁盘慢慢写满、响应时间逐步恶化EMA 能显著提升检测稳定性。实现就是一行循环alpha 0.3 smoothed np.zeros_like(residuals) smoothed[0] residuals[0] for i in range(1, len(residuals)): smoothed[i] alpha * residuals[i] (1 - alpha) * smoothed[i - 1]alpha 越大越跟随原始残差越小越平滑。0.2 到 0.4 是一个常用区间具体值可以用验证集扫一遍。6.2 用对抗验证检查训练集和测试集分布偏差竞赛数据集经常发生训练集和测试集来自不同时间段或不同机房的情况模型直接迁移会掉分。对抗验证是一种快速体检方案把 train.csv 打标签 0test.csv 打标签 1混在一起训练一个二分类器看分类器能不能靠特征区分两个集合。如果 AUC 接近 1说明分布差异已经大到能轻易分辨这时继续用 min-max 归一化意义不大更稳妥的是用 z-score 或者对每个 KPI 单独去趋势、去周期减弱分布漂移的影响。6.3 从单步预测改成序列重构提升对缓慢漂移的敏感性最后一个值得上手试的改动是把预测目标从“预测下一个点”改成“重构整个窗口”。单步预测对缓慢漂移不敏感因为模型每次都顺着上一帧修订预测残差始终不大而窗口重构强迫模型一次性重建完整模式序列局部结构一旦变化重构误差就会明显上升。改动很小forward 里把整个输出序列都接全连接层def forward(self, x): out, _ self.lstm(x) return self.fc(out) # 输出 shape 变为 (batch, seq_len, 1)代价是 loss 从单点误差变成整个窗口的平均误差训练时间略有增加但对工业生产中常见的漂移型故障这个改进比调阈值更能提升 recall。从那以后我每次拿到时间序列异常检测任务都会先做对抗验证确认数据分布再画残差直方图定阈值最后决定用单步预测还是窗口重构。这套流程让我在多个数据集上少走了很多弯路希望这份源码的拆解能帮到你至少下载之后不用再对着报错日志猜了。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →