ResNet深度神经网络异常检测:A/B映射与多输出策略 简介这份PDF文档面向从事机器学习、深度学习与数据建模的研究者及工程人员聚焦异常检测中自编码器易过拟合、误报率偏高的痛点提出一种基于ResNet深度神经网络的改进模型。资源包共1个文件为1.59MB的PDF论文内容完整呈现模型设计思路与实验验证过程。文档以固定切分规则将数据分为A、B两部分训练网络学习A到B的映射并引入L2正则化与相应代价函数抑制过拟合测试时通过输出误差与阈值判定正常与异常同时借助ResNet残差层缓解梯度消失问题。文中还结合KDDCup99数据集给出检测率与误报率结果并围绕神经网络、深度学习、ResNet、代价函数等概念展开论述适合需要理解异常检测建模流程、复现实验或撰写相关论文的读者参考。目前已有181人学习。1. 从自编码器的误报泥潭说起为什么换条路做异常检测做过网络入侵检测的同行大概都有过这种体验自编码器Autoencoder训完正常流量重构得挺漂亮一上测试集误报率直接飙到让你怀疑人生。KDDCup99 这种经典数据集上多层自编码器的误报率能到 3.91%检测率却只有 93.26%——看着还行但放到真实生产环境里每天几百万条连接记录这个误报量足够让运维把告警群屏蔽掉。这篇《基于ResNet深度神经网络的异常检测模型》走了一条不太一样的路。它不要求网络重构输入而是把每条数据按固定规则切成 A、B 两部分让网络学 A→B 的映射。正常数据的 A 和 B 之间存在稳定的统计关联异常数据进来这个映射关系对不上误差自然就大了。再配合 ResNet 残差层压住梯度消失L2 正则化控制过拟合最终在 KDDCup99 上把误报率压到 1.00%检测率反而升到 94.5%。这套思路适合谁如果你手头有标注好的正常流量数据想搭一个半监督异常检测基线又受够了自编码器调参时那种“重构 loss 降了但误报不降”的玄学这份论文里的模型结构和训练策略值得逐行拆一遍。下面我从数据预处理开始把能复现的步骤和参数选择逻辑摊开讲。2. 数据切分与预处理A/B 划分不是随便砍一刀2.1 KDDCup99 的 41 维到 123 维独热编码与归一化KDDCup99 原始数据每条连接有 41 个特征其中 7 个是符号型protocol_type、service、flag 等34 个是连续数值型。神经网络只吃数值所以符号特征必须做独热编码。论文里的做法是7 个符号特征独热展开后加上 34 个连续特征最终得到 123 维输入。连续特征归一化用 min-max公式很标准import numpy as np import pandas as pd # 假设 df 是原始 KDDCup99 数据列名按标准数据集 categorical_cols [protocol_type, service, flag] # 实际有 7 个符号特征这里以 3 个为例其余同理 numeric_cols [c for c in df.columns if c not in categorical_cols [label]] # 独热编码 df_encoded pd.get_dummies(df, columnscategorical_cols) # min-max 归一化只对连续特征做 for col in numeric_cols: col_min df_encoded[col].min() col_max df_encoded[col].max() if col_max - col_min 0: df_encoded[col] (df_encoded[col] - col_min) / (col_max - col_min) else: df_encoded[col] 0.0 # 最终特征矩阵 X df_encoded.drop(label, axis1).values.astype(np.float32) print(f特征维度: {X.shape[1]}) # 应输出 123这段代码的关键点在于归一化必须在独热编码之后、且只对连续列做。如果你先归一化再独热符号列会被错误地当成数值处理。另外min-max 的分母要加保护KDDCup99 里某些特征在 10% 子集上可能方差为 0直接除会出 NaN。提示论文用的是 10% 训练子集97278 条正常数据做训练测试集用完整的 corrected 文件295068 条。如果你手头只有完整训练集建议按同样比例抽样保持分布一致。2.2 为什么选 1-82 维做 B、83-123 维做 A这是整个模型最反直觉的一步。论文把 123 维切成两段1-82 维作为标签 B83-123 维共 41 维作为输入 A。乍一看B 的维度82比 A41多一倍网络要学的映射是“少→多”这跟自编码器“多→少→多”的压缩重构完全不是一回事。作者的解释是1-82 维里第 1 维是连续数值2-82 维实际上是 3 个符号特征独热展开来的大部分位置是 0只有 3 个位置是 1。所以 B 的实际信息量远小于 82 维这个数字给人的印象。而 83-123 维这 41 维里连续数值特征占多数信息密度高。让信息密度高的部分去预测信息密度低的部分一对多关系会少很多。我自己的经验是如果你的数据集没有这种“独热膨胀”结构切分规则要重新设计。一个可操作的启发是——对每个特征算一下熵或者方差把高信息量的特征划到 A低信息量的划到 B同时保证 A 的维度不要太小太小容易过拟合也不要太大太大一对多关系爆炸。# 按论文规则切分 A X[:, 82:123] # 输入41 维 B X[:, 0:82] # 标签82 维 # 检查一下 B 的稀疏程度 print(fB 中非零元素占比: {np.count_nonzero(B) / B.size:.4f}) # KDDCup99 上大概在 0.15 左右确实很稀疏2.3 训练/测试划分与 batch 设置论文训练用 10% 子集的正常数据测试用 corrected 文件全部数据。训练终止条件是固定跑 150 个 epochbatch size 256。这个 256 不是拍脑袋来的——论文里提到 GPU 显存限制GT 7302GB 显存256 是能跑满又不爆显存的折中。如果你用现代显卡batch 可以加大到 512 或 1024但要注意batch 太大时RMSProp 的梯度估计会变准但泛化可能略降。我一般会从 256 起步观察验证集上的 F1 变化再调。from torch.utils.data import DataLoader, TensorDataset import torch # 只取正常数据训练 normal_mask (y_train 0) A_train torch.tensor(A[normal_mask], dtypetorch.float32) B_train torch.tensor(B[normal_mask], dtypetorch.float32) train_dataset TensorDataset(A_train, B_train) train_loader DataLoader(train_dataset, batch_size256, shuffleTrue) # 测试集包含正常和异常 A_test torch.tensor(A_test_all, dtypetorch.float32) B_test torch.tensor(B_test_all, dtypetorch.float32) test_dataset TensorDataset(A_test, B_test) test_loader DataLoader(test_dataset, batch_size256, shuffleFalse)注意训练时只喂正常数据这是半监督异常检测的标准做法。如果你不小心把异常数据也混进训练集模型会学到异常数据的 A→B 映射检测率直接崩。3. ResNet 层与多输出结构把回归问题改造成“n 选 1”3.1 残差连接为什么能救深层网络论文用了 9 层网络其中 6 层是 ResNet 层。残差层的形式是output F(x) xF 是一个全连接层加激活。这个加法的意义在于反向传播时梯度可以通过恒等映射那条路直接传回前层不用经过 F 里的权重矩阵连乘。对于 9 层这种深度没有残差连接的话梯度消失会让前几层几乎学不到东西。import torch.nn as nn class ResNetBlock(nn.Module): def __init__(self, dim): super().__init__() self.fc nn.Linear(dim, dim) self.act nn.LeakyReLU(0.01) def forward(self, x): residual x out self.fc(x) out self.act(out) return out residual # 残差连接这里有个细节ResNet 块要求输入输出维度一致所以论文里 ResNet 层的输入输出都是 60 或 123。第一个全连接层把 41 维升到 60 维之后三个 ResNet 块保持 60 维再一个全连接层升到 123 维又是三个 ResNet 块保持 123 维最后输出层把 123 维映射到 246 维即 3×82。3.2 多输出 n3 的代价函数设计论文最核心的创新点在这里。正常数据的 A→B 映射大部分是一对一的但总有一些 A 对应多个可能的 B。如果网络只输出一个 B遇到一对多的情况就会学成一个“平均 B”误差反而大。所以论文让网络输出 n 个 B 的候选n3然后取距离最近的那个算误差。但光这样还不够。如果只用最近距离做代价网络可能会让 3 个输出分散到不同方向对异常数据来说3 个候选里只要有一个碰巧接近真实 B误差就小了异常就被漏检。所以论文加了一个 NearCost让 3 个输出互相靠拢class AnomalyDetectionNet(nn.Module): def __init__(self, input_dim41, hidden_dim60, b_dim82, n_outputs3): super().__init__() self.n_outputs n_outputs self.b_dim b_dim self.fc_in nn.Linear(input_dim, hidden_dim) self.res1 ResNetBlock(hidden_dim) self.res2 ResNetBlock(hidden_dim) self.res3 ResNetBlock(hidden_dim) self.fc_mid nn.Linear(hidden_dim, 123) self.res4 ResNetBlock(123) self.res5 ResNetBlock(123) self.res6 ResNetBlock(123) self.fc_out nn.Linear(123, b_dim * n_outputs) self.act nn.LeakyReLU(0.01) def forward(self, x): x self.act(self.fc_in(x)) x self.res1(x) x self.res2(x) x self.res3(x) x self.act(self.fc_mid(x)) x self.res4(x) x self.res5(x) x self.res6(x) x self.fc_out(x) # 重塑为 (batch, n_outputs, b_dim) return x.view(-1, self.n_outputs, self.b_dim)代价函数分三部分def cost_function(pred, target, alpha1e-3, beta1e-2, modelNone): # pred: (batch, n, b_dim), target: (batch, b_dim) # Dist: 每个输出到 target 的欧式距离取最小 diff pred - target.unsqueeze(1) # (batch, n, b_dim) dist_per_output torch.norm(diff, dim2) # (batch, n) dist torch.min(dist_per_output, dim1)[0] # (batch,) # NearCost: n 个输出互相靠拢 near_cost 0.0 for i in range(pred.size(1)): for j in range(i 1, pred.size(1)): near_cost torch.norm(pred[:, i] - pred[:, j], dim1).mean() # L2 正则 l2_reg 0.0 if model is not None: for p in model.parameters(): l2_reg torch.norm(p, 2) ** 2 return dist.mean() alpha * near_cost beta * l2_reg参数说明alpha 控制 NearCost 的权重论文在 1e-3 到 5e-3 之间试最终取 1e-3beta 控制 L2 正则强度取 1e-2。这两个值不能太大否则网络会退化成输出常数。我自己的经验是alpha 超过 1e-2 后3 个输出会几乎重合多输出的意义就没了。3.3 激活函数选 LeakyReLU 而不是 ReLU论文明确对比了 sigmoid、tanh、ReLU、LeakyReLU。sigmoid 和 tanh 的导数在大部分区间小于 19 层连乘后梯度基本消失。ReLU 在负半轴导数为 0一旦某个神经元输出为负它就“死”了反向传播过不去。LeakyReLU 给负半轴一个 0.01 的斜率保证梯度始终有通路。# 对比一下不同激活函数的梯度特性 import torch x torch.linspace(-5, 5, 100) relu_grad (x 0).float() leaky_grad torch.where(x 0, torch.ones_like(x), torch.full_like(x, 0.01)) # relu_grad 在 x0 时全为 0leaky_grad 有 0.01 的底提示LeakyReLU 的负斜率一般取 0.01 到 0.1。论文用 0.01我试过 0.05在 KDDCup99 上差异不大但在其他数据集上可能需要调。4. 训练、阈值选取与避坑记录4.1 RMSProp 优化器与 150 epoch 训练论文用 RMSProp学习率没明说但 PyTorch 默认 1e-2 通常能跑。RMSProp 对非平稳目标函数比 SGD 稳适合这种代价函数里带正则项的场景。import torch.optim as optim model AnomalyDetectionNet() optimizer optim.RMSprop(model.parameters(), lr1e-2) for epoch in range(150): model.train() total_loss 0.0 for A_batch, B_batch in train_loader: optimizer.zero_grad() pred model(A_batch) loss cost_function(pred, B_batch, alpha1e-3, beta1e-2, modelmodel) loss.backward() optimizer.step() total_loss loss.item() if (epoch 1) % 10 0: print(fEpoch {epoch1}, Loss: {total_loss / len(train_loader):.6f})训练到 50 epoch 后TPR 和 F1 基本稳定FPR 还有小幅波动。150 epoch 是论文的设定实际用的时候可以加早停如果验证集 F1 连续 10 个 epoch 不升就停。4.2 阈值 均值 1.5 倍标准差测试时把测试数据喂进网络算每个样本的 Dist最近输出的距离然后统计训练数据 Dist 的均值和标准差阈值取mean 1.5 * std。这个 1.5 是论文试出来的太大漏检太小误报。model.eval() train_dists [] with torch.no_grad(): for A_batch, B_batch in train_loader: pred model(A_batch) diff pred - B_batch.unsqueeze(1) dist torch.norm(diff, dim2).min(dim1)[0] train_dists.append(dist) train_dists torch.cat(train_dists) mean_dist train_dists.mean().item() std_dist train_dists.std().item() threshold mean_dist 1.5 * std_dist # 测试 test_dists [] with torch.no_grad(): for A_batch, B_batch in test_loader: pred model(A_batch) diff pred - B_batch.unsqueeze(1) dist torch.norm(diff, dim2).min(dim1)[0] test_dists.append(dist) test_dists torch.cat(test_dists) y_pred (test_dists threshold).long() # 1 为异常4.3 避坑记录现象、原因、解决坑一独热编码后维度对不上A/B 切分错位现象按 82/41 切分后训练 loss 不降测试检测率接近随机。原因KDDCup99 的 7 个符号特征独热展开后维度不是固定的pd.get_dummies在训练集和测试集上可能产生不同的列数。如果训练集有 123 维测试集只有 120 维切分就全乱了。解决用align对齐列或者手动指定所有可能的类别。更稳的做法是先把训练集和测试集合并做独热再拆开。# 合并后编码再拆 df_all pd.concat([df_train, df_test], axis0) df_all pd.get_dummies(df_all, columnscategorical_cols) df_train df_all.iloc[:len(df_train)] df_test df_all.iloc[len(df_train):]坑二NearCost 权重 alpha 太大3 个输出退化成 1 个现象训练完后3 个输出几乎一模一样多输出没起作用检测率和单输出差不多。原因alpha 设成了 1e-1 甚至更大NearCost 主导了代价函数网络为了降低 NearCost 直接把 3 个输出拉平。解决alpha 从 1e-3 起步观察 3 个输出的方差。如果方差小于 1e-4说明靠得太近调小 alpha。坑三阈值用测试集统计量算导致数据泄露现象测试集上 F1 很高但换一批新数据就崩。原因阈值应该用训练集的 Dist 分布来定如果用测试集的均值和方差等于提前知道了测试数据的分布。解决严格按论文阈值只用训练数据算。测试时直接套用。坑四ResNet 块输入输出维度不一致加法报错现象RuntimeError: The size of tensor a (60) must match the size of tensor b (123)。原因ResNet 块的残差连接要求F(x)和x维度相同。如果在 ResNet 块前后加了改变维度的全连接层必须把残差连接跳过那个全连接层。解决ResNet 块内部不改变维度维度变换放在块外面。坑五GPU 显存不够batch 256 跑不起来现象CUDA out of memory。原因论文用的 GT 730 只有 2GB 显存现代显卡虽然大但如果同时开了其他进程也可能不够。解决减小 batch 到 128 或 64或者用梯度累积模拟大 batch。accumulation_steps 4 for i, (A_batch, B_batch) in enumerate(train_loader): pred model(A_batch) loss cost_function(pred, B_batch, modelmodel) / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()5. 从 94.5% 到更高几个我试过的调优技巧论文在 KDDCup99 上做到 94.5% 检测率、1.00% 误报率已经比自编码器的 93.26%/3.91% 好不少。但如果你想把检测率再往上推或者迁移到自己的数据集下面几个方向值得试。技巧一用 k-d 树分析数据本身的重复性论文最后提到一个很有意思的发现测试集里有 7593 条异常数据和正常数据完全一样占异常总数的 3.03%。这意味着即使模型完美检测率上限也被这些“标签噪声”卡住了。我一般会先跑一遍 k-d 树或者近似最近邻看看数据里有多少完全重复的样本。如果重复率超过 2%先清洗数据再训比调模型管用。from sklearn.neighbors import KDTree # 用正常数据建树 tree KDTree(B_train.numpy()) # 查异常数据里有多少和正常数据完全一样 dist, _ tree.query(B_test[y_test 1].numpy(), k1) exact_matches (dist 1e-6).sum() print(f异常数据中与正常数据完全相同的数量: {exact_matches})技巧二n 值不要死磕 3按数据复杂度调论文选 n3 是因为 KDDCup99 里大部分 A→B 是一对一少部分一对二或一对三。如果你的数据集里一对多关系更普遍n 可以加到 5 或 7。但 n 越大NearCost 越难压住需要同步调小 alpha。我试过一个经验规则n 取ceil(平均一对多关系数) 1先用聚类估一下每个 A 对应几个 B。技巧三阈值系数 1.5 可以按业务调1.5 倍标准差对应的是“宁可误报不可漏报”还是反过来取决于你的业务。安全场景通常更怕漏报可以把系数降到 1.0 甚至 0.8牺牲一点误报率换检测率。论文里也提到检测率到 94.5% 后再想提升误报率会显著上升。这个 trade-off 没有标准答案得看你的告警处理能力。技巧四ResNet 层数不是越多越好论文用 9 层是因为输入只有 41 维信息量有限。如果你把同样的结构搬到 200 维以上的输入可以试着加到 15 层但每加 3 层要观察验证集 loss。我自己的血泪经验是ResNet 层超过 12 层后KDDCup99 上就开始过拟合了训练 loss 降但测试 F1 不升。技巧五用 F1 而不是准确率做模型选择异常检测里正常样本远多于异常样本准确率会被正常样本主导。KDDCup99 测试集里正常 60593 条异常 250436 条异常反而更多但真实场景通常反过来。所以从训练到调参我全程盯 F1 和 TPR/FPR不看 accuracy。from sklearn.metrics import f1_score, confusion_matrix tn, fp, fn, tp confusion_matrix(y_test, y_pred).ravel() tpr tp / (tp fn) fpr fp / (fp tn) f1 f1_score(y_test, y_pred) print(fTPR: {tpr:.4f}, FPR: {fpr:.4f}, F1: {f1:.4f})从那以后我每次搭异常检测模型都先把数据里的重复样本和标签噪声清一遍再开始调网络结构。很多所谓的“模型效果不好”其实是数据本身的上限就在那。这套 ResNet A/B 映射 多输出的组合我后来在流量分类和日志异常检测上都复用过核心思路——用部分预测另一部分、用残差抗梯度消失、用 NearCost 处理一对多——比具体参数更值得带走。希望帮到你。本文还有配套的精品资源点击获取