简介:这份资源是面向高校学生与深度学习初学者的网络流量检测课程设计完整方案,基于 Python 实现,采用 CNN 与 LSTM 组合模型完成流量特征提取与分类识别,适合用作课程设计、毕业设计参考或网络安全方向的入门实战练习。压缩包共 6 个文件,包含 5 个 py 源码与 1 个 md 说明文档,整体约 5KB,源码覆盖数据预处理、模型构建、训练与测试等核心环节,文档则交代了项目背景、实现思路、代码结构与结果分析。目前已有 306 人学习下载,说明该方案在同类课设中具有一定参考价值。读者可据此理清 CNN+LSTM 混合模型在流量识别任务中的搭建流程,理解数据加载与特征处理方式,并借助文档快速定位各模块职责,减少从零摸索的时间成本,也便于在此基础上替换数据集或调整网络结构开展二次实验。
1. 从一份课程设计说起:CNN+LSTM 做网络流量检测到底靠不靠谱
很多做安全方向课程设计的人,第一次拿到「网络流量检测」这个题目时,第一反应是上规则匹配,比如 Snort 那种特征串比对。但规则库更新永远慢半拍,遇到变种流量就抓瞎。于是越来越多的人转向深度学习方案,其中 CNN+LSTM 混合模型是出现频率最高的组合之一。原因很直接:网络流量本质上是带时序的序列数据,每个流由一串带统计特征的数据包组成,CNN 擅长从单条流里提取局部特征模式,LSTM 擅长捕捉流与流之间的时序依赖,两者串起来刚好覆盖「单流特征 + 跨流时序」两个维度。
这套方案适合谁?如果你正在做网络安全、入侵检测方向的课程设计或毕业设计,手头有 CICIDS2017、NSL-KDD 这类公开数据集,想跑通一个从数据预处理到模型训练再到检测输出的完整链路,那 CNN+LSTM 是一个性价比很高的选择。它不需要 GPU 集群,一张消费级显卡甚至 CPU 都能跑起来,代码量可控,论文里也好写创新点。但要注意,课程设计级别的方案和工业级部署之间差距很大,后面会具体讲边界在哪。
2. 数据从哪来、怎么洗:流量特征工程的四个关键决策
2.1 数据集选型:CICIDS2017 和 NSL-KDD 的取舍
做网络流量检测,第一步不是写模型,是找数据。目前公开数据集里用得最多的是 CICIDS2017 和 NSL-KDD。NSL-KDD 是老牌数据集,样本量小、特征维度低(41 维),跑起来快,适合快速验证模型结构是否 work。但它的问题是年代久远,攻击类型和现代网络环境脱节严重,拿它跑出来的准确率再高,放到真实场景里也没有参考价值。
CICIDS2017 是加拿大网络安全研究所发布的,包含正常流量和多种攻击流量(DDoS、PortScan、Web Attack、Brute Force 等),特征维度 78 维,样本量百万级。它的优势是流量采集时间近、攻击类型全、特征工程已经做得比较到位。缺点是类别极度不平衡,正常流量占绝大多数,少数攻击类样本可能只有几百条。我一般建议:如果只是跑通流程,用 NSL-KDD;如果要写论文、做对比实验,用 CICIDS2017,但必须处理类别不平衡问题。
提示:CICIDS2017 原始数据是 pcap 格式,需要用 CICFlowMeter 提取特征生成 CSV。网上能找到已经提取好的 CSV 版本,省去这一步。
2.2 特征清洗:无穷值、缺失值和常数列怎么处理
拿到 CSV 之后,第一件事是检查数据质量。CICIDS2017 有几个经典坑:某些特征列存在 Infinity 和 NaN 值,原因是流量计算时出现了除以零的情况;还有一些列在所有样本中取值相同,属于无效特征。下面是我常用的清洗流程:
import pandas as pd import numpy as np def clean_data(df): # 1. 替换无穷值为 NaN df.replace([np.inf, -np.inf], np.nan, inplace=True) # 2. 删除包含 NaN 的行(也可以选择填充,但流量特征填充容易引入噪声) df.dropna(inplace=True) # 3. 删除常数列(方差为 0 的特征对模型没有贡献) nunique = df.nunique() constant_cols = nunique[nunique <= 1].index.tolist() df.drop(columns=constant_cols, inplace=True) # 4. 去除重复行 df.drop_duplicates(inplace=True) # 5. 重置索引 df.reset_index(drop=True, inplace=True) print(f"清洗后剩余特征数: {df.shape[1]}, 样本数: {df.shape[0]}") return df这段代码的逻辑很直白:无穷值本质上是计算异常,不能直接当有效值用;常数列对分类没有信息增益,留着只会增加计算量;重复行会导致训练集和测试集泄露,必须去掉。参数方面,dropna的how默认是any,也就是只要有一个特征缺失就删整行。如果你的数据缺失率很高,可以考虑用中位数填充,但流量特征里缺失往往意味着采集异常,删掉更安全。
2.3 标签编码与类别不平衡处理
CICIDS2017 的标签列是字符串类型的攻击名称,需要转成数值。同时,正常流量和攻击流量的比例可能达到 10:1 甚至更高,直接训练会导致模型偏向多数类。常见做法有两种:欠采样(从多数类中随机抽取与少数类相当的数量)和过采样(用 SMOTE 生成少数类合成样本)。课程设计里我一般用欠采样,因为实现简单、训练快。
from sklearn.preprocessing import LabelEncoder from sklearn.utils import resample def balance_data(df, label_col='Label'): # 标签编码 le = LabelEncoder() df['label_encoded'] = le.fit_transform(df[label_col]) # 分离多数类和少数类 df_majority = df[df['label_encoded'] == 0] # 假设 0 是 BENIGN df_minority = df[df['label_encoded'] != 0] # 欠采样:多数类降到少数类样本数的 3 倍(不用 1:1,保留一些真实分布信息) n_minority = len(df_minority) df_majority_downsampled = resample( df_majority, replace=False, n_samples=min(len(df_majority), n_minority * 3), random_state=42 ) df_balanced = pd.concat([df_majority_downsampled, df_minority]) df_balanced = df_balanced.sample(frac=1, random_state=42).reset_index(drop=True) print(f"平衡后标签分布:\n{df_balanced['label_encoded'].value_counts()}") return df_balanced, le这里有个细节:欠采样比例不要设成 1:1,因为真实网络中正常流量本来就占多数,完全均衡反而会让模型对正常流量的识别率下降。我一般设 3:1 到 5:1 之间,具体看少数类样本量。random_state固定住是为了保证实验可复现,这个在写论文时很重要。
2.4 归一化与滑动窗口构造
CNN+LSTM 的输入需要是三维张量,形状通常是(样本数, 时间步长, 特征数)。但 CICIDS2017 的每条记录是一个独立的流,没有天然的时间步维度。常见做法是用滑动窗口把连续的 N 条流拼成一个序列,模拟时序关系。虽然这种做法在严格意义上不是真正的时序建模,但在课程设计层面足够用。
from sklearn.preprocessing import MinMaxScaler def create_sequences(X, y, time_steps=10): """ 用滑动窗口将二维数据转成三维序列 X: (样本数, 特征数) y: (样本数,) time_steps: 每个序列包含的流数量 """ Xs, ys = [], [] for i in range(len(X) - time_steps): Xs.append(X[i:(i + time_steps)]) ys.append(y[i + time_steps]) # 用序列末尾的标签作为该序列的标签 return np.array(Xs), np.array(ys) # 归一化 scaler = MinMaxScaler() feature_cols = [c for c in df_balanced.columns if c not in ['Label', 'label_encoded']] X_scaled = scaler.fit_transform(df_balanced[feature_cols]) y = df_balanced['label_encoded'].values # 构造序列 X_seq, y_seq = create_sequences(X_scaled, y, time_steps=10) print(f"序列形状: {X_seq.shape}") # (样本数, 10, 特征数)time_steps这个参数需要调。设太小(比如 3),LSTM 捕捉不到足够的时序依赖;设太大(比如 50),训练慢且容易过拟合。我一般从 10 开始试,根据验证集表现调整。归一化用 MinMaxScaler 而不是 StandardScaler,是因为流量特征里有很多计数类特征,分布不均匀,MinMax 能把它们压到 [0,1] 区间,对 CNN 的卷积操作更友好。
3. CNN+LSTM 模型怎么搭:从 Keras 代码到参数调优
3.1 模型结构设计:为什么是 CNN 在前、LSTM 在后
先讲清楚为什么这么串。CNN 的核心是卷积核在局部窗口上滑动,提取的是局部特征。放在流量检测场景里,一个卷积核可以捕捉相邻几个数据包之间的统计模式,比如短时间内连续出现的大包、特定端口号的频繁出现等。LSTM 的核心是门控机制,能记住长距离依赖。放在 CNN 后面,它接收的是 CNN 提取的高层特征序列,学习的是这些特征在时间维度上的演变规律。
如果反过来,LSTM 在前、CNN 在后,LSTM 输出的序列再经过卷积,效果通常不如前者。原因是 LSTM 的输出已经是高度抽象的时间特征,再做卷积容易丢失时序信息。所以标准做法是:Conv1D 提取局部模式,MaxPooling 降维,然后送入 LSTM 做时序建模,最后全连接层分类。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv1D, MaxPooling1D, LSTM, Dense, Dropout, BatchNormalization from tensorflow.keras.optimizers import Adam def build_cnn_lstm(input_shape, num_classes): model = Sequential([ # 第一层卷积:64 个卷积核,核大小 3,激活函数 ReLU Conv1D(filters=64, kernel_size=3, activation='relu', padding='same', input_shape=input_shape), BatchNormalization(), MaxPooling1D(pool_size=2), Dropout(0.3), # 第二层卷积:128 个卷积核,核大小 3 Conv1D(filters=128, kernel_size=3, activation='relu', padding='same'), BatchNormalization(), MaxPooling1D(pool_size=2), Dropout(0.3), # LSTM 层:128 个隐藏单元,return_sequences=False 表示只取最后一个时间步的输出 LSTM(128, return_sequences=False), Dropout(0.3), # 全连接层 Dense(64, activation='relu'), Dropout(0.3), # 输出层:softmax 多分类 Dense(num_classes, activation='softmax') ]) model.compile( optimizer=Adam(learning_rate=0.001), loss='sparse_categorical_crossentropy', metrics=['accuracy'] ) return model model = build_cnn_lstm(input_shape=(10, X_seq.shape[2]), num_classes=len(le.classes_)) model.summary()逐层解释一下参数选择。第一层卷积用 64 个核,是因为输入特征维度通常几十到上百,64 个核能覆盖足够多的局部模式。kernel_size=3是经验值,对应一次看 3 个相邻时间步。padding='same'保证输出长度和输入一致,避免边缘信息丢失。BatchNormalization 放在卷积后面、激活前面(这里 Keras 的写法是先激活再 BN,实际效果差异不大),作用是加速收敛、防止梯度消失。Dropout 设 0.3 是防止过拟合的常规操作,如果训练集很大可以降到 0.2。
LSTM 层用 128 个隐藏单元,return_sequences=False表示只取最后一个时间步的输出送入全连接层。如果你的任务需要每个时间步都输出(比如序列标注),就设成 True。损失函数用sparse_categorical_crossentropy而不是categorical_crossentropy,是因为标签是整数编码而不是 one-hot 编码,这样省内存。
3.2 训练配置:batch size、epoch 和早停策略
模型搭好之后,训练配置直接影响最终效果。下面是我常用的训练代码:
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau from sklearn.model_selection import train_test_split # 划分训练集和测试集 X_train, X_test, y_train, y_test = train_test_split( X_seq, y_seq, test_size=0.2, random_state=42, stratify=y_seq ) # 回调函数 early_stop = EarlyStopping( monitor='val_loss', patience=5, restore_best_weights=True, verbose=1 ) reduce_lr = ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=3, min_lr=1e-6, verbose=1 ) # 训练 history = model.fit( X_train, y_train, validation_split=0.2, epochs=50, batch_size=64, callbacks=[early_stop, reduce_lr], verbose=1 )batch_size=64是折中值。设太小(比如 16),训练不稳定,loss 震荡大;设太大(比如 256),梯度更新次数少,收敛慢。epochs=50配合 EarlyStopping,实际训练轮数通常远小于 50。patience=5表示验证集 loss 连续 5 轮不下降就停止训练,并恢复最优权重。ReduceLROnPlateau是学习率衰减策略,当验证集 loss 停滞时把学习率减半,帮助模型跳出局部最优。
stratify=y_seq这个参数很重要,它保证训练集和测试集的类别比例一致。如果不加,可能测试集里某个攻击类样本极少,评估结果不可信。
3.3 评估指标:准确率之外必须看混淆矩阵
训练完之后,不要只看 accuracy。网络流量检测是典型的不平衡分类问题,准确率高不代表模型好用。比如 99% 的样本是正常流量,模型全预测为正常也能拿到 99% 准确率,但攻击流量一个都没抓到。必须看混淆矩阵和每个类别的 precision、recall、f1-score。
from sklearn.metrics import classification_report, confusion_matrix import matplotlib.pyplot as plt import seaborn as sns # 预测 y_pred_prob = model.predict(X_test) y_pred = np.argmax(y_pred_prob, axis=1) # 分类报告 print(classification_report(y_test, y_pred, target_names=le.classes_)) # 混淆矩阵 cm = confusion_matrix(y_test, y_pred) plt.figure(figsize=(10, 8)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=le.classes_, yticklabels=le.classes_) plt.xlabel('Predicted') plt.ylabel('True') plt.title('Confusion Matrix') plt.tight_layout() plt.savefig('confusion_matrix.png', dpi=150) plt.show()看混淆矩阵时重点关注两点:一是正常流量被误判为攻击的比例(假阳性),这个太高会导致告警疲劳;二是攻击流量被漏判的比例(假阴性),这个太高意味着安全防护形同虚设。课程设计里如果能把假阴性率压到 5% 以下,就算不错了。
4. 避坑与排查:五个让模型翻车的经典问题
4.1 训练 loss 不下降,accuracy 卡在某个值不动
现象:训练开始后 loss 一直在 0.6 附近震荡,accuracy 停在 50% 左右,几十轮都不变。
原因:最常见的是学习率设太大,梯度更新步长过大导致在最优解附近来回跳。其次是数据没有归一化,特征值范围差异太大,导致梯度方向被大值特征主导。
解决:先把学习率降到 0.0001 试试,如果还不行,检查归一化步骤是否漏掉了某些列。另外确认标签编码是否正确,如果标签是字符串直接送进去,模型会报错而不是静默失败,但标签编码错位(比如 0 和 1 反了)会导致模型学到相反的模式。
4.2 验证集准确率远高于测试集
现象:训练时验证集 accuracy 到 98%,但拿测试集一跑只有 80% 出头。
原因:数据泄露。最常见的情况是归一化时用了全部数据 fit,然后才划分训练测试集。这样测试集的统计信息已经泄露到了训练过程中。另一个原因是重复样本没有去干净,同一个流既出现在训练集又出现在测试集。
解决:归一化必须在训练集上 fit,然后 transform 测试集。去重要在划分之前做。如果用了滑动窗口构造序列,注意窗口之间可能有重叠,重叠部分会导致信息泄露,可以改用不重叠的窗口或者按时间顺序划分。
4.3 少数类攻击样本 recall 极低
现象:正常流量的 f1 是 0.99,但某个攻击类别的 recall 只有 0.3 甚至更低。
原因:类别不平衡没有处理好,或者欠采样比例太激进,少数类样本被淹没。另一个可能是该攻击类别的特征和正常流量太相似,模型区分不开。
解决:先检查该类别的样本量,如果少于 500 条,考虑用 SMOTE 过采样而不是欠采样。如果样本量够但 recall 还是低,可以给损失函数加类别权重,让模型对少数类的错误惩罚更大。Keras 里可以用class_weight参数:
from sklearn.utils.class_weight import compute_class_weight class_weights = compute_class_weight( 'balanced', classes=np.unique(y_train), y=y_train ) class_weight_dict = dict(enumerate(class_weights)) model.fit( X_train, y_train, class_weight=class_weight_dict, ... )4.4 模型训练时间过长,一个 epoch 要跑十几分钟
现象:数据集只有几十万条,但每个 epoch 耗时超过 10 分钟,50 个 epoch 跑完要一整天。
原因:可能是 batch_size 设太小,导致迭代次数过多。也可能是 LSTM 层隐藏单元太多,参数量爆炸。还有一种情况是数据没有转成 numpy 数组,每次 batch 都在做 pandas 索引,速度极慢。
解决:先把 batch_size 提到 128 或 256 试试。然后检查 LSTM 的 units 是不是设了 256 以上,课程设计场景 64 到 128 足够了。最后确认输入数据是np.array类型而不是 DataFrame,后者在 Keras 里会触发额外的类型转换开销。
4.5 保存的模型加载后预测结果不一致
现象:训练完保存模型,下次加载后拿同样的输入预测,结果和保存前不一样。
原因:没有保存归一化器的参数。模型保存的是权重,但输入数据的归一化是在模型外部做的。如果重新加载时用了不同的 scaler 或者没有做归一化,预测结果自然不同。
解决:把 scaler 的均值和方差一起保存,加载时先 transform 再送入模型。可以用 joblib 保存 scaler:
import joblib # 保存 joblib.dump(scaler, 'scaler.pkl') model.save('cnn_lstm_model.h5') # 加载 scaler = joblib.load('scaler.pkl') model = load_model('cnn_lstm_model.h5') X_new_scaled = scaler.transform(X_new)5. 从课程设计到可用原型:三个让方案更值钱的进阶技巧
5.1 用注意力机制替换 LSTM 的最后一层
LSTM 的问题是长序列上容易遗忘早期信息,而且训练慢。如果你的序列长度超过 20,可以试试在 CNN 后面加一个注意力层,让模型自己学习哪些时间步更重要。Keras 里可以用Attention层或者自己写一个简单的注意力池化:
from tensorflow.keras.layers import Layer import tensorflow.keras.backend as K class AttentionPooling(Layer): def __init__(self, **kwargs): super(AttentionPooling, self).__init__(**kwargs) def build(self, input_shape): self.W = self.add_weight( name='attention_weight', shape=(input_shape[-1], 1), initializer='glorot_uniform', trainable=True ) self.b = self.add_weight( name='attention_bias', shape=(input_shape[1], 1), initializer='zeros', trainable=True ) super(AttentionPooling, self).build(input_shape) def call(self, x): # x shape: (batch, time_steps, features) e = K.tanh(K.dot(x, self.W) + self.b) # (batch, time_steps, 1) a = K.softmax(e, axis=1) # 注意力权重 output = x * a # 加权 return K.sum(output, axis=1) # 池化 def compute_output_shape(self, input_shape): return (input_shape[0], input_shape[-1])把 LSTM 换成AttentionPooling之后,训练速度通常能快 30% 以上,而且在小样本上表现更稳定。代价是可解释性变差,注意力权重虽然能可视化,但不如 LSTM 的门控信号直观。
5.2 用混淆矩阵反推特征工程方向
模型跑完之后,如果某个类别的 recall 特别低,不要急着调模型,先回去看数据。把该类别的样本单独拎出来,和它被误判成的类别做特征对比。比如 PortScan 被误判成 Normal,对比两者的特征均值,往往能发现某个关键特征(比如 Destination Port 的方差)在两类之间差异不明显,说明这个特征没有区分度,需要构造新特征或者换特征。
我一般会做一张特征重要性表,用随机森林跑一遍特征重要性排序,把排名后 20% 的特征删掉再训练,往往能提升 1 到 2 个百分点。这个技巧在课程设计论文里也好写,属于「基于特征选择的优化」这类创新点。
5.3 用 TensorBoard 看训练过程,别靠猜
很多人训练模型就是盯着终端输出的 loss 和 accuracy,看到不下降就手动停掉。其实 TensorBoard 能提供更多信息,比如每层的权重分布、梯度大小、学习率变化。如果某一层的梯度一直是 0,说明该层没有学到东西,可能是初始化有问题或者被前面的层阻断了梯度。
import tensorflow as tf log_dir = "logs/fit/" + datetime.datetime.now().strftime("%Y%m%d-%H%M%S") tensorboard_callback = tf.keras.callbacks.TensorBoard( log_dir=log_dir, histogram_freq=1, write_graph=True ) model.fit( X_train, y_train, validation_split=0.2, epochs=50, batch_size=64, callbacks=[early_stop, reduce_lr, tensorboard_callback] )跑完之后在终端执行tensorboard --logdir logs/fit,浏览器打开就能看到可视化面板。我一般重点看三个图:loss 曲线是否平滑下降、学习率是否在合理区间、每层的权重直方图是否在更新。如果权重直方图一直不动,说明这层是死的,直接删掉或者换初始化方式。
最后说一个我自己的习惯:每次跑完实验,不管结果好坏,都把超参数、数据集版本、随机种子记在一个表格里。课程设计往往要跑几十组对比实验,没有记录的话,过两天就忘了哪组参数对应哪个结果。这个习惯帮我省了很多后悔药,希望帮到你。
本文还有配套的精品资源,点击获取