简介:这是一份面向计算机相关专业学生的课程设计与期末大作业参考项目,主题为基于CNN与LSTM融合模型的网络流量检测系统,适合正在准备高分课设、需要项目实战练习的学习者。资源以Python源码形式提供,完整覆盖数据加载、预处理、模型定义、训练测试与主流程入口等环节,可帮助读者理解卷积神经网络提取流量空间特征、长短期记忆网络建模时序依赖的联合思路。压缩包共6个文件,以5个py脚本和1个txt说明文档为主,整体约6KB,体量轻便,便于快速阅读与二次修改。目前已有207人学习下载,具备一定参考热度。读者可据此获得一套结构清晰的赛题实现方案,掌握流量特征处理、模型搭建与训练评估的完整链路,并借助说明文档梳理项目模块关系与运行逻辑,为课设答辩或后续深度学习实践提供可复用的代码基础。
1. 从一份课设压缩包说起:CNN+LSTM 做流量检测到底在解决什么
拿到「基于CNN+LSTM的网络流量检测系统python源码」这个题目的人,多半是两种情况:一是课设/毕设卡在选题,想找一个既有深度学习含量、又能跑出漂亮指标的方向;二是已经动手了,但卡在特征怎么喂、模型怎么搭、指标怎么解释。网络流量检测这件事的本质,是把连续的网络行为切成一帧一帧的样本,判断它是正常还是异常,而 CNN+LSTM 这套组合拳解决的正是「单帧看不出来、要看一段时间的节奏」这类问题。
单看一条流量记录,端口、包长、协议这些字段谁都能读,但端口扫描、DDoS、慢速爆破这类攻击的特征藏在时间维度里——短时间内大量半连接、固定间隔的心跳式请求、包长序列的周期性抖动。CNN 擅长从局部窗口里抽模式,LSTM 擅长记住长距离的先后依赖,两者串起来,等于既看「这一小段长什么样」,又看「这一小段在整条序列里处于什么位置」。这套思路在公开数据集上做二分类,正常流量和攻击流量的区分度通常能到 95% 以上,做多分类(区分具体攻击类型)会掉一些,但仍是课设里拿得出手的结果。
这篇文章面向的是要真正把这份源码跑起来、改得动、讲得清的人。我会按「数据怎么来 → 特征怎么造 → 模型怎么搭 → 怎么训怎么调 → 哪里最容易翻车」的顺序讲,中间给可直接抄的代码和参数。你不需要有 GPU 集群,一台带独显的笔记本或者实验室的入门卡就够,CPU 也能跑,只是慢。
2. 数据与特征工程:把 pcap 变成 CNN+LSTM 能吃的张量
2.1 公开数据集怎么选,CICIDS 和 NSL-KDD 差在哪
做流量检测,第一步不是写模型,是找数据。课设里最常用的两个来源是 NSL-KDD 和 CICIDS2017。NSL-KDD 是 KDD99 的清洗版,每条记录是已经提取好的 41 维统计特征(连接时长、协议类型、字节数、错误率等),没有原始包,好处是干净、小、跑得快,坏处是太老,很多现代攻击形态它没有,而且特征已经被人嚼过一遍,你很难讲出「我做了什么特征工程」。
CICIDS2017 更贴近真实,它同时提供 pcap 和用 CICFlowMeter 提取的 CSV,每条流有 80 多列,包含前向/后向包长统计、流间隔、标志位计数等。做 CNN+LSTM 我一般推荐 CICIDS2017,因为它的 CSV 天然带时间戳,可以按时间排序后切成序列,正好喂给 LSTM。NSL-KDD 没有可靠的时间顺序,硬做序列是自欺欺人。
选数据时注意一点:CICIDS2017 的类别极度不平衡,正常流量占八成以上,DDoS、Bot 这类可能只有几百条。直接训练模型会偏向多数类,后面第 5 章会讲怎么处理。
2.2 从原始流到定长序列:滑窗切片的三个参数
CNN+LSTM 的输入是一个三维张量:(样本数, 时间步, 特征数)。CICIDS 的 CSV 是「一行一条流」,要变成序列,得先按时间排序,再用滑动窗口把连续的 N 条流捆成一个样本。这里有三个参数必须自己定:
| 参数 | 含义 | 常见取值 | 影响 |
|---|---|---|---|
| window_size | 一个样本包含多少条连续流 | 10 / 20 / 50 | 太小抓不到节奏,太大显存吃紧且稀释局部模式 |
| stride | 窗口每次滑动几条 | 1 / window_size/2 | 步长小样本多但重叠高,步长大样本少 |
| step | LSTM 展开的时间步 | 等于 window_size | 一般与窗口一致,也可下采样 |
标签怎么定?如果窗口内只要有一条攻击流就标为攻击,召回高但误报多;如果要求窗口内全是攻击才标攻击,精确高但漏报多。课设里我一般用「窗口内攻击占比超过 50% 标攻击」,折中且好解释。
2.3 特征归一化与序列构造的可复现代码
下面这段是把 CICIDS CSV 切成序列张量的核心逻辑,直接可跑:
import pandas as pd import numpy as np from sklearn.preprocessing import MinMaxScaler def build_sequences(csv_path, window_size=20, stride=10, attack_ratio=0.5): df = pd.read_csv(csv_path) # 去掉无穷值和缺失,CICIDS 里 Flow Bytes/s 经常出现 inf df.replace([np.inf, -np.inf], np.nan, inplace=True) df.dropna(inplace=True) # 按时间戳排序,保证序列的先后关系真实 df['Timestamp'] = pd.to_datetime(df['Timestamp'], format='%d/%m/%Y %H:%M:%S', errors='coerce') df = df.sort_values('Timestamp').reset_index(drop=True) label_col = 'Label' feature_cols = [c for c in df.columns if c not in ['Timestamp', label_col, 'Flow ID', 'Source IP', 'Destination IP']] X_raw = df[feature_cols].values.astype(np.float32) y_raw = (df[label_col].str.strip() != 'BENIGN').astype(np.int32).values # 归一化必须在切窗之前做,否则每个窗口的尺度不一致 scaler = MinMaxScaler() X_scaled = scaler.fit_transform(X_raw) X_seq, y_seq = [], [] for start in range(0, len(X_scaled) - window_size + 1, stride): end = start + window_size window_x = X_scaled[start:end] window_y = y_raw[start:end] X_seq.append(window_x) y_seq.append(1 if window_y.mean() >= attack_ratio else 0) return np.array(X_seq), np.array(y_seq), scaler, feature_cols逻辑说明:先清洗 inf 和 NaN,CICIDS 的速率类字段除以零会产生无穷值,不处理会让 loss 直接变 NaN。时间戳排序是序列建模的前提,很多人跳过这步,结果 LSTM 学的是乱序。归一化放在切窗之前,保证所有窗口共享同一套缩放参数,如果每个窗口单独归一化,模型会学到窗口内的相对关系而非全局尺度,泛化会崩。
参数说明:window_size=20是课设里的稳妥起点,20 条流大约覆盖几十秒到几分钟的真实行为,足够体现扫描或爆破的节奏。stride=10让相邻窗口重叠一半,既扩增样本又保留连续性。attack_ratio=0.5是标签判定阈值,想提高召回就降到 0.3,想提高精确就升到 0.7,这个值直接决定你论文里那张混淆矩阵长什么样。
提示:切完序列后先打印
X_seq.shape,正常应该是(样本数, 20, 特征数)。如果特征数超过 80,建议先做一次方差过滤或相关性剔除,否则 LSTM 第一层参数量会大得没必要。
3. CNN+LSTM 模型搭建:从 Conv1d 到 LSTM 的维度对齐
3.1 为什么是 CNN 在前、LSTM 在后
顺序不能反。CNN 的作用是在时间轴上做局部卷积,把相邻几条流的联合模式压成更高层的特征,相当于给 LSTM 喂「已经提炼过的片段」。如果 LSTM 在前,它要先在原始特征上记长依赖,再让 CNN 去卷,等于让 CNN 处理已经被 LSTM 混过的表示,局部模式反而模糊了。常见做法是Conv1d → 池化 → LSTM → 全连接,这也是这份课设源码里最可能采用的骨架。
Conv1d 的卷积核在时间维滑动,kernel_size=3表示每次看连续 3 条流,out_channels是提取多少种局部模式。池化用MaxPool1d(2)把时间步减半,既降参又保留最强响应。LSTM 接在池化后的序列上,hidden_size决定记忆容量,最后取最后一个时间步的输出送全连接分类。
3.2 PyTorch 实现与维度变化逐层核对
import torch import torch.nn as nn class CNNLSTM(nn.Module): def __init__(self, n_features, n_classes=2, conv_channels=64, kernel_size=3, hidden_size=128, num_layers=2, dropout=0.3): super().__init__() # 输入 (batch, n_features, seq_len),Conv1d 要求通道在前 self.conv = nn.Sequential( nn.Conv1d(n_features, conv_channels, kernel_size, padding=kernel_size // 2), nn.BatchNorm1d(conv_channels), nn.ReLU(), nn.MaxPool1d(2) ) self.lstm = nn.LSTM( input_size=conv_channels, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0 ) self.classifier = nn.Sequential( nn.Linear(hidden_size, 64), nn.ReLU(), nn.Dropout(dropout), nn.Linear(64, n_classes) ) def forward(self, x): # x: (batch, seq_len, n_features) -> 转成 (batch, n_features, seq_len) x = x.permute(0, 2, 1) x = self.conv(x) # (batch, conv_channels, seq_len//2) x = x.permute(0, 2, 1) # (batch, seq_len//2, conv_channels) out, (h_n, c_n) = self.lstm(x) last = out[:, -1, :] # 取最后时间步 return self.classifier(last)逻辑说明:permute是这份代码最容易出错的地方。Conv1d 要求输入是(batch, channels, length),而我们的序列张量是(batch, length, features),所以进卷积前转一次,出卷积后再转回来给 LSTM。LSTM 设了batch_first=True,输入输出都是(batch, seq, hidden),取out[:, -1, :]拿最后一步的隐状态做分类。
参数说明:conv_channels=64是特征数的量级参考,特征 80 左右时 64 够用,特征少可以降到 32。kernel_size=3覆盖 3 条流的局部窗口,想抓更长的局部模式可以升到 5,但要注意padding同步改成kernel_size//2保持长度。hidden_size=128是课设的甜点值,升到 256 指标提升有限但训练时间翻倍。num_layers=2时 dropout 才生效,单层 LSTM 加 dropout 是无效的,这点很多人不知道。
3.3 训练循环与类别不平衡的加权损失
CICIDS 正常流量占大头,直接交叉熵会让模型学会「全猜正常」也有高准确率。用pos_weight给攻击类加权:
from torch.utils.data import DataLoader, TensorDataset def train(model, X_train, y_train, epochs=30, batch_size=64, lr=1e-3, device='cuda'): model.to(device) loader = DataLoader(TensorDataset(torch.tensor(X_train), torch.tensor(y_train)), batch_size=batch_size, shuffle=True) # 攻击类权重 = 正常样本数 / 攻击样本数 n_pos = y_train.sum() n_neg = len(y_train) - n_pos pos_weight = torch.tensor([n_neg / max(n_pos, 1)], dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=torch.tensor([1.0, n_neg / max(n_pos, 1)], dtype=torch.float32).to(device)) optimizer = torch.optim.Adam(model.parameters(), lr=lr) for epoch in range(epochs): model.train() total_loss = 0 for xb, yb in loader: xb, yb = xb.to(device), yb.to(device) optimizer.zero_grad() logits = model(xb) loss = criterion(logits, yb) loss.backward() # 梯度裁剪,LSTM 容易梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() print(f"epoch {epoch+1}, loss {total_loss/len(loader):.4f}")逻辑说明:CrossEntropyLoss的weight参数按类别给权重,攻击类权重设为正常/攻击的比例,让模型不敢忽视少数类。clip_grad_norm_是 LSTM 训练的后悔药,梯度超过 5 就裁掉,能避免 loss 突然变 NaN。
参数说明:batch_size=64在显存 6G 以上都稳,显存小降到 32。lr=1e-3是 Adam 的常规起点,loss 震荡就降到 5e-4。epochs=30配合早停,验证集 loss 连续 5 轮不降就停,别硬训到过拟合。
4. 训练完怎么验证:指标、混淆矩阵和推理脚本
4.1 别只看准确率,看召回和 F1
流量检测里准确率是最会骗人的指标。正常流量占 80%,模型全猜正常就有 80% 准确率,但攻击一个没抓到。真正要看的是攻击类的召回率(Recall)和 F1。混淆矩阵里,假阴性(攻击被判正常)是安全场景最不能接受的,假阳性(正常被判攻击)只是多几条告警。课设答辩时老师最爱问「你这个模型漏报多少」,提前把召回算清楚。
from sklearn.metrics import classification_report, confusion_matrix def evaluate(model, X_test, y_test, device='cuda'): model.eval() with torch.no_grad(): logits = model(torch.tensor(X_test).to(device)) preds = logits.argmax(dim=1).cpu().numpy() print(confusion_matrix(y_test, preds)) print(classification_report(y_test, preds, target_names=['BENIGN', 'ATTACK']))逻辑说明:model.eval()关掉 dropout 和 BN 的训练行为,torch.no_grad()省显存。classification_report直接给出每类的 precision、recall、f1,比手算省事。
4.2 单条流量序列的推理封装
课设演示时经常要现场喂一条数据看输出,封装一个推理函数:
def predict_one(model, sequence, scaler, device='cuda'): # sequence: (window_size, n_features) 的原始未归一化数据 model.eval() seq_scaled = scaler.transform(sequence) tensor = torch.tensor(seq_scaled, dtype=torch.float32).unsqueeze(0).to(device) with torch.no_grad(): logits = model(tensor) prob = torch.softmax(logits, dim=1)[0] label = 'ATTACK' if prob[1] > 0.5 else 'BENIGN' return label, prob.cpu().numpy()逻辑说明:推理时必须用训练时保存的同一个scaler,重新 fit 会导致尺度不一致,预测全乱。unsqueeze(0)补上 batch 维度。返回概率而不是硬标签,方便你调阈值。
参数说明:阈值 0.5 可以调,安全场景想少漏报就降到 0.3,代价是误报增多。这个阈值和 2.3 节的attack_ratio是两回事,前者是推理判定,后者是训练标签生成。
5. 避坑与排查:这份源码跑不起来时先查这五处
5.1 现象:loss 一开始就是 NaN
原因:CICIDS 的Flow Bytes/s、Flow Packets/s存在除以零产生的 inf,归一化后仍是 inf,进网络直接污染梯度。解决:在读 CSV 后立刻replace([np.inf, -np.inf], np.nan)再dropna,别等到归一化之后才处理。
5.2 现象:训练准确率 99%,测试准确率 60%
原因:切窗时用了stride=1且没按时间划分训练测试集,相邻窗口高度重叠,训练集和测试集泄漏。解决:按时间前 70% 做训练、后 30% 做测试,或者切窗后按时间顺序划分,绝不用随机划分。这是课设里最隐蔽的翻车点。
5.3 现象:LSTM 层报维度错误
原因:Conv1d 输出是(batch, channels, length),直接喂给batch_first=True的 LSTM 会把它当成(batch, seq, feature),维度对不上。解决:卷积后加一次permute(0, 2, 1),把通道维换到最后一维。打印每层输出 shape 是最快的定位方式。
5.4 现象:显存不够,batch 降到 8 还 OOM
原因:window_size或特征数太大,LSTM 的参数量随hidden_size和时间步平方级增长。解决:先把window_size从 50 降到 20,再做特征选择把 80 维降到 30 维以内,最后才考虑降hidden_size。顺序别反,先动数据再动模型。
5.5 现象:多分类时某些攻击类 F1 为 0
原因:该类样本只有几十条,模型根本没学到。解决:要么合并相似类别(把各种 DDoS 合成一类),要么用重采样给少数类扩增,要么在损失里给该类更高权重。课设做二分类最稳,多分类要预留调参时间。
6. 让指标再上一个台阶:阈值搜索与特征消融的实操技巧
模型搭完、能跑通之后,真正拉开课设分数的是最后这 10% 的调优。我一般先做阈值搜索:把推理阈值从 0.1 到 0.9 扫一遍,画一条召回-精确曲线,挑 F1 最高的点。很多人默认 0.5,其实在类别不平衡时最优阈值经常在 0.3 附近。代码很短:
import numpy as np from sklearn.metrics import f1_score def search_threshold(model, X_val, y_val, device='cuda'): model.eval() with torch.no_grad(): probs = torch.softmax(model(torch.tensor(X_val).to(device)), dim=1)[:, 1].cpu().numpy() best_t, best_f1 = 0.5, 0 for t in np.arange(0.1, 0.9, 0.05): preds = (probs > t).astype(int) f1 = f1_score(y_val, preds) if f1 > best_f1: best_f1, best_t = f1, t return best_t, best_f1逻辑说明:拿验证集(不是测试集)扫阈值,避免在测试集上过拟合。返回的best_t写进推理脚本,best_f1就是你论文里能写的最优结果。
第二个技巧是特征消融。CICIDS 的 80 多列里,很多是冗余的(比如前向包长均值、最大值、最小值高度相关)。我一般按方差和与标签的相关性排序,取前 30 维重训一次,对比指标。如果 30 维和 80 维差不多,就用 30 维,训练快一倍,答辩时还能讲「我做了特征选择」。这一步用sklearn.feature_selection.SelectKBest几行就能做,但一定要在切窗之前对原始流做选择,别对序列做。
最后一个习惯:每次改完参数,把window_size、stride、hidden_size、lr、最优阈值和对应 F1 记到一张表里。我踩过的最大坑就是调了十几轮之后忘了哪组参数最好,只能重跑。这份源码值不值得深挖,取决于你愿不愿意在跑通之后继续做这两步调优——只跑默认参数,它就是个及格课设;把阈值和特征消融做完,它才是一个你能讲清楚每个决策理由的项目。希望帮到你。
本文还有配套的精品资源,点击获取