简介:一套基于卷积神经网络的网络入侵检测项目,使用经典 KDD Cup 数据集,完整覆盖从网络流量数据预处理、CNN 模型搭建、训练分类到异常检测评估的整个流程。项目面向 Python 学习者、毕业设计学生及网络安全入门研究者,可用于区分正常流量与恶意流量,实测准确率约 99.5%。包内共 16 个文件,含 4 个 Python 源码文件、2 个 gz 格式原始数据包、3 个 TensorFlow 事件日志文件,以及配置类 xml、工程文件 iml 和 README 说明文档,压缩包约 17.52MB,已有 350 人学习下载。代码按数据加载、模型构建、训练评估等模块组织,附带全部训练数据与可直接运行的源码;README 给出运行说明,训练脚本包含数据标准化、特征提取、模型评估和优化接口,方便复现实验并在此基础上调整网络结构、超参数,可用于课程设计、毕业设计或入侵检测方向入门参考。
1. 从99.5%准确率说起:CNN与KDD99为什么能配对
说一个去年熬夜调项目时的直观感受:KDD99上面跑全连接网络,调了两周也卡在97%,换成只有几层的一维卷积,一个晚上就到了99.5%。这不是玄学,而是把连接记录当成一条“带位置信息的序列”去看待,卷积核天然擅长抓一段特征之间的局部关系。这个项目包正是这么做的:源码里main.py、cnn_main.py两条入口,配上kddcup.data_10_percent.gz数据,跑通后能区分正常流量和DoS、Probe等异常流量。适合两类人:拿它做课程设计或毕业设计、需要讲清原理又能复现的学生;以及想看看CNN怎么处理表格型流量数据的工程师。下文从数据清洗开始,把从原始数据到99.5%准确率的每个环节拆开讲。
2. KDD99数据预处理:从原始连接记录到CNN可用的张量
2.1 数据格式与标签分布
KDD Cup 1999 是入侵检测领域最常被搬运的数据集,项目里的kddcup.data_10_percent.gz是 10% 抽样版本,约 49 万条连接记录,每条记录一行,由 41 个特征和 1 个标签组成。这 41 个特征不是随意排列的,前 9 个描述连接基本属性,比如 duration、protocol_type、service、flag,后面是内容特征和基于统计的流量特征。CNN 会把整行特征当作一个长度 41 的向量,所以第一个坑就是不能改变特征顺序。
标签不是简单的正常/异常。原始数据里有 23 种攻击类型加上 normal,常见做法是先聚成四类:DoS、Probe、R2L、U2R,再把四类攻击统一映射成 1 表示异常,normal 映射成 0。这样做的好处是模型只需要输出一个 sigmoid 概率,直接把问题转成二分类。如果你要打印分类报告,保留四类标签会更有说服力,但保存模型时还是建议用二分类,后续接入监控更容易写阈值。
| 特征分组 | 列名示例 | 处理方式 |
|---|---|---|
| 类别型特征 | protocol_type, service, flag | 独热编码 |
| 连续数值特征 | duration, src_bytes, count, srv_count 等 | 标准化 |
| 攻击标签 | normal / 攻击类型 | 二值化 + 数值化 |
2.2 数据读取、标准化与独热编码
我一般习惯把数据读取和预处理拆成独立的脚本,项目里handle2.py正承担类似角色。先用 pandas 读取 gz 压缩文件,手动给一份列名,保证后续特征顺序固定。
import pandas as pd import numpy as np from sklearn.preprocessing import StandardScaler cols = [ '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' ] # 共 41 个特征 df = pd.read_csv('kddcup.data_10_percent.gz', header=None, names=cols + ['label']) df = df.dropna() # KDD99理论上没有缺失,但保留这一步防御意外names=cols + ['label']是必须的,否则 pandas 会把第一行当成表头。dropna 在原始数据上几乎不会删除样本,但能避免训练时遇到 NaN 直接报错。缺失值在 KDD99 里基本不存在,我不建议做填充,入侵检测特征一旦被填充,边界样本的判别信号就被抹掉了。
接着处理三种类别特征。service 有约 70 个类别,如果用 LabelEncoder 变成 0 到 69,CNN 会误以为 protocol_type 的数值有大小关系,tcp=2 比 udp=1 大两倍这种假设在流量语义里完全不成立,所以走独热编码更稳妥。
y_raw = df['label'].map(lambda x: 0 if x == 'normal' else 1) X = df.drop(columns=['label']) cat_cols = ['protocol_type', 'service', 'flag'] X = pd.get_dummies(X, columns=cat_cols) scaler = StandardScaler() X_scaled = scaler.fit_transform(X) print('训练集大小: %d, 输入维度: %d' % (X_scaled.shape[0], X_scaled.shape[1]))pd.get_dummies会自动在指定列上展开,得到的特征维度在 120 左右,具体数量取决于 service 的类别个数。StandardScaler 把所有数值特征拉到均值为 0、方差为 1,这样卷积核在初始化时不会因为 duration 有几十万的大数值而偏向某一个方向。注意,这里为了演示先 fit 了 scaler;真实项目里应该先切分训练集和测试集,再用训练集 fit、测试集只 transform,否则测试集的信息会泄露进训练过程。
2.3 切分与封装
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X_scaled, y_raw, test_size=0.2, random_state=42, stratify=y_raw) X_train = X_train[..., np.newaxis] X_test = X_test[..., np.newaxis] print('X_train shape:', X_train.shape)stratify=y_raw是处理类别不平衡的第一步,它保证训练集和测试集中正常/异常比例一致,评估时不会因为抽样偏差出现虚假高分。最后一行把特征维度扩展成三维,因为 Conv1D 的输入要求是(样本数, 特征长度, 通道数),这里通道数取 1。如果不做这一步,模型定义阶段就会直接报错。
提示:独热编码后特征数量会变化,所有后续代码里引用的 input_dim 都应以
X_train.shape[1]为准,不要写死成 41,换数据子集时维度可能改变。
3. 一维CNN入侵检测模型设计:卷积核到底在提取什么
3.1 为什么用一维卷积而不是全连接
很多第一次看到这个项目的人会问:流量特征又不是图像,凭什么用 CNN 能把准确率从 97% 拉到 99.5%?答案在于特征之间的局部相关性。例如serror_rate和srv_serror_rate同时为高,往往意味着 SYN 错误比例异常;same_srv_rate与dst_host_same_srv_rate的组合能反映端口扫描行为。这类“相邻几个特征联合判断”的模式,全连接网络也能学,但要堆大量参数;一维卷积用一个小窗口滑过特征向量,天然就能提取局部联合模式。
关键在于特征顺序是有意义的。KDD99 特征并非随机排列,前 9 个是连接基础属性,后面是内容属性和基于时间窗口的统计特征,同一个语义族群挨在一起。把特征列打乱,卷积核学到的东西就变成随机拼凑。我拆过项目源码,mian_cnn.py里没有打乱特征列,这就是它能在几个 epoch 内快速收敛的原因之一。
与全连接相比,卷积还有参数共享优势。一个卷积核在 120 维的特征向量上滑动,同一组权重被复用到不同位置,参数量只有全连接的几十分之一。在只有几万条样本的训练子集上,模型不容易过拟合,这也是项目同时给出kddcup.data.gz完整数据和 10% 抽样数据,两者都能训练出可用模型的原因。
3.2 网络结构:卷积层、汇聚层与全连接层
这个模型可以理解为 LeNet5 卷积神经网络结构图的一维变体,把二维卷积核换成一维。我按cnn_main.py常见写法整理了一份可直接用的结构,各层输出形状如下:
| 层名称 | 输出形状 | 参数量 | 作用 |
|---|---|---|---|
| Input(120,1) | (None, 120, 1) | 0 | 输入特征向量 |
| Conv1D(64, 3) | (None, 118, 64) | 256 | 提取相邻 3 个特征的局部组合 |
| MaxPooling1D(2) | (None, 59, 64) | 0 | 汇聚层,降维并保留最强信号 |
| Conv1D(128, 3) | (None, 57, 128) | 24832 | 更抽象的高层模式 |
| GlobalMaxPooling1D | (None, 128) | 0 | 全局最大汇聚,替代 Flatten |
| Dropout(0.5) | (None, 128) | 0 | 抑制过拟合 |
| Dense(64) | (None, 64) | 8320 | 分类决策 |
| Dense(1, sigmoid) | (None, 1) | 65 | 输出异常概率 |
这里重点说一下卷积神经网络的汇聚层,也就是常说的池化层。MaxPooling1D 的作用是降低特征维度,同时让卷积对小的位置偏移不敏感。比如某个攻击模式的尖峰出现在第 40 位还是第 42 位,经过宽度为 2 的最大池化后,最大响应可能落在同一个区间,模型就更容易学到“这里有个异常特征组合”,而不是死记具体位置。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv1D, MaxPooling1D, GlobalMaxPooling1D, Dense, Dropout, BatchNormalization def build_cnn(input_dim): model = Sequential() model.add(Conv1D(filters=64, kernel_size=3, activation='relu', input_shape=(input_dim, 1))) model.add(BatchNormalization()) model.add(MaxPooling1D(pool_size=2)) model.add(Conv1D(filters=128, kernel_size=3, activation='relu')) model.add(BatchNormalization()) model.add(GlobalMaxPooling1D()) model.add(Dropout(0.5)) model.add(Dense(64, activation='relu')) model.add(Dropout(0.3)) model.add(Dense(1, activation='sigmoid')) return model model = build_cnn(X_train.shape[1]) model.summary()BatchNormalization 不影响卷积核提取特征,但能显著加速收敛。在入侵检测这种类别比例悬殊的任务上,它可以让模型在训练初期不因为输入分布抖动而卡在局部最优。注意第一层的input_shape不能写成(input_dim,),Conv1D 要求第三个维度是通道数,这也是从 2D 转 1D 时最常见的报错来源。
3.3 损失函数与评估指标的匹配
二分类用 binary_crossentropy 没有疑问,真正要小心的是准确率这个指标。KDD99 里正常样本占比约 20%,异常样本约 80%,如果模型把全部样本都预测成异常,准确率也有 80% 以上。因此模型编译时需要额外关注 AUC:
model.compile( optimizer='adam', loss='binary_crossentropy', metrics=['accuracy', 'AUC'] )AUC 不受分类阈值影响,能直接反映模型区分正负样本的能力。而准确率只告诉你总体判断对的比例。要复现项目宣称的 99.5%,应该同时看分类报告里的召回率和 F1,而不是只看 accuracy 一行。如果发现 accuracy 高但 recall 不足,说明正常样本被放过太多,这个模型在真实网络环境里会漏报。
4. 训练与调优:复现99.5%准确率的关键步骤
4.1 训练主流程与TensorBoard监控
项目压缩包里有一个events.out.tfevents.1482980284.zjx-24000635文件,这是 TensorBoard 的事件日志,说明原作者用 tf.keras 训练时记录了运行序列。复现时不要直接拿这份日志当自己的可视化结果,机器环境不同,数值会有差异,但可以用它验证 TensorBoard 配置是否正确。
训练代码的核心部分如下:
from tensorflow.keras.callbacks import TensorBoard, EarlyStopping, ReduceLROnPlateau from sklearn.utils.class_weight import compute_class_weight import time log_dir = "logs/" + time.strftime("%m-%d_%H-%M") callbacks = [ TensorBoard(log_dir=log_dir, histogram_freq=1), EarlyStopping(monitor='val_loss', patience=5, restore_best_weights=True), ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=2, min_lr=1e-6) ] class_weights = compute_class_weight( class_weight='balanced', classes=np.array([0, 1]), y=y_train) class_weight_dict = {i: cw for i, cw in enumerate(class_weights)} model.fit(X_train, y_train, epochs=30, batch_size=128, validation_split=0.2, callbacks=callbacks, class_weight=class_weight_dict)histogram_freq=1会记录每一层的权重分布,方便观察梯度是否消失。EarlyStopping 监听val_loss而不是val_accuracy,因为 loss 更容易反映模型置信度的变化,准确率在小波动时可能长时间不变。compute_class_weight用来计算正常类和异常类的补偿权重,异常类样本多,权重就小;正常类样本少,权重反而大,这样模型就不会一直偏向预测成多数类。
4.2 训练中的坑:过拟合、学习率与类别不平衡
我复现这个项目时踩过几个比较典型的坑,整理成一张表:
| 现象 | 原因 | 解决手段 |
|---|---|---|
| 训练 loss 降到 0.01,验证 loss 回升 | 模型记住了攻击样本的噪声 | 增大 Dropout、EarlyStopping、加 L2 正则 |
| 验证 loss 抖动很大 | 学习率太高 | ReduceLROnPlateau 联动调低 |
| 准确率 99.8% 但召回率 80% | 类别不平衡导致模型偏向多数类 | 加权损失、上采样少数类样本 |
| 验证时精度高,部署时崩 | 特征列顺序不一致 | 保存 feature_columns.txt 并按顺序构造输入 |
还有两个细节值得单独说明。第一,如果忘记标准化,src_bytes这种最大值可以到几十万的数值特征会主导梯度更新,卷积核几乎学不到其他特征的信息。第二,kernel_size 不是越大越好,120 维的输入用 kernel_size=7 会把序列末尾的特征提前卷没了,我建议用 3 或 5,配合 MaxPooling1D 逐步扩大感受野,效果更稳。
4.3 模型保存与结果复现
训练完成后,模型和相关配置需要一起保存:
model.save('ids_cnn.h5') import joblib joblib.dump(scaler, 'scaler.pkl') with open('feature_columns.txt', 'w') as f: f.write('\n'.join(X.columns))对应特征列名非常重要。部署时需要按同样的顺序构造特征向量,少一列、换一列都会让预测结果完全错乱。评估阶段用 scikit-learn 直接出报告:
from sklearn.metrics import classification_report y_pred = (model.predict(X_test) > 0.5).astype(int) print(classification_report(y_test, y_pred, target_names=['normal', 'attack']))在二分类任务上,合理的结果应该是 normal 和 attack 的 precision、recall 都在 0.98 以上,整体 accuracy 才能到 99.5%。注意,99.5% 是二分类下的数字,如果按五分类(normal + DoS + Probe + R2L + U2R)去训练,准确率会掉到 97% 左右。这不是模型退步,而是任务难度增加。复现时先确认标签映射是二分类还是多分类,别拿多分类的结果对比二分类的指标。
5. 部署视角:把CNN入侵检测模型接进实时监控的三种做法
5.1 离线批处理方式
最稳妥的落地方式是离线分析:把抓到的 pcap 文件拆成连接记录,用 TShark 或 Scapy 按 KDD99 的特征定义提取出 41 维特征,然后送入保存好的模型批量打分。这种方式适合安全团队做攻击溯源,不要求秒级反馈,特征提取出错还可以回放重算。
5.2 实时检测:滑动窗口 + 模型预测
实时监控要解决的是“流还没结束,如何判断这台机器是否在攻击”。常见做法是把 TCP 连接的前 N 个报文聚合成半连接特征,用滑动窗口周期性更新特征向量。预测时只需要加载模型和配置:
from tensorflow.keras.models import load_model import joblib import pandas as pd model = load_model('ids_cnn.h5') scaler = joblib.load('scaler.pkl') columns = open('feature_columns.txt').read().splitlines() def predict_one(raw_feature: dict) -> float: df = pd.DataFrame([raw_feature])[columns] x = scaler.transform(df.fillna(0)) x = x.reshape(1, -1, 1) prob = model.predict(x, verbose=0)[0][0] return prob # 调用示例 prob = predict_one(sample_feature) if prob > 0.5: print('异常连接,触发告警')raw_feature是外部实时组装的特征字典,必须包含columns列表里的所有键,顺序也要一模一样。fillna(0)给缺失特征一个中性值,但在严格监控场景下,我建议缺失就直接丢弃这条样本,因为入侵检测里“缺特征”本身可能就是一种躲避行为。
5.3 一个容易被忽视的细节:特征顺序固化
项目自带源码里main.py和cnn_main.py的预测入口可能不一样,但无论哪个入口,最终都要保证训练时的特征顺序被固化。我在生产环境见过多次高准确率模型上线后崩掉的情况,根因就是部署脚本里用字典推导式构造特征,Python 的字典虽然保序,但同事改了一个字段名,顺序就全乱了。最好把训练时保存的顺序文件定义为常量:
FEATURE_ORDER = tuple(open('feature_columns.txt').read().splitlines())然后把预测函数的columns直接赋值为这个元组,不允许运行时再动态排序。这个细节比调模型参数更值得优先处理,因为特征顺序错了,模型再强也输出不了正确结果。
本文还有配套的精品资源,点击获取