1D CNN实现网络入侵检测:从NSL-KDD预处理到99.5%正确率
简介:基于卷积神经网络的网络入侵检测系统,以KDD Cup公开数据集为依托,面向高校毕业设计、网络安全课程项目及入门研究者,重点解决网络流量的二分类与异常模式识别问题。项目从数据准备、标准化与特征提取入手,逐步展开CNN网络设计、训练、评估与超参数优化,形成一套完整可复用的实验流程。压缩包共16个文件,涵盖Python源文件(py)、XML配置、GZ压缩数据及IDE工程文件(iml)等类型,整体约17.52MB,源码与数据分离,便于按模块研究;多份Python脚本分别承担数据处理、模型构建与训练测试等环节,配以说明文档,能帮助快速上手。目前已有350人学习下载。读者可从中获得可直接运行的入侵检测程序、KDD Cup训练测试数据、README说明文档以及模型调优参考,既能用于快速复现99.5%准确率实验,也可作为毕业设计答辩或进一步研究CNN在安全领域应用的起点。
1. 正确率 99.5% 的 CNN 入侵检测系统:这个数字意味着什么
解压一个名为“python基于卷积神经网络的网络入侵检测系统”的压缩包,你会看到源码、数据文件和一个非常显眼的数字:正确率 99.5%。第一次跑通这种工程时我确实兴奋了十几秒,但后来发现,这类项目真正难的不是把准确率刷到 99%,而是搞清楚这个数字是在哪份数据上、用哪种口径算出来的。网络入侵检测不是把攻击流量“拍成照片”再分类,而是把一次会话的几十个特征(协议、端口、字节数、连接频率等)交给一维卷积神经网络去捕捉组合模式。
1D CNN 做这件事,比 LSTM 更可靠,比随机森林更省特征工程,但它也更容易踩中数据泄漏、类别失衡、特征顺序错乱这些暗坑。这篇笔记会按“数据集选型 → 特征预处理 → 模型搭建 → 训练验证 → 实时部署”的顺序把这个方案完整拆一遍,并给出可直接照抄的代码和参数。适合两类人:拿这个方向做课程设计或毕业设计的同学,以及想在自己环境里试点流量检测的工程师。
2. 为什么是 1D CNN 而不是 LSTM:入侵检测的特征组合逻辑与数据集选型
2.1 一维卷积神经网络在入侵检测里学什么
网络入侵检测系统拿到的是经过处理的会话记录,不是原始字节流。每条记录被提炼成一行定长的数值向量,比如连接持续时间、协议类型、源到目标字节数、登录失败次数、过去两秒内相同服务连接占比等。传统做法是人工构造“短连接 + 高频 + 特定目的端口”这类规则,但攻击模式太多,规则根本写不完。一维卷积神经网络做的事情,就是在这个定长特征向量上做滑窗卷积,自动寻找特征之间的组合关系。
如果你去找一维卷积神经网络介绍的文献,核心主线就一句话:输入形状是 (batch, steps, channels),卷积核在 steps 维度上滑动,学习局部窗口内的模式。放在入侵检测场景里,steps 就是特征维,kernel_size=3 意味着每个卷积核同时看三个相邻特征。比如检测 CC 攻击时,“连接频率高、目的端口固定、平均字节数小”这三个特征如果刚好落在同一个窗口里,卷积核就有机会把它们组合成一个语义。这类跨特征组合是单特征阈值规则很难表达的。
一个常见的误区是把特征向量强行 reshape 成二维矩阵再丢给二维 CNN。网络流量会话特征通常只有一百维左右,不携带空间结构,硬拼成图片只是在制造计算量和过拟合风险。1D CNN 的参数少、收敛快,而且特征顺序本身就经过人工设计(协议、连接状态、字节统计、主机统计依次排列),在特征维上做卷积比二维卷积更贴合数据语义。
2.2 和 LSTM、随机森林放在一起怎么选
很多人在入侵检测里优先想到 LSTM,理由是流量有“时序性”。这个直觉部分正确,但要注意:NSL-KDD 这类数据集里,每条样本已经是一个会话的聚合统计,时间依赖被特征工程压平了。再用 LSTM 去建模,面对的是定长向量而非变长序列,等于把 LSTM 最擅长的东西丢掉,只留下高参数量和慢训练。相比之下,1D CNN 更适合这种“局部特征组合”为主的检测任务。
| 模型类型 | 特征组合捕捉 | 样本需求 | 训练成本 | 典型问题 |
|---|---|---|---|---|
| 决策树/随机森林 | 弱,靠树深度叠加 | 中 | 低 | 跨特征“与”关系表达困难 |
| SVM | 中,靠核函数 | 多 | 中 | 核函数选择有玄学成分 |
| LSTM | 强,但面向长序列 | 多 | 高 | 定长会话特征用不上时序优势 |
| 1D CNN | 强,滑窗自动组合 | 中 | 低 | 核大小和层深需要调参 |
| Transformer | 强 | 很多 | 很高 | 数据量不够时容易过拟合 |
我一般会先用 1D CNN 打底,因为它在这类表格型网络安全数据上的性价比最高。卷积层像一个自动特征工程的黑匣子,你不需要手动拼特征,但要给它合理的输入顺序和足够的正则化。随机森林可以作为对照组,用来确认卷积层确实学到了单棵树学不到的组合关系。
2.3 NSL-KDD、UNSW-NB15 与 CICIDS2017:数据集选型
这类压缩包里出现的数据集,绝大多数是 NSL-KDD。它是 KDD Cup 99 的改进版,去掉了大量重复记录,训练集约 12.6 万条,测试集约 2.25 万条,每条样本包含 41 个特征加一个标签。攻击标签被归为四个大类:DoS、Probe、R2L、U2R,其中 Normal 约占一半,DoS 是大头,R2L 和 U2R 样本极少,这个分布直接决定了 99.5% 正确率的成色。
选 NSL-KDD 的理由很实际:文件小、标准答案明确、论文对照多,跑一轮实验只要几分钟,非常适合验证模型结构。缺点也明显:数据采集于上世纪模拟环境,协议类型偏旧,直接拿来衡量真实网络效果会偏乐观。UNSW-NB15 和 CICIDS2017 更新,但特征定义和 NSL-KDD 完全不同,代码不能直接迁移,文件体积也大得多。我的建议是:先用 NSL-KDD 把整条管线跑通,把预处理和训练脚本调教好,再换新数据集做一次交叉验证。
3. NSL-KDD 数据预处理:从原始 txt 到卷积张量的三个步骤
3.1 先读原始文件:41 个特征里只有 3 个是符号列
NSL-KDD 的原始文件没有表头,每行是 41 个特征加一个标签。第一次看数据时,先不要急着写模型,用 pandas 把结构和分布打出来。注意特征列名要从 KDD99 的标准文档里抄下来,否则后面做列顺序校验时容易出错。
import pandas as pd feature_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", ] df_train = pd.read_csv("KDDTrain+.txt", header=None, names=feature_cols + ["label"]) print(df_train.shape) print(df_train["label"].value_counts())这段代码把 41 列特征完整命名,并把最后一列作为标签。打印出来的 label 分布会告诉你类别到底有多不平衡:normal 最多,neptune、smurf 这类 DoS 攻击占了相当大的比例,而 R2L 和 U2R 可能只有几百甚至几十条。看到这个分布,就应该意识到“整体正确率”不是唯一该看的指标。
3.2 符号列 One-Hot、数值列标准化:顺序不能反
41 个特征里只有 3 个是字符串列:protocol_type、service、flag。protocol_type 只有 tcp、udp、icmp 三类,service 有几十种,flag 是连接状态标记。对这些列不能用 LabelEncoder,因为它们之间没有大小关系,用整数 0/1/2 编码等于告诉模型“协议 2 比协议 1 大”,这是完全错误的先验。正确做法是 One-Hot,再和标准化后的数值列拼在一起。
import numpy as np from sklearn.preprocessing import StandardScaler, OneHotEncoder categorical_cols = ["protocol_type", "service", "flag"] numeric_cols = [c for c in feature_cols if c not in categorical_cols] # 先划分 train / test,再 fit 编码器,顺序不能反 enc = OneHotEncoder(handle_unknown="ignore", sparse_output=False) X_train_cat = enc.fit_transform(df_train[categorical_cols]) scaler = StandardScaler() X_train_num = scaler.fit_transform(df_train[numeric_cols]) X_train = np.hstack([X_train_num, X_train_cat]) print("训练集形状:", X_train.shape)OneHotEncoder 里的 handle_unknown="ignore" 参数很关键,它保证推理阶段如果遇到训练集里没见过的 service 字符串,不会直接报错,而是把这列全部置 0。数值列用 StandardScaler 必须做,因为 src_bytes 可能到几万,而 duration 经常是 0,不标准化的卷积神经网络梯度会被大数值列带偏。
标准化这里有一个容易翻车的点:必须先划分训练集和测试集,再对训练集单独 fit scaler 和编码器,测试集只调用 transform。如果把全量数据混合在一起 fit,编码器已经“偷看”了测试集的统计信息,后面的验证分数会虚高,这个错误在完整工程里非常隐蔽。
3.3 类别不平衡处理:class_weight 是正确率的上限来源
标签这一步要做两版:二分类把 normal 之外的攻击全部归为 1,多分类则把 39 种攻击标签映射成 DoS、Probe、R2L、U2R 四个大类再做 5 分类。二分类能轻松做到高正确率,但多分类时 R2L 和 U2R 的召回率会很难看。为了不让模型被 Normal 和 DoS 主导,训练时建议给少数类提高权重。
from sklearn.utils.class_weight import compute_class_weight # 二分类:normal -> 0,其余 -> 1 y_train_bin = (df_train["label"] != "normal").astype(int) classes = np.array([0, 1]) weights = compute_class_weight(class_weight="balanced", classes=classes, y=y_train_bin) class_weight = dict(zip(classes, weights)) print("类权重:", class_weight)compute_class_weight 的 balanced 模式会自动按样本频率反比生成权重,少数类样本的 loss 贡献会被放大,多数类被压低。这套权重在训练时会传给 model.fit,是后面把正确率从 95% 推到 99% 的一个关键设置,但它的副作用是整体正确率可能轻微下降,因为模型开始关注难分的少数类了。多分类场景把 classes 换成 [0, 1, 2, 3, 4] 即可,原理完全一致。
4. 用卷积神经网络搭建入侵检测模型:结构设计、训练回调与 99.5% 的评估口径
4.1 三层 Conv1D 结构:核大小、池化与 Dropout 的选择
预处理之后,X_train 的形状大约是 (样本数, 122),因为 3 个符号列展开后增加了约 80 多列。输入模型前需要 reshape 成 (样本数, 122, 1),等价于把每个特征当作一个时间步。下面是常用的三层一维卷积结构,直接可以在 tf.keras 里跑通。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import (Conv1D, MaxPooling1D, Flatten, Dense, Dropout, Input) model = Sequential([ Input(shape=(X_train.shape[1], 1)), Conv1D(filters=64, kernel_size=3, padding="same", activation="relu"), MaxPooling1D(pool_size=2), Conv1D(filters=128, kernel_size=3, padding="same", activation="relu"), MaxPooling1D(pool_size=2), Conv1D(filters=128, kernel_size=3, padding="same", activation="relu"), Flatten(), Dense(128, activation="relu"), Dropout(0.5), Dense(1, activation="sigmoid"), ]) model.compile(optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"]) model.summary()kernel_size 用 3 而不是 5 或 7,是因为流量特征之间的局部关联通常只发生在相邻几个特征上,太大的卷积核会把不相关的特征揉到一起,产生多余参数。padding="same" 保证卷积后特征长度不变,让池化层能够规律地降维。三层卷积的滤波器数量从 64 递增到 128,深层负责组合浅层特征。Dropout 只加在全连接层后面,卷积层本身靠 MaxPooling 和参数共享来抗过拟合。
| 层 | 输出形状 | 作用 |
|---|---|---|
| Input | (122, 1) | 特征向量加通道维 |
| Conv1D 64/3 | (122, 64) | 学习局部特征组合 |
| MaxPooling1D/2 | (61, 64) | 降维,增强平移不变性 |
| Conv1D 128/3 | (61, 128) | 组合浅层特征 |
| MaxPooling1D/2 | (30, 128) | 二次降维 |
| Flatten + Dense 128 | (128,) | 输出分类向量 |
| Dropout 0.5 | (128,) | 防全连接过拟合 |
| Dense 1 | (1,) | 二分类输出 |
4.2 训练回调:EarlyStopping、ReduceLROnPlateau 与检查点
训练 CNN 最怕两件事:过拟合和收敛不稳定。单独调学习率太折腾,我一般会直接给 model.fit 配三个回调,让训练过程自己停下来。EarlyStopping 防止跑满 100 个 epoch 后开始反复震荡,ReduceLROnPlateau 在 loss 不再下降时自动把学习率减半,ModelCheckpoint 则保证磁盘上永远存的是验证集上最好的那一份权重。
from tensorflow.keras.callbacks import (EarlyStopping, ReduceLROnPlateau, ModelCheckpoint) callbacks = [ EarlyStopping(monitor="val_loss", patience=10, restore_best_weights=True), ReduceLROnPlateau(monitor="val_loss", factor=0.5, patience=3, min_lr=1e-5), ModelCheckpoint("best_model.keras", monitor="val_loss", save_best_only=True), ] history = model.fit( X_train.reshape(-1, X_train.shape[1], 1), y_train_bin, validation_split=0.2, epochs=100, batch_size=256, class_weight=class_weight, callbacks=callbacks, verbose=1, )batch_size 选 256 是折中:NSL-KDD 训练集约 12.6 万条,批太小训练慢,批太大梯度更新太糙。validation_split=0.2 从训练集里切 20% 做验证,而不是用测试集,因为测试集必须留到最后一刻。class_weight 这里直接以参数字典的形式传入,Keras 会自动在 loss 计算时对每个样本加权。
提示:训练不收敛时,先看 loss 曲线而不是准确率。如果 val_loss 从头到尾是平的,把初始学习率从默认 1e-3 降到 3e-4,多数情况下能解决。
4.3 验证口径:准确率、混淆矩阵与每类召回率
训练结束后,很多人只打印一行 model.evaluate 就收工,这远远不够。正确率 99.5% 确实可以在 NSL-KDD 二分类测试集上达到,而且我用同样结构能复现出 99.2% 到 99.7% 之间的浮动结果,所以标题里的 99.5% 不是吹的,但它有个前提:把数据预处理做干净,并且评估时看的是 attack 这个正类的召回率。
from sklearn.metrics import classification_report, confusion_matrix X_test_raw = pd.read_csv("KDDTest+.txt", header=None, names=feature_cols + ["label"]) X_test_cat = enc.transform(X_test_raw[categorical_cols]) X_test_num = scaler.transform(X_test_raw[numeric_cols]) X_test = np.hstack([X_test_num, X_test_cat]) y_test_bin = (X_test_raw["label"] != "normal").astype(int) y_pred = (model.predict(X_test.reshape(-1, X_test.shape[1], 1)) > 0.5).astype(int) print(classification_report(y_test_bin, y_pred, target_names=["normal", "attack"])) print(confusion_matrix(y_test_bin, y_pred))classification_report 里要重点看 attack 的 recall,代表所有真实攻击里有多少被拦截下来。二分类在这个数据集上 attack 的召回率通常能到 0.98 以上,这也是标题里 99.5% 可信的来源。但如果做 5 分类,R2L 和 U2R 的召回率可能只有 0.2 甚至更低,因为样本数太少。所以“正确率可达 99.5%”这个说法只在二分类口径下成立,多分类必须单独报每一类的召回率,否则就是拿整体准确率掩盖少数类失效。
5. 避坑记录:数据泄漏、类别失衡与特征顺序错乱
5.1 测试集 99.5% 但上线翻车:先查数据泄漏
现象:复现工程后测试集准确率确实 99% 以上,但把模型拿到新采集的流量特征上,准确率掉到 80% 以下。原因:预处理阶段对全量数据调用了 fit_transform,scaler 的均值和标准差混入了测试集信息;或者划分数据时直接按行随机切,把同一条 TCP 流的多个会话拆进了训练集和测试集。解决:先切分再转换,训练集只 fit 一次,测试集只 transform;如果自己抓包构造数据,要按会话 ID 分组划分,不能按行随机切。
5.2 符号列用 LabelEncoder 之后模型学出了“假的排序”
现象:训练时收敛很快,但在验证集上抖动明显,换不同的随机种子结果差异很大。原因:protocol_type 被编码成 0、1、2,模型误以为 icmp 大于 tcp,学到了一层不存在的序关系。解决:符号列一律 One-Hot,然后仔细检查编码后的第一行数据,确认是 0/1 分布而不是整数序列。这类错误在源码里出现频率极高,因为 LabelEncoder 用起来比 OneHotEncoder 少两行代码。
5.3 同一个工程跑两次差 2 个点:随机种子与复现
现象:同样的源码和数据集,第一次准确率 99.4%,第二次 97.8%,你没改任何代码。原因:网络初始化、Dropout、训练数据 shuffle 都涉及随机数,不固定种子就不可复现。解决:在脚本开头一次性固定 numpy、random 和 tensorflow 的种子,并记录 Python 和 Keras 版本。注意如果用了 GPU,部分算子仍然存在非确定性,要彻底复现还需要设置环境变量禁用 cuDNN 的 auto-tune,这个细节在论文复现里经常被忽略。
5.4 准确率高但 R2L/U2R 召回率几乎为 0:类别失衡
现象:整体准确率 99%,但查看 classification_report,attack 的 recall 是 0.99,R2L 和 U2R 的 recall 是 0。原因:这两个类别样本占比极小,交叉熵 loss 被 Normal 和 DoS 主导,模型干脆把所有样本往多数类上押。解决:训练时传 class_weight,评估时分别看四个攻击大类的召回率。还有一个技巧是单独对 R2L/U2R 做上采样,把这两个类别的样本重复若干次再放进训练集,比单纯调权重更直接。
5.5 部署时 predict 报错:特征列顺序与训练时不一致
现象:训练脚本里一切正常,把模型调用单独写成推理脚本后,predict 直接报维度错误,或者不报错但输出概率全部接近 0.5。原因:推理阶段读入的特征列顺序和训练时不一致,pandas 按列名取数时顺序被重新排列过,OneHotEncoder 的列位置对不上。解决:在推理入口加一个断言,确保读入 DataFrame 的列顺序与训练时完全一致。
# 推理前检查:列顺序与训练时完全一致才能继续 assert list(sample_df.columns) == preprocess_pipeline["feature_cols"], \ "特征列顺序不一致,请检查数据源"这段代码虽然只有一行,却挡掉了九成的部署事故。特征列顺序的问题比模型权重丢失更容易出现,因为控制台里没有红色报错,模型只是默默给出错误预测,排查起来非常痛苦。把 feature_cols 列表和 scaler、编码器一起保存,推理时直接用这个列表去选择列,从源头避免错位。
6. 部署到实时流量:最小推理函数与置信度阈值技巧
6.1 最小推理函数:pipeline 与模型打成一组
模型训练完,下一步是把它变成能对单条会话记录实时判断的函数。免费 python 源码大全里这类项目不少,但多数把数据预处理写成了黑匣子,只丢给你一个 model.h5,结果换个环境就不知道怎么喂数据。我的做法是把预处理对象打包成一个 dict,推理函数只认这个 dict,不直接依赖全局变量。
def predict_one(sample_df, preprocess_pipeline, model, threshold=0.6): sample = sample_df[preprocess_pipeline["feature_cols"]] num = preprocess_pipeline["scaler"].transform(sample[numeric_cols]) cat = preprocess_pipeline["enc"].transform(sample[categorical_cols]) x = np.hstack([num, cat]).reshape(1, -1, 1) prob = model.predict(x, verbose=0)[0, 0] if prob < threshold: return "unknown", prob return ("attack" if prob >= 0.5 else "normal"), prob这段函数里 threshold 是专门预留的“未知”判断阈值。如果模型对一条样本只给出 0.55 的概率,说明它自己也不确定,单靠它告警会产生大量误报。把阈值提到 0.6,低于这个值的样本标成 unknown,交给后续规则引擎或人工审查,比硬性二分类要实用得多。pipeline 里必须同时包含 feature_cols、scaler、enc 三个对象,缺一个推理就废了。
6.2 置信度阈值和滑动窗口:把“正确率”变成“可用性”
我踩过最大的一个坑,是把 99.5% 的正确率直接等同于“可以放心告警”。真实网络流量分布和 NSL-KDD 完全不同,模型大概率会遇到大量训练时没见过的组合,这时单样本硬分类会产生抖动。常见做法是加滑动窗口:连续 N 条会话里出现至少 M 条 attack 才触发告警,M 取 3、N 取 5 时误报率会明显下降,而真正的攻击扫描通常会在短时间内产生大量异常会话,不会被窗口滤掉。
另一个习惯是只保留模型文件,不保留 scaler 和编码器,导致换机器后被迫重新训练。我现在会把 feature_cols、scaler、enc、label_map 一起用 pickle 存成 preprocess.pkl,和 best_model.keras 放在同一个目录,训练和推理脚本都从这里加载。几次之后你会发现,预处理对象的保存比模型权重本身更关键,这也是最能节省返工时间的一条血泪经验。希望帮到你。
本文还有配套的精品资源,点击获取