简介:这份资源是面向高校学生与深度学习入门者的课程设计完整方案,聚焦网络流量检测这一网络安全细分场景,通过CNN与LSTM组合模型实现对流量数据的特征提取与时序建模,适合作为高分课设参考或深度学习实战练手项目。压缩包共6个文件,以5个Python源码文件与1份Markdown说明文档为主,整体约5KB,源码覆盖数据加载、预处理、模型定义、训练测试与主流程入口等模块,文档则交代了项目背景、实现思路、代码结构与结果分析,便于快速理解整体架构。目前已有306人学习下载,说明该方案在同类课设中具备一定参考价值。读者可据此掌握从原始流量数据到模型训练评估的完整链路,理解CNN与LSTM在网络安全检测中的分工与配合,并借助文档说明梳理代码逻辑、复现实验流程,为后续扩展或撰写报告提供可用的模板与思路。
1. 从一份课程设计说起:CNN+LSTM 做网络流量检测到底在解决什么
很多人第一次接触网络流量检测,是在一份课程设计里:给一段抓包数据,判断它是正常流量还是攻击流量。单看这个任务,用 CNN 或 LSTM 单独做都能跑出不错的准确率,但真正上手就会发现各自的短板——CNN 擅长从字节序列里抠局部特征,比如某个端口号、某个协议字段的固定模式,但它对时序上的先后关系不敏感;LSTM 擅长记住长距离的依赖,比如一次会话里前几个包和后几个包的关联,但面对高维原始字节,它容易在噪声里迷路。把两者串起来,让 CNN 先做局部特征提取、LSTM 再对特征序列建模,就是这份课程设计标题里「CNN+LSTM」的核心动机。
这份源码+文档说明适合两类人:一类是正在做课程设计、需要一份能跑通、能讲清楚原理的参考实现;另一类是刚转安全或数据方向、想找一个端到端小项目练手的工程师。它不解决生产级 IDS 的全部问题,但能把「流量特征怎么构造、双分支模型怎么搭、训练完怎么评估」这条链路走完。下面我按自己复现这类项目的顺序,把每一步拆开讲。
2. 数据从哪来、特征怎么构造:NSL-KDD 与 CICIDS 的取舍
2.1 先想清楚用哪个数据集,别一上来就抓包
课程设计里最常见的两个公开数据集是 NSL-KDD 和 CICIDS2017。NSL-KDD 是 KDD99 的清洗版,每条记录 41 维特征,已经做过数值化和归一化,标签是 normal 加四类攻击。它的优点是体量小、格式规整,一份 CSV 就能跑;缺点是太老,很多攻击模式在现代网络里已经过时,模型容易学到「数据集偏差」而不是真实判别能力。CICIDS2017 是较新的流量数据集,包含 PCAP 和提取好的 CSV,特征维度到 78 维,攻击类型更贴近真实场景,但 CSV 里有大量缺失值和无穷值,直接喂给模型会翻车。
我一般会这样选:如果目标是快速验证模型结构、把课程设计跑通,用 NSL-KDD;如果想让结果更有说服力、愿意花时间做数据清洗,用 CICIDS2017。下面以 NSL-KDD 为例,因为它对新手最友好,且能完整展示 CNN+LSTM 的输入构造过程。
2.2 把 41 维表格特征转成 CNN+LSTM 能吃的序列
CNN 和 LSTM 都期望输入有「序列」结构,而 NSL-KDD 是扁平的 41 维向量。常见做法是把 41 维特征按语义分组,重排成一个二维矩阵,比如把基于时间的特征、基于主机的特征、内容特征各自成块,再 reshape 成 (时间步, 特征通道)。另一种更直接的做法是把 41 维当成一个长度为 41 的序列,每个时间步 1 个特征,但这样 CNN 的卷积核在单通道上滑动意义不大。我倾向于前者:把特征分成若干组,每组作为一个时间步,组内维度作为通道,这样 CNN 能在组内做局部卷积,LSTM 能在组间建模依赖。
import numpy as np import pandas as pd from sklearn.preprocessing import MinMaxScaler, LabelEncoder # 读取 NSL-KDD 训练集,列名按官方文档补齐 col_names = ["duration","protocol_type","service","flag","src_bytes","dst_bytes", "land","wrong_fragment","urgent","hot","num_failed_logins","logged_in", "num_compromised","root_shell","su_attempted","num_root","num_file_creations", "num_shells","num_access_files","num_outbound_cmds","is_host_login", "is_guest_login","count","srv_count","serror_rate","srv_serror_rate", "rerror_rate","srv_rerror_rate","same_srv_rate","diff_srv_rate", "srv_diff_host_rate","dst_host_count","dst_host_srv_count", "dst_host_same_srv_rate","dst_host_diff_srv_rate","dst_host_same_src_port_rate", "dst_host_srv_diff_host_rate","dst_host_serror_rate","dst_host_srv_serror_rate", "dst_host_rerror_rate","dst_host_srv_rerror_rate","label","difficulty"] df = pd.read_csv("KDDTrain+.txt", names=col_names) # 类别型特征做 one-hot,数值型做归一化 cat_cols = ["protocol_type", "service", "flag"] df = pd.get_dummies(df, columns=cat_cols) # 标签二分类:normal 为 0,其余攻击为 1 df["label"] = df["label"].apply(lambda x: 0 if x == "normal" else 1) # 去掉 difficulty 列,它只是难度标记,不是特征 X = df.drop(["label", "difficulty"], axis=1).values.astype(np.float32) y = df["label"].values.astype(np.int64) scaler = MinMaxScaler() X = scaler.fit_transform(X) # 重排成 (样本数, 时间步, 通道数),这里按 41 维原始顺序切成 8 个时间步 # 实际项目中建议按语义分组,这里为演示用等长切分 def reshape_to_sequence(X, steps=8): n, dim = X.shape # 补齐到 steps 的整数倍 pad = (steps - dim % steps) % steps if pad: X = np.pad(X, ((0,0),(0,pad)), mode="constant") dim_padded = X.shape[1] chunk = dim_padded // steps return X.reshape(n, steps, chunk) X_seq = reshape_to_sequence(X, steps=8) print(X_seq.shape) # (样本数, 8, 通道数)这段代码的关键点有三个。第一,get_dummies会把 protocol_type、service、flag 展开成几十列,最终特征维度会从 41 涨到 120 左右,这是正常的,别被维度吓到。第二,MinMaxScaler必须只在训练集上 fit,然后 transform 测试集,否则会有数据泄漏,这是很多人课程设计被扣分的地方。第三,reshape_to_sequence里的切分方式只是演示,真正做的时候应该按特征语义分组,比如把 count、srv_count、serror_rate 这些基于时间的统计量放一组,把 dst_host 开头的放另一组,这样 CNN 卷积才有物理意义。
提示:CICIDS2017 的 CSV 里常见
inf和NaN,读进来后先df.replace([np.inf, -np.inf], np.nan)再dropna或填充,否则 scaler 会报错或产出全零列。
3. CNN+LSTM 双分支模型怎么搭:从 Keras 到 PyTorch 的落地细节
3.1 模型结构选型:串行还是并行
CNN+LSTM 有两种常见接法。串行是 CNN 提取特征后直接接 LSTM,让 LSTM 在 CNN 输出的特征序列上建模;并行是两条分支各自处理输入,最后拼接或相加。串行结构参数少、训练快,适合课程设计这种数据量不大的场景;并行结构表达能力强,但容易过拟合,需要更多正则。我一般先用串行跑 baseline,如果准确率卡住了再考虑并行。
串行结构的具体形态:输入 (时间步, 通道) → Conv1D 做局部卷积 → MaxPooling1D 降采样 → LSTM 建模时序 → 全连接分类。Conv1D 的卷积核在时间步上滑动,每个时间步内所有通道参与计算,这正好对应「组内特征做局部组合」的直觉。LSTM 接在池化后的序列上,捕捉组与组之间的依赖。
3.2 用 PyTorch 写一个可复现的 CNN+LSTM
import torch import torch.nn as nn class CNNLSTM(nn.Module): def __init__(self, input_channels, num_classes=2, cnn_out=64, lstm_hidden=128): super().__init__() # Conv1D 在时间维滑动,输入通道是特征通道数 self.conv1 = nn.Conv1d(in_channels=input_channels, out_channels=cnn_out, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm1d(cnn_out) self.relu = nn.ReLU() self.pool = nn.MaxPool1d(kernel_size=2) # LSTM 输入维度是 CNN 输出通道数 self.lstm = nn.LSTM(input_size=cnn_out, hidden_size=lstm_hidden, num_layers=1, batch_first=True, bidirectional=False) self.dropout = nn.Dropout(0.3) self.fc = nn.Linear(lstm_hidden, num_classes) def forward(self, x): # x: (batch, time_steps, channels) -> Conv1d 需要 (batch, channels, 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, channels) 给 LSTM out, (h_n, c_n) = self.lstm(x) # 取最后一个时间步的隐藏状态做分类 x = self.dropout(h_n[-1]) return self.fc(x)参数说明:input_channels是上一步 reshape 后的通道数,比如 8 个时间步、总维度 120,那通道数就是 15。cnn_out=64表示卷积输出 64 个特征图,这个值可以调,数据量小就降到 32,避免过拟合。kernel_size=3在时间步上做三邻域卷积,如果时间步只有 8,池化后剩 4,再大就卷不动了。lstm_hidden=128是隐藏层维度,课程设计里 64 到 128 都常见,再大显存和过拟合都会上来。dropout=0.3是经验值,如果训练集准确率远高于验证集,可以加到 0.5。
训练循环里有两个容易忽略的点。一是类别不平衡,NSL-KDD 里正常流量远多于攻击流量,直接训练模型会偏向多数类,建议用nn.CrossEntropyLoss(weight=...)给少数类加权,权重按类别频率的倒数算。二是早停,验证集 loss 连续几轮不降就停,别硬跑满 epoch,否则过拟合后准确率反而掉。
from torch.utils.data import DataLoader, TensorDataset # 假设 X_seq 已归一化,y 是 0/1 标签 X_tensor = torch.tensor(X_seq, dtype=torch.float32) y_tensor = torch.tensor(y, dtype=torch.long) dataset = TensorDataset(X_tensor, y_tensor) loader = DataLoader(dataset, batch_size=256, shuffle=True) # 类别权重 class_counts = np.bincount(y) weights = torch.tensor(1.0 / class_counts, dtype=torch.float32) weights = weights / weights.sum() criterion = nn.CrossEntropyLoss(weight=weights) model = CNNLSTM(input_channels=X_seq.shape[2]) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(30): model.train() total_loss = 0 for xb, yb in loader: optimizer.zero_grad() logits = model(xb) loss = criterion(logits, yb) loss.backward() optimizer.step() total_loss += loss.item() print(f"epoch {epoch}, loss {total_loss/len(loader):.4f}")batch_size=256是显存和梯度稳定性的折中,数据量小可以降到 64。lr=1e-3是 Adam 的常用起点,如果 loss 震荡就降到 5e-4。epoch=30配合早停,实际可能十几轮就收敛。
3.3 评估指标别只看准确率
网络流量检测里,准确率是最容易骗人的指标。如果攻击样本只占 10%,模型全预测正常也有 90% 准确率。必须看混淆矩阵、精确率、召回率和 F1。尤其是召回率,漏报一个攻击的代价远大于误报一个正常流量。课程设计答辩时老师常问「你的模型漏报率多少」,提前把sklearn.metrics.classification_report跑出来,心里有数。
from sklearn.metrics import classification_report, confusion_matrix model.eval() with torch.no_grad(): logits = model(X_tensor) preds = logits.argmax(dim=1).numpy() print(confusion_matrix(y, preds)) print(classification_report(y, preds, target_names=["normal", "attack"]))如果召回率明显低于精确率,说明模型偏保守,可以调低攻击类的分类阈值,或者增加攻击类权重。如果精确率低,说明误报多,检查特征里有没有和标签强相关的泄漏列。
4. 训练完别急着交:几个让结果站得住脚的验证动作
4.1 交叉验证与独立测试集
单次划分训练/测试集的结果波动很大,尤其是数据量不大时。建议做 5 折交叉验证,看 F1 的均值和方差。如果方差超过 0.05,说明模型对数据划分敏感,需要检查是否有样本泄漏或特征分布不一致。NSL-KDD 官方提供了 KDDTest+ 作为独立测试集,但它的分布和训练集有差异,准确率通常会掉 10 到 20 个百分点,这是正常的,别为了刷高测试集准确率去调参,那样只会过拟合。
4.2 看模型到底学到了什么
CNN 部分可以可视化卷积核的激活,看它是否对某些特征组敏感。LSTM 部分可以看最后一个时间步的隐藏状态在正常和攻击样本上的分布差异。更简单的做法是做特征消融:把某一组特征置零,看 F1 掉多少,掉得多的组就是关键特征。这个分析写进课程设计文档里,比单纯报一个准确率有说服力得多。
4.3 推理速度与部署可行性
课程设计通常不要求部署,但如果想加分,可以测一下单条样本的推理耗时。用time.time()包住model(x)跑 1000 次取平均,如果在 CPU 上超过 10ms,说明模型偏重,可以考虑减小lstm_hidden或去掉一层。实际 IDS 场景对延迟敏感,这个意识值得提前建立。
5. 避坑与排查:那些让课程设计翻车的细节
5.1 现象:训练 loss 不降,准确率卡在 50% 左右
原因通常是标签没对齐或输入维度搞错了。比如get_dummies后特征列顺序变了,但 reshape 时还按原始 41 维切分,导致通道数对不上。解决:在 reshape 前打印X.shape,确认总维度,再决定steps和chunk。另外检查标签是不是全 0 或全 1,np.bincount(y)一看便知。
5.2 现象:验证集准确率远高于测试集
这是典型的数据泄漏。常见来源是归一化时用了全量数据 fit,或者把测试集样本混进了训练集。解决:严格按train_test_split划分后再 fit scaler,且random_state固定。如果用了 KDDTest+,注意它的特征分布和训练集不同,准确率下降是正常的,不要当成 bug。
5.3 现象:LSTM 训练极慢,GPU 利用率低
原因可能是batch_first没设对,或者序列长度太长。PyTorch 的 LSTM 默认batch_first=False,输入是 (seq_len, batch, feature),如果传了 (batch, seq_len, feature) 而不设batch_first=True,不仅结果错,速度也慢。解决:显式设batch_first=True,并检查permute后的维度顺序。另外num_layers=1就够,堆到 2 层以上收益很小但耗时翻倍。
5.4 现象:模型在正常流量上误报很多
原因可能是正常样本里的某些特征被 scaler 压到了接近零,模型把它们当成了攻击模式。解决:检查MinMaxScaler后的特征分布,如果某列方差极小,考虑去掉或换用StandardScaler。另外可以看混淆矩阵里正常被误判为攻击的样本,手动看几条原始记录,往往能发现是某类服务或协议没在训练集里出现过。
5.5 现象:换到 CICIDS2017 后代码报错或结果崩掉
CICIDS2017 的 CSV 列名带空格、有inf、标签列叫Label且攻击类型是字符串。解决:读入后先df.columns = df.columns.str.strip(),再replace([np.inf, -np.inf], np.nan),然后dropna。标签用LabelEncoder转成整数,如果做二分类就把所有非 BENIGN 的归为攻击。注意它的流量类别极不平衡,某些攻击只有几十条样本,训练时要么合并类别,要么用重采样。
6. 把课程设计变成能讲清楚的作品:文档与复现技巧
一份课程设计能不能拿高分,代码只占一半,另一半是文档能不能让老师快速看懂你做了什么、为什么这么做。我的习惯是文档里放三样东西:一张模型结构图(用文字描述层与层的连接和维度变化)、一张训练曲线(loss 和 F1 随 epoch 的变化)、一张混淆矩阵。这三样比大段文字管用。
模型结构图不用画得多漂亮,用表格把每层的输入输出维度列清楚就行。比如输入 (batch, 8, 15),Conv1d 后 (batch, 64, 8),池化后 (batch, 64, 4),LSTM 后取最后隐藏状态 (batch, 128),全连接后 (batch, 2)。老师一看维度就知道你理解每一层在干什么。
训练曲线用 matplotlib 画,横轴 epoch,纵轴 loss 和验证集 F1,两条线放一张图里。如果 F1 在某个 epoch 后开始降,那就是过拟合的信号,文档里写一句「采用早停,最终取验证集 F1 最高的模型」,显得有工程意识。
混淆矩阵用sklearn.metrics.ConfusionMatrixDisplay画,标注清楚 normal 和 attack 的对应关系。如果攻击类召回率偏低,文档里主动分析原因,比如「攻击样本仅占 12%,模型偏向多数类,后续可通过加权损失或过采样改善」,这比藏着掖着强。
最后一个技巧:把随机种子固定。torch.manual_seed(42)、np.random.seed(42)、random.seed(42)三件套加上,保证别人复现你的代码时结果一致。课程设计答辩时如果老师让你当场跑一遍,结果和文档里对不上就尴尬了。这个习惯我吃了亏才养成,希望帮到你。
本文还有配套的精品资源,点击获取