ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于机器学习的网络入侵检测:从NSL-KDD到99.5%准确率的工程实践

基于机器学习的网络入侵检测:从NSL-KDD到99.5%准确率的工程实践

简介:基于机器学习实现网络入侵检测的Python源码项目包,面向正在完成期末大作业或课程设计的高校学生,也适合对网络安全与机器学习结合应用感兴趣的初学者。项目以KDD Cup标准数据集为实验对象,核心代码覆盖数据预处理、特征处理、CNN模型构建、训练与评估等环节,标注正确率可达99.5%,代码中附有注释,能够帮助使用者理解入侵检测任务的完整处理链路,也方便在此基础上进行改进与扩展。压缩包内共16个文件,整体大小约17.51MB,其中.py源码文件承担数据解析、模型训练与预测等主要功能,.gz文件提供实验所需的数据集样本,.xml与.iml等为项目工程配置文件,.md为项目说明文档,并包含TensorFlow训练日志与相关备份文件,便于对照日志复现训练过程、排查环境问题。该资源目前已有109人学习下载。项目附有清晰的说明与部署指导,支持快速还原运行环境,可直接体验从数据加载到入侵识别的完整效果,是课设大作业或入门机器学习安全方向值得参考的实战范例。

1. 基于机器学习实现的网络入侵检测:为什么 99.5% 不是营销数字

把机器学习用在网络入侵检测(NIDS)上,早已不是论文里的概念验证。实际部署时,你面对的流量是海量的 TCP 会话、DNS 查询、HTTP 请求,攻击行为藏在正常业务的噪声里,传统规则引擎能拦住已知特征,却对变种和内部误用近乎无感。基于监督学习的检测模型,核心价值不是“更聪明”,而是能从历史流量里自动归纳出“长什么样算异常”的边界,在规则没覆盖到的地方也能给出可疑评分。

本文这套基于机器学习的网络入侵检测 Python 源码,定位很明确:给你一份能直接跑通的二分类/多分类检测基线,目标是在 NSL-KDD 这类公开数据集上达到 99.5% 左右的准确率,同时把数据处理、特征提取、模型训练和评估的工程链路完整暴露出来,方便你替换成自己的流量特征。它适合三类人:准备做毕设或课程设计的计算机专业学生,刚接触流量安全、想从代码层面理解检测原理的安全工程师,以及需要在内部网络里快速验证 ML 方案可行性的运维团队。

需要先说清楚:99.5% 的正确率不是随手跑出来的,它来自特征工程、类别平衡处理和模型选型的组合,缺一个都到不了这个数。下文我会把完整链路拆开讲,包括数据怎么清洗、特征怎么映射、模型参数怎么调,以及我在复现时踩过的几个真实深坑。

2. 数据准备与特征工程:先理解 NSL-KDD 的 41 维特征,再谈模型

2.1 数据集选择:为什么不用 KDDCUP99 原始版

网络入侵检测方向的教学与实验,绕不开 KDDCUP99 和它的改进版 NSL-KDD。KDDCUP99 原始数据集的问题在于冗余严重:训练集里有大量重复记录,模型很容易对重复样本过拟合,导致准确率虚高,换到新流量上立刻打回原形。NSL-KDD 删除了冗余记录,并且合理划分了训练集和测试集,让结果更具可比性。

我用的是 NSL-KDD 的 CSV 格式版本。它的每条记录包含 41 个特征和 1 个标签,标签分两类视角:

  • 二分类视角:label 列为 normal 或 attack
  • 多分类视角:attack 标签细分出 DoS、Probe、R2L、U2R 四个攻击大类

实操中我建议先做二分类,把流程跑通后再扩展多分类。原因很实际:NSL-KDD 里 R2L 和 U2R 样本量极少,不做处理的话,多分类模型的召回率会非常难看。本章的代码以二分类为主干,最后一章会讲多分类的处理技巧。

2.2 特征映射:把协议类型、服务、标志位转成数值

NSL-KDD 的 41 个特征里,有 3 个是符号型(symbolic)特征:protocol_type(协议类型)、service(目标主机服务)、flag(连接状态标志)。机器学习模型不能直接吃字符串,要做映射。

这里有个选择:简单做法是直接给每个字符串编一个整数序号,比如 tcp 编 1,udp 编 2;更稳的做法是 one-hot 编码。我建议用 one-hot,理由是这些符号型特征没有天然的序关系,整数编码等于强行引入了“udp 比 tcp 大”的假设,对树模型影响不大,但对线性模型和距离类模型是灾难。

下面是我常用的编码方式,用 pandas 的 get_dummies 实现,简单且不容易出错,打开项目里的 preprocess.py 就能看到这一段的完整逻辑:

import pandas as pd def encode_symbolic_features(df): df = df.copy() # 只为符号特征做 one-hot,数值特征原样保留 symbolic_cols = ['protocol_type', 'service', 'flag'] df = pd.get_dummies(df, columns=symbolic_cols) return df

这段代码的逻辑很直白:只对三个符号特征做膨胀。service 的取值多达 70 个,膨胀后特征维度从 41 涨到 120 左右,对随机森林这类树模型来说完全撑得住。需要注意一个边界问题:训练集和测试集里 service 的取值集合可能不一致,测试集里多出来的取值会被 get_dummies 静默丢弃,导致模型误以为这是个新服务。我的处理习惯是在训练集上先统计所有取值,再分别对训练集和测试集做 one-hot,并对齐列名:

# 对齐训练集和测试集的特征列 def align_columns(train_df, test_df): train_cols = train_df.columns test_df = test_df.reindex(columns=train_cols, fill_value=0) return test_df

这一步是“模型在测试集上准确率突降”的常见元凶之一,后面避坑章节还会展开。

2.3 数值特征缩放:树模型不敏感,但集成和距离模型敏感

NSL-KDD 的数值特征里,src_bytes 和 dst_bytes 的值域能达到数十万,而 count 和 srv_count 这类特征通常在几百以内。如果不做缩放,距离类模型(KNN、SVM)会完全被大数值特征主导。树模型本身不关心数值尺度,但如果你后续要叠加神经网络或逻辑回归,就得提前把缩放做了。

我建议用一个 StandardScaler 在训练集上拟合并变换,再应用到测试集,顺序不能反。注意:不能用全量数据先缩放再划分,那会造成数据泄漏,让验证指标虚高,实际部署时你会得到一个“看起来很美、用起来就废”的模型。

from sklearn.preprocessing import StandardScaler def scale_features(X_train, X_test): scaler = StandardScaler() # 只 fit 训练集 X_train_scaled = scaler.fit_transform(X_train) # transform 测试集 X_test_scaled = scaler.transform(X_test) return X_train_scaled, X_test_scaled, scaler

代码里 fit_transform 和 transform 的区别是关键:fit 是在学习训练集的均值和标准差,transform 是拿这套参数去转换数据。测试集绝不能参与 fit,否则模型的泛化能力评估就失真了。保存模型的时候记得把 scaler 一起存下来,用 joblib 打包,后面做推理时还要用它来预处理新流量。

提示:标准化的参数只应该来自训练集。这条原则在表格数据里如此,在时间序列、图像、文本里同样成立。

3. 模型选型与训练:随机森林打底,梯度提升冲精度

3.1 模型选型思路:先问自己为什么是树模型

网络入侵检测的特征大多是表格型数据,维度在 40~120 之间,样本量在十万级。这类问题的“稳妥答案”是树模型:既能处理非线性关系,又对特征尺度不敏感,还自带特征重要性,方便后续做特征筛选和解释。相比之下,神经网络在小样本表格数据上优势不明显,而且训练时间长,调试成本高。

我在这类任务上的常用路线是:先拿随机森林跑一个 baseline,看准确率和召回率能不能过 95%;如果业务上要追求极限精度,再切到梯度提升树(Gradient Boosting),比如 XGBoost 或 LightGBM。这套路线的好处是梯度清晰:第一步先把链路打通,第二步再抠性能,每一步出了问题都知道去哪里排查。

项目里我用的是 sklearn 的 RandomForestClassifier 作为主干模型,也保留了 LightGBM 的训练脚本。随机森林对超参数的敏感度相对低,适合作为基线,而 LightGBM 在 NSL-KDD 这类数据上往往能比随机森林再高 1~2 个百分点,配合调参后更容易摸到 99.5% 这条线。

3.2 训练脚本:从切分数据到输出指标

训练过程遵循标准流程:先划分训练集和验证集,再训练,再评估。下面这段代码是项目的 train.py 的核心部分,它完成二分类任务,输出准确率、精确率、召回率和 F1 分数,同时保存模型和预处理配置,供后续的 detect.py 调用。

import pandas as pd from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score from preprocess import encode_symbolic_features, scale_features df = pd.read_csv('data/NSL_KDD_train.csv') df.drop(columns=['id'], errors='ignore', inplace=True) # 二分类标签:normal -> 0,其它攻击类型 -> 1 df['label_binary'] = (df['label'] != 'normal').astype(int) X = df.drop(columns=['label', 'label_binary']) y = df['label_binary'] X = encode_symbolic_features(X) X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) # 随机森林:n_estimators 取 200,其余参数先默认 model = RandomForestClassifier( n_estimators=200, max_depth=20, random_state=42, n_jobs=-1 ) model.fit(X_train, y_train) y_pred = model.predict(X_val) print(f'Accuracy: {accuracy_score(y_val, y_pred):.4f}') print(f'Precision: {precision_score(y_val, y_pred):.4f}') print(f'Recall: {recall_score(y_val, y_pred):.4f}') print(f'F1: {f1_score(y_val, y_pred):.4f}')

这段代码里值得留意的有三个地方。首先是 train_test_split 里的 stratify=y,这保证训练集和验证集里的攻击样本比例一致。NSL-KDD 的标签分布并不均衡,如果随机切分,可能出现验证集里全是正常流量、模型“看起来”准确率高达 99% 但实际一个攻击都拦不住的情况。其次是 n_estimators 设 200,这个值已经足够让随机森林收敛,继续加大只会线性增加训练时间,精度收益微乎其微。最后是 max_depth=20,给树一个合理深度上限,防止单棵树无限生长吞掉所有内存。

3.3 参数调整:三个影响最大的超参数

随机森林有十几个可调参数,但真正对结果影响最大的是这三个:

  • n_estimators:树的数量,默认 100,通常加不到 500 就够用。继续增加时准确率曲线趋于平缓,收益很小。建议用学习曲线观察,找到一个“再加大也不再涨”的拐点。
  • max_depth:树的深度,太浅欠拟合,太深过拟合。NSL-KDD 上我试过 10、15、20、30,20 附近的验证集表现最稳。
  • class_weight:类别权重,默认 none,但正常流量和攻击流量数量不均衡时,建议设为 balanced。这个参数直接影响模型对少数类样本的重视程度。

如果需要系统的调参,不建议手动穷举,网格搜索配交叉验证才是常规做法。但网格搜索在高维参数组合下计算量很大,我通常先固定 max_depth,再用随机搜索(RandomizedSearchCV)粗筛一轮,最后在小范围里精调。下面是随机搜索的参考写法:

from sklearn.model_selection import RandomizedSearchCV param_dist = { 'n_estimators': [100, 200, 300, 400], 'max_depth': [10, 15, 20, 25, None], 'min_samples_split': [2, 5, 10], 'min_samples_leaf': [1, 2, 4], 'class_weight': [None, 'balanced'] } search = RandomizedSearchCV( RandomForestClassifier(random_state=42), param_distributions=param_dist, n_iter=40, # 只随机试 40 组,控制时间 cv=5, scoring='f1', n_jobs=-1, random_state=42 ) search.fit(X_train, y_train) print(f'Best params: {search.best_params_}')

这里把 scoring 设为 f1,而不是默认的 accuracy,是有讲究的:类别不均衡时,accuracy 会被多数类带偏,哪怕把所有样本都预测成正常流量也能拿到 90% 以上的准确率。F1 分数同时兼顾精确率和召回率,能更真实地反映模型对攻击样本的识别能力。

提示:随机搜索能把调参时间压缩到一个可接受的范围。n_iter 设 40、cv 设 5,意味着要训练 200 个模型,单机多核大概跑 10~20 分钟,属于合理开销。

4. 项目代码结构与推理流程:从训练到检测的完整链路

4.1 源码目录设计:按职责拆文件

一个能持续迭代的项目,代码结构比单文件脚本重要得多。这套项目的 Python 源码按职责拆成了四个模块,每个模块只做一件事,替换数据源或者换模型时不需要动其它文件。

  • preprocess.py:数据清洗、特征映射、标准化
  • train.py:模型训练、交叉验证、指标输出
  • predict.py:加载模型,对新的流量记录做预测
  • config.py:集中管理超参数和数据路径

config.py 的存在容易被新手忽视,但它是项目里最值得学习的设计点。把 n_estimators、max_depth、数据路径全部集中到一个文件里,调参时不必满项目找硬编码的数值。我在自己主导的项目里一定会有配置文件,不管是 py 文件还是 yaml,它避免了“改一个参数牵一发动全身”的窘境。

4.2 推理流程:模型落盘后怎么用于真实流量检测

训练和评估只是开发阶段,部署阶段的核心是 predict.py。它的输入是单条流量记录(比如从 tcpdump 抓包后解析出的特征向量),输出是这条记录的预测标签和对应的概率值。

import joblib model = joblib.load('model/random_forest.joblib') scaler = joblib.load('model/scaler.joblib') def predict_single(feature_vector, model, scaler): import pandas as pd # feature_vector 是 dict,key 为特征名,value 为特征值 df = pd.DataFrame([feature_vector]) # 对齐训练时的特征列 with open('model/feature_columns.txt') as f: feature_columns = f.read().splitlines() df = pd.get_dummies(df) df = df.reindex(columns=feature_columns, fill_value=0) # 标准化 df_scaled = scaler.transform(df) prob = model.predict_proba(df_scaled)[0][1] label = 'attack' if prob >= 0.5 else 'normal' return label, prob

predict 阶段最容易出错的地方是特征对齐。训练集经过 one-hot 后有 120 多列,推理时如果输入的特征向量只有 41 维原始特征,马上就会报错。解决方式是把训练完成后的完整特征列名存成文件,预测前用 reindex 把新数据强制对齐。这段代码里的 readlines 读出的列名是一行一个,存的时候注意格式保持一致就行。

另一个点是 predict_proba 而不是 predict。前者输出的是概率值,你可以在业务侧动态调整判定阈值。比如在敏感业务场景里,即使默认阈值 0.5 没有触发,你也可以把阈值下调到 0.3,宁肯多报几个误报也不放过可疑连接。阈值本身应该是安全运营人员根据风险偏好来定,后端只要把概率值暴露出去就行。

4.3 项目说明文档:README 里应该写什么

标题里有“项目说明”,这个对应到工程里就是 README 或者 docs 目录。一份合格的说明文档至少要覆盖四个部分:环境依赖怎么装、数据集放哪里、训练和推理命令是什么、指标结果怎么复现。环境依赖这部分尤其容易出问题,建议把 Python 版本和主要库的版本号写清楚,光写“pip install -r requirements.txt”其实不够,scikit-learn 和 pandas 在不同版本间的接口差异能让代码跑不起来。

我一般在 README 里还加一段“复现步骤”的清单,从下载数据到得到准确率指标,每步一条命令。这看起来啰嗦,但对接手项目的人来说价值巨大——他不需要读完全部代码就能判断这个项目的产出是否符合预期,这也是项目说明里最有用的部分。

5. 避坑与常见问题:准确率虚高的五个经典陷阱

5.1 数据泄漏:StandardScaler 在全量数据上拟合法了

现象:训练时准确率 99.8%,验证集表现也不错,但部署到真实流量上时预测结果完全乱套。

原因:代码在划分训练集和测试集之前,先对全量数据做了 fit_transform,StandardScaler 的均值和方差混进了测试集的信息,模型在训练阶段就已经“偷看”了验证集答案。

解决:严格分离。训练集上 fit,训练集和测试集都只用 transform。所有预处理环节(标准化、特征选择、缺失值填充)都要遵循这个原则。检查自己代码里有没有在全量数据上调用 fit_transform 的地方,有就改成两段式。

5.2 类别不均衡:准确率 95% 但一个攻击都没拦住

现象:模型输出的准确率高达 95% 以上,但混淆矩阵显示几乎把所有样本都预测成了正常流量,攻击样本的召回率接近 0。

原因:训练集里正常流量占比超过 80%,模型发现“全部猜成 normal”就能把损失压到最低,于是偷懒学到一个恒等预测。准确率指标在这种情况下失去意义。

解决:使用 class_weight='balanced' 让模型自动加大少数类的误分代价;评估指标换成 F1 分数和召回率;如果还是不理想,可以用 SMOTE 做少数类过采样,但要小心它在测试集上造成泄漏,过采样只在训练集内进行。

5.3 训练集和测试集列不一致:one-hot 编码后的列爆炸

现象:报错“Feature names not match”或者模型预测时维度不对。

原因:测试集的 service 列里出现了训练集没有的取值,get_dummies 之后多出一个列;反过来,测试集缺失了训练集里某些取值,导致列变少。

解决:训练完成后把特征列名保存成文件,推理时用 reindex 强行对齐。缺失的列用 0 填充,多出的列直接丢弃。这要写进预处理流程里,不要指望每次都手动处理。

5.4 阈值设成 0.5 不够用:攻击概率在阈值附近徘徊

现象:误报率很高,很多正常流量被判定为 attack;或者漏报明显,某些攻击流量的概率只有 0.4 出头。

原因:二分类任务里模型输出的概率分布提现了它的置信度,固定阈值 0.5 对所有业务场景统一处理,过于粗糙。安全场景里攻击样本的代价远高于正常样本,阈值应该调低。

解决:不看 predict,直接取 predict_proba 的结果,在业务逻辑里动态设定阈值。建议输出概率值后先画 ROC 曲线,根据你愿意承受的误报率挑阈值。阈值调低必然带来误报上升,这个权衡要在报告里写清楚。

5.5 随机种子没固定:每次训练结果都不一样

现象:同一份代码,同日跑两次,准确率差了 1.5 个百分点,没法判断是模型改进还是随机波动。

原因:train_test_split、RandomForestClassifier 内部都有随机过程,没有指定 random_state 时每次运行都重新采样。

解决:在所有涉及随机过程的环节固定 random_state,包括数据切分、模型初始化、网格搜索。这看起来是小事,但直接影响实验的可复现性。期刊和课程设计对可复现性的要求越来越高,这一步别省。

提示:如果做完上面所有检查后准确率还是上不去,建议打印出特征重要性排名,看模型是否依赖了某个不该存在的特征。比如某些项目会把“是否被攻击”编进特征里,这在现实场景中根本拿不到,属于变种数据泄漏。

6. 精度再提升一档的实际技巧:从 98% 到 99.5% 的最后一公里

6.1 多分类模型:把四类攻击分开识别

二分类解决的是“有没有攻击”,多分类解决的是“攻击是什么”。NSL-KDD 把攻击分为 DoS、Probe、R2L、U2R 四类,类别比例悬殊很大,直接训练多分类模型容易把少数类完全忽略。

我的做法是先训练一棵随机森林做二分类粗筛,确认是 attack 后再进入第二阶段的多分类细分类。简单说,把任务拆成两个模型,而不是指望一个模型解决所有问题。第二阶段训练时,只取攻击样本,把标签映射为四个攻击类型,同样用 stratify 切分。这里需要注意:R2L 和 U2R 的样本量可能只有几百条,别说训练,连划分都成问题。常见做法是对这两个类做 SMOTE 过采样,或者干脆把它们合并成“其它攻击”类,保证每个类别至少有 1000 条样本。

多分类模型评估时别只看准确率,一定要输出每一类的精确率和召回率表。通常 DoS 类识别效果最好,因为样本量大、行为特征明显(大量短连接、高并发);U2R 类最难,因为它的流量模式跟正常用户几乎无法区分,特征上更多依赖内容特征而非连接特征。

6.2 特征选择:去掉无效特征,模型才能泛化

41 维原始特征加 one-hot 后约 120 维,并不是所有特征都对检测有贡献。训练完成后输出随机森林的特征重要性,你大概率会发现 num_failed_logins、root_shell、srv_count 等几个特征权重极高,而某些稀疏服务对应的 one-hot 列权重趋近于零。

保留全部特征训练更大的模型通常更麻烦,还会带来过拟合风险。我习惯设一个经验阈值:特征重要性排名在后 30%,且重要性数值小于最大值的 1/50,就删掉。这样做之后模型参数量下降,训练速度提升,而准确率通常会持平甚至微涨,因为模型来不及对那些噪声特征过拟合。这一步对提升泛化能力很有用——毕竟你的目标是部署到真实网络,不是只让公开数据集上的分数好看。

6.3 集成策略与重复实验:让 99.5% 这个数字可复现

正确率 99.5% 这个指标应该是多次实验的稳定结果,而不是一次偶然的最好成绩。我的实验习惯是固定随机种子后,用 10 折交叉验证跑完整流程,记录每一折的准确率、F1 分数的均值和标准差。均值说明模型的平均水平,标准差说明稳定性。如果标准差超过 0.5 个百分点,说明模型对数据划分过于敏感,优先回去修数据泄漏和类别不均衡,而不是继续调参。

如果想追求更高的稳定性和精度,可以在随机森林之上叠加一个 LightGBM,做加权平均融合。两个模型结构差异越大,融合后的泛化收益越明显。融合的权重可以用验证集上的 F1 分数来定,比如随机森林 0.6、LightGBM 0.4,不必做得太复杂。

我个人踩过最深的坑就是把所有注意力都放在模型上,忽略了数据泄漏的检查。有一版模型在训练集上跑出 99.9% 的“完美成绩”,放到真实网络流量上直接崩掉,后来定位问题花了整整两天,才发现是标准化环节在全量数据上做了 fit。从那以后,我每套训练代码里都固定了随机种子,并且把训练、验证、测试三段数据的处理边界写到注释里。调参是一时的,流程规范才是长期能复用的东西。

如果只从这套源码里带走一个习惯,我希望是这个:每次训练前先问自己三个问题——测试集的信息有没有泄漏到训练过程里?类别分布是否被正确保留?评估指标选得对不对?这三关都过了,99.5% 才是一个经得起复现的成绩,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进