
简介这份PDF文档面向网络安全与深度学习方向的研究者、算法工程师及高年级学生聚焦传统机器学习异常流量检测依赖人工特征、准确率偏低且难以应对0day等未知攻击的痛点给出CNN与LSTM混合算法的完整实现思路。资源包共1个PDF文件大小约1.08MB内容涵盖卷积神经网络与长短期记忆网络的基本原理、混合模型设计、CIC-IDS2017数据集上的实验验证及结论分析可帮助读者理解如何提取流量数据的时空特征并构建检测模型。文中多种异常流量检测准确率均超过96.9%总体准确率达98.8%同时保持极低误警率并附有与随机森林、SVM、KNN等方法的对比讨论。目前已有378人学习适合作为数据建模与深度学习实战的参考材料也可为撰写相关论文或开展课题研究提供方法借鉴与实验对照。1. 从一份 PDF 标题说起LSTM 加 CNN 做异常流量检测到底在解决什么问题流量异常检测这件事很多团队一开始都是拿阈值和规则硬扛带宽突增、连接数暴涨、某台机器半夜疯狂外连全靠人肉盯 Zabbix 曲线。等到业务上了云、容器一扩缩容正常流量自己就抖得跟心电图一样规则要么天天误报要么把真正的攻击放过去。这份标题里的「基于 LSTM 的卷积神经网络异常流量检测方法」本质上是把两个东西拼在一起CNN 负责从流量特征里抠局部模式LSTM 负责记住时间上的前后关系。单看某一秒的流量包可能平平无奇但把它放进一段连续时间窗口里前后拼接的模式就露馅了。适合谁看手里有 NetFlow、Zeek 日志或者 pcap 抓包数据想从「规则告警」升级到「模型打分」的运维和安全工程师。这篇不聊虚的从特征怎么切、模型怎么搭、参数怎么调一路讲到部署时哪些坑会让你半夜被叫起来。2. 为什么是 CNN 加 LSTM流量数据的两种结构决定了模型选型2.1 流量特征天然是「二维」的空间维度加时间维度先想清楚一件事一条网络流量记录到底长什么样。以最常见的 NetFlow 为例一条记录包含源 IP、目的 IP、源端口、目的端口、协议号、包数量、字节数、持续时间、TCP 标志位组合等字段。如果你把每条记录的这些字段排成一行它就是一个特征向量把连续 N 条记录按时间顺序摞起来就得到一个 N × F 的矩阵N 是时间步F 是每条记录的特征数。这个矩阵有两个方向的信息。横向看同一时刻不同特征之间有相关性比如「目的端口 443 大字节数 短持续时间」组合起来像正常 HTTPS 下载而「目的端口 443 极小包 固定间隔」就可能是心跳外联。纵向看相邻时间步之间有演化关系比如 SYN 包突然增多然后 RST 包跟着涨这是扫描的典型节奏。CNN 擅长在横向的局部窗口里找模式LSTM 擅长在纵向序列上记长期依赖。这就是把两者拼起来的根本理由不是什么「深度学习很火所以都用」。常见做法是先用一维卷积1D-CNN在时间轴上滑动每个卷积核覆盖连续几个时间步的多个特征提取局部时序模式再把卷积输出的特征序列喂给 LSTM让 LSTM 在更高层抽象上建模更长范围的依赖。注意这里 CNN 的「卷积」不是图像那种二维卷积而是一维卷积在时间步方向滑动卷积核的宽度就是它一次看几个连续时间步。2.2 单独用 LSTM 或单独用 CNN 会缺什么单独用 LSTM 处理 N × F 的输入理论上也能跑但有两个实际问题。第一LSTM 参数量大N 稍微长一点比如 100 个时间步训练就慢得让人想砸键盘而且容易过拟合。第二LSTM 对局部突变模式的捕捉不如卷积核直接卷积核可以精确地学到「连续 3 个时间步内字节数递增且端口号相同」这种模式LSTM 要靠门控机制隐式学效率低。单独用 CNN 呢它没有显式的记忆单元只能靠堆叠层数和膨胀卷积来扩大感受野。对于「前面 50 个时间步都很安静第 51 步突然爆发」这种长程依赖普通 CNN 的感受野不够除非你把卷积核开得很大但那样参数又爆炸。所以工程上常见的折中是CNN 做前端特征提取把序列长度压缩、特征维度升高再交给 LSTM 做时序建模。这样 LSTM 的输入序列更短、每步信息更浓缩训练效率和效果都更稳。2.3 一个最小可跑的模型结构定义下面这段 PyTorch 代码定义了一个最简版的 CNNLSTM 异常流量检测模型。输入形状是 (batch_size, time_steps, features)先转置给 Conv1d 用卷完再转回来给 LSTM。import torch import torch.nn as nn class CNNLSTMAnomalyDetector(nn.Module): def __init__(self, n_features, cnn_channels64, kernel_size3, lstm_hidden128, lstm_layers2, dropout0.3): super().__init__() # 一维卷积在时间步方向滑动输入通道数n_features输出通道数cnn_channels self.conv1 nn.Conv1d(in_channelsn_features, out_channelscnn_channels, kernel_sizekernel_size, paddingkernel_size // 2) self.bn1 nn.BatchNorm1d(cnn_channels) self.relu nn.ReLU() self.pool nn.MaxPool1d(kernel_size2) # 时间步减半降低 LSTM 负担 # LSTM输入维度是卷积输出通道数batch_firstTrue 让输入形状为 (batch, seq, feature) self.lstm nn.LSTM(input_sizecnn_channels, hidden_sizelstm_hidden, num_layerslstm_layers, batch_firstTrue, dropoutdropout if lstm_layers 1 else 0) self.dropout nn.Dropout(dropout) self.fc nn.Linear(lstm_hidden, 1) # 二分类异常/正常 def forward(self, x): # x: (batch, time_steps, n_features) - Conv1d 需要 (batch, n_features, time_steps) x x.permute(0, 2, 1) x self.relu(self.bn1(self.conv1(x))) x self.pool(x) # 时间步压缩 x x.permute(0, 2, 1) # 转回 (batch, time_steps//2, cnn_channels) out, (h_n, c_n) self.lstm(x) # 取最后一个时间步的隐状态做分类 last_hidden out[:, -1, :] logit self.fc(self.dropout(last_hidden)) return logit.squeeze(-1) # 输出 (batch,)逻辑说明Conv1d的in_channels必须等于特征数out_channels是卷积核个数决定了提取多少种局部模式。paddingkernel_size//2保证卷积后时间步长度不变方便后面池化。MaxPool1d(2)把时间步减半这是为了控制 LSTM 的序列长度避免训练太慢。LSTM 的input_size必须等于卷积输出通道数batch_firstTrue让输入输出都用 (batch, seq, feature) 的格式不容易搞混。最后取out[:, -1, :]即最后一个时间步的隐状态因为 LSTM 已经把前面所有时间步的信息累积到了这个向量里。参数说明cnn_channels一般从 32 到 128 之间试特征维度高就取大一点kernel_size常用 3 或 5对应一次看 3 到 5 个连续时间步lstm_hidden128 或 256 是常见起点再大容易过拟合lstm_layers1 到 2 层足够超过 2 层收益很小但训练时间翻倍dropout0.2 到 0.5 之间异常样本少的时候往 0.5 靠。3. 数据从哪来、特征怎么切把 pcap 变成模型能吃的张量3.1 公开数据集和自采数据的取舍做异常流量检测数据是第一个拦路虎。公开数据集常见的有 CIC-IDS2017、UNSW-NB15、NSL-KDD。NSL-KDD 太老特征维度低拿它跑出来的指标好看但放到真实环境基本翻车我一般只用来验证代码跑不跑得通。CIC-IDS2017 相对新一些包含正常流量和多种攻击流量特征也丰富适合做基线对比。UNSW-NB15 的流量分布更接近现代网络但类别不平衡严重。自采数据的话常见做法是在镜像端口上抓 pcap用 Zeek 或 Argus 生成连接级日志再聚合成时间窗口。注意自采数据最大的问题是标注。你不可能人工标几十万条流量通常是用已知的攻击工具在隔离环境里打一遍把那个时间段标成异常其余标正常。这种标注方式会引入噪声因为攻击时间段里也有正常流量混着。3.2 时间窗口切分滑动窗口的步长和长度怎么定把连续流量切成 N × F 的样本核心是两个参数窗口长度 N 和滑动步长 S。N 太小LSTM 记不住长程模式N 太大训练慢且容易把不相关的流量混进一个样本。经验值是 N 取 20 到 100 个时间步S 取 N 的一半到 N 之间。如果流量采集频率是每秒一条N60 就是看一分钟的窗口。下面这段代码演示了用 pandas 做滑动窗口切分的过程。假设你已经有了一个 DataFrame每行是一个时间点的流量特征最后一列是标签。import numpy as np import pandas as pd def make_sliding_windows(df, feature_cols, label_col, window_size60, stride30): df: 按时间排序的 DataFrame feature_cols: 特征列名列表 label_col: 标签列名0 正常 1 异常 window_size: 每个样本包含的时间步数 stride: 滑动步长 返回: X shape (n_samples, window_size, n_features), y shape (n_samples,) data df[feature_cols].values.astype(np.float32) labels df[label_col].values X, y [], [] for start in range(0, len(data) - window_size 1, stride): end start window_size window data[start:end] # 标签取窗口内异常占比超过 10% 就算异常样本 window_labels labels[start:end] label 1 if window_labels.mean() 0.1 else 0 X.append(window) y.append(label) return np.array(X), np.array(y)逻辑说明循环从 0 开始每次跳stride步取window_size长度的片段。标签的处理是关键不能简单取窗口最后一个时间步的标签因为异常可能只出现在窗口中间某几步。这里用窗口内异常标签的均值超过 0.1 就标为异常这个阈值可以根据实际数据调。如果异常流量占比很低可以降到 0.05。参数说明window_size和stride需要根据采集频率和攻击持续时间来定。如果攻击持续几十秒采集频率每秒一条window_size至少 60 才能覆盖完整攻击过程。stride越小样本越多但重叠也越多训练时要注意重叠样本不能同时出现在训练集和验证集里否则验证指标虚高。3.3 特征归一化和类别不平衡处理流量特征的量纲差异极大字节数可能是几万端口号是 0 到 65535协议号是个位数。不归一化的话CNN 的卷积核会被大数值特征主导。常见做法是对每个特征做 z-score 标准化用训练集的均值和标准差验证集和测试集用同样的参数。类别不平衡是另一个必须处理的问题。正常流量通常占 95% 以上异常不到 5%。直接训练的话模型把所有样本都预测成正常就能拿到 95% 准确率但召回率是 0。常见做法有三种一是对异常样本过采样比如 SMOTE二是在损失函数里给异常类更高的权重三是用 focal loss。我一般先用带权重的 BCEWithLogitsLoss简单直接。from torch.utils.data import DataLoader, TensorDataset import torch # 假设 X_train, y_train 已经切好并归一化 X_tensor torch.tensor(X_train, dtypetorch.float32) y_tensor torch.tensor(y_train, dtypetorch.float32) dataset TensorDataset(X_tensor, y_tensor) loader DataLoader(dataset, batch_size64, shuffleTrue) # 计算正样本权重负样本数 / 正样本数 pos_weight torch.tensor([(y_train 0).sum() / max((y_train 1).sum(), 1)], dtypetorch.float32) criterion torch.nn.BCEWithLogitsLoss(pos_weightpos_weight)逻辑说明pos_weight让异常样本的损失被放大模型不敢轻易忽略它们。DataLoader的shuffleTrue在每个 epoch 打乱顺序避免模型学到样本排列的虚假规律。注意如果用了滑动窗口且 stride 小于 window_size重叠样本在 shuffle 后可能同时出现在一个 batch 里影响不大但要知道这件事。4. 训练、调参和评估别被 99% 准确率骗了4.1 训练循环和早停策略训练循环本身不复杂关键是监控什么指标。准确率在类别不平衡时基本没用我一般盯验证集上的 F1 和 AUC。早停策略是验证集 F1 连续 5 个 epoch 不提升就停同时保存 F1 最高的模型权重。from sklearn.metrics import f1_score, roc_auc_score def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0 for X_batch, y_batch in loader: X_batch, y_batch X_batch.to(device), y_batch.to(device) optimizer.zero_grad() logits model(X_batch) loss criterion(logits, y_batch) loss.backward() # 梯度裁剪防止 LSTM 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() return total_loss / len(loader) def evaluate(model, loader, device): model.eval() all_probs, all_labels [], [] with torch.no_grad(): for X_batch, y_batch in loader: X_batch X_batch.to(device) logits model(X_batch) probs torch.sigmoid(logits).cpu().numpy() all_probs.extend(probs) all_labels.extend(y_batch.numpy()) # 默认阈值 0.5实际部署时按业务调 preds (np.array(all_probs) 0.5).astype(int) f1 f1_score(all_labels, preds) auc roc_auc_score(all_labels, all_probs) return f1, auc逻辑说明clip_grad_norm_是 LSTM 训练的标配梯度超过 1.0 就按比例缩回去防止梯度爆炸导致 loss 变 NaN。评估时先收集所有概率再统一算指标不要在 batch 级别算 F1 再平均那样会受 batch 大小影响。阈值 0.5 只是默认值实际部署时根据误报和漏报的代价来调安全场景通常宁可误报也不能漏报阈值可以降到 0.3。4.2 必调的四个参数和它们的实际影响第一个是学习率。Adam 优化器下 1e-3 是常见起点如果 loss 震荡就降到 1e-4如果收敛太慢就升到 3e-3。第二个是 batch size64 或 128 比较稳太小梯度噪声大太大泛化差。第三个是 LSTM 隐藏层维度128 起步数据量超过十万条可以试 256。第四个是卷积核数量64 起步特征维度超过 50 可以试 128。调参顺序建议先固定其他参数调学习率找到 loss 下降最快的值再调 batch size 看验证集 F1 有没有提升最后调模型容量相关的参数隐藏层维度、卷积核数量如果训练集 F1 很高但验证集 F1 低说明过拟合减小模型或增大 dropout。4.3 评估指标的选择准确率是最大的陷阱前面提过异常占比 5% 时全预测正常就有 95% 准确率。所以必须看召回率Recall、精确率Precision和 F1。召回率高但精确率低说明模型把很多正常流量也标成异常运维会被误报淹没精确率高但召回率低说明很多攻击被漏掉。安全场景一般要求召回率优先精确率可以适当牺牲。还有一个指标容易被忽略误报率False Positive Rate。假设每天有一百万条流量误报率 0.1% 就是一千条误报运维团队根本处理不过来。所以实际部署前一定要在真实流量上跑一段时间统计每天的误报数量再决定阈值。5. 避坑与排查那些让模型指标好看但上线就废的坑5.1 训练集和验证集有重叠窗口验证 F1 虚高现象训练时验证集 F1 到了 0.98上线后实际检测效果很差。原因滑动窗口切分时 stride 小于 window_size相邻样本有重叠随机划分训练集和验证集时同一个时间段的窗口可能同时出现在两边模型相当于在验证集上看到了训练数据。解决按时间顺序划分前 70% 做训练中间 15% 做验证最后 15% 做测试不要随机打乱。如果数据量够直接在时间上留 gap比如训练集最后时间点和验证集第一个时间点之间隔一个 window_size。5.2 归一化用了全量数据的均值方差测试集信息泄漏现象离线评估很好换一批新数据就崩。原因归一化时用了包含测试集在内的全量数据算均值和标准差测试集的分布信息泄漏到了训练过程。解决只用训练集的均值和标准差保存下来验证集和测试集用同一组参数做变换。部署时也要用训练时保存的参数不能重新算。5.3 异常样本标注时间窗口不精确模型学到错误模式现象模型把某些正常流量也标成异常排查发现这些流量出现在攻击时间段附近。原因标注时把整个攻击时间段都标成异常但攻击可能只持续了其中几秒其余时间是正常流量模型学到了正常流量的模式却被打上异常标签。解决尽量精确标注攻击的起止时间如果做不到用窗口内异常占比来打标签并且把占比阈值调高一点比如 0.3宁可丢掉一些边界样本也不要引入噪声。5.4 LSTM 序列太长导致训练慢且梯度消失现象window_size 设了 200训练一个 epoch 要半小时而且 loss 降不下去。原因LSTM 在长序列上反向传播时梯度容易消失而且计算量随时间步线性增长。解决用 CNN 的池化层先把时间步压缩比如 window_size200 经过两次 MaxPool1d(2) 变成 50再喂给 LSTM。或者用 GRU 替代 LSTM参数量少一些训练快一点。另外可以考虑把 window_size 降到 60 到 100 之间大多数流量异常在几十秒内就能看出模式。5.5 部署时特征计算和训练时不一致现象离线 F1 0.95上线后模型输出全是正常。原因训练时用的特征是从 pcap 离线算出来的部署时从实时流里算两者在时间对齐、字段含义、缺失值处理上不一致。比如训练时用 Zeek 日志的duration字段部署时从 NetFlow 取的同名字段单位不同。解决把特征计算逻辑封装成一个独立的模块训练和部署用同一份代码。部署前用一批历史数据回放对比离线特征和在线特征是否一致不一致就排查字段映射。6. 进阶技巧用注意力机制和在线学习把模型真正用起来6.1 在 LSTM 输出上加注意力让模型告诉你它在看哪段时间步基础版 CNNLSTM 取最后一个时间步的隐状态做分类但最后一个时间步未必包含最关键的信息。比如攻击发生在窗口中间最后一个时间步可能已经恢复正常了。加一个注意力机制让模型自己学习每个时间步的权重把关键时间步的信息加权聚合。class CNNLSTMAttention(nn.Module): def __init__(self, n_features, cnn_channels64, kernel_size3, lstm_hidden128, lstm_layers2, dropout0.3): super().__init__() self.conv1 nn.Conv1d(n_features, cnn_channels, kernel_size, paddingkernel_size // 2) self.bn1 nn.BatchNorm1d(cnn_channels) self.relu nn.ReLU() self.pool nn.MaxPool1d(2) self.lstm nn.LSTM(cnn_channels, lstm_hidden, lstm_layers, batch_firstTrue, dropoutdropout if lstm_layers 1 else 0) # 注意力把每个时间步的隐状态映射成一个分数 self.attn nn.Linear(lstm_hidden, 1) self.dropout nn.Dropout(dropout) self.fc nn.Linear(lstm_hidden, 1) def forward(self, x): x x.permute(0, 2, 1) x self.relu(self.bn1(self.conv1(x))) x self.pool(x) x x.permute(0, 2, 1) out, _ self.lstm(x) # out: (batch, seq, hidden) scores self.attn(out).squeeze(-1) # (batch, seq) weights torch.softmax(scores, dim1) # 加权求和每个时间步的隐状态乘以权重后累加 context torch.bmm(weights.unsqueeze(1), out).squeeze(1) # (batch, hidden) logit self.fc(self.dropout(context)) return logit.squeeze(-1)逻辑说明self.attn是一个线性层把每个时间步的隐状态映射成一个标量分数softmax 归一化成权重权重越大说明该时间步越重要。torch.bmm做批量矩阵乘法把权重和隐状态加权求和得到 context 向量。这个 context 向量再经过全连接层做分类。好处是可解释性你可以把 weights 打印出来看看模型在哪些时间步上权重高如果高权重集中在攻击发生的时段说明模型学到了正确的东西。参数说明注意力层没有额外超参数但要注意如果序列很长softmax 的数值稳定性可能有问题可以用torch.softmax自带的最大值减法。另外注意力权重可以用来做告警解释告诉运维「模型认为第 35 到 42 个时间步最可疑」方便人工复核。6.2 在线学习用增量数据定期更新模型流量模式会随时间变化新业务上线、协议升级、攻击手法演变都会让模型的效果慢慢下降。常见做法是定期用新数据重新训练但全量重训成本高。折中方案是增量学习固定 CNN 部分的权重只微调 LSTM 和全连接层用最近一周的数据跑几个 epoch。# 加载已训练模型冻结 CNN 部分 model CNNLSTMAttention(n_features40) model.load_state_dict(torch.load(best_model.pt)) for param in model.conv1.parameters(): param.requires_grad False for param in model.bn1.parameters(): param.requires_grad False # 只优化 LSTM 和后面的层 optimizer torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr1e-4 )逻辑说明冻结 CNN 部分是因为卷积核学到的是通用的局部模式不太会随时间变化而 LSTM 和分类层学的是时序模式和决策边界需要适应新数据。学习率用 1e-4 而不是 1e-3避免把已学到的知识冲掉。增量学习的数据要包含正常流量和最新的攻击样本如果只有正常流量模型会逐渐偏向预测正常。6.3 一个我踩过的坑别在训练集上调阈值最后说一个血泪教训。模型输出概率后需要定一个阈值来判定异常我一开始在训练集上试不同阈值找到 F1 最高的那个结果上线后误报率比预期高很多。原因是训练集和真实流量的分布有差异训练集上最优的阈值在真实数据上不是最优的。正确做法是在验证集上选阈值然后在测试集上验证最后上线后根据实际误报情况再微调。如果条件允许上线初期用双阈值高阈值触发告警低阈值只记录不告警观察一段时间后再调整。我现在的习惯是每次训练完模型先把验证集上的概率分布画出来看看正常和异常的概率分布有没有明显分离。如果重叠区域很大说明模型区分能力不够调阈值也救不了得回去检查特征或模型结构。这个习惯帮我省了很多次上线后才发现问题的尴尬。希望帮到你。本文还有配套的精品资源点击获取