多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

NSL-KDD入侵检测实战:从数据预处理到XGBoost建模全流程

NSL-KDD入侵检测实战:从数据预处理到XGBoost建模全流程 简介一份基于NSL-KDD数据集构建入侵检测模型的Python完整项目面向计算机相关专业需要完成课程设计、期末大作业或毕业设计的学生也适合想通过实战学习机器学习在网络安全领域应用的学习者。项目包含数据预处理、特征降维、模型训练与评估等完整流程覆盖PCA与无PCA两种建模方式并配有详细文档说明便于理解每一步思路。压缩包共27个文件约30MB以csv数据文件、ipynb交互式笔记本、txt说明、m模型脚本及docx文档为主。csv提供原始数据与预处理后数据ipynb分步骤展示数据处理和建模过程m和py文件保存可运行的模型与脚本文档则对设计方法、实验结果进行说明目录结构清晰便于按模块查阅。已有79人学习下载。代码经导师指导并获高分评价确保可运行适合零基础快速上手。读者可获得完整源码、数据集、模型文件与文档说明能够直接复现实验也可在此基础上扩展改进是完成相关课程项目的高质量参考资料。1. NSL-KDD不是老掉牙数据集入侵检测模型的第一块试金石NSL-KDD 是 KDD Cup 1999 数据集的改进版本去掉了大量重复记录把训练集和测试集的比例从悬殊调到了合理范围是目前做入侵检测模型最稳妥的入门数据集。标题里这个项目本质上是让你走通一条“加载数据 - 特征工程 - 训练模型 - 评估效果”的最小闭环真正的产出是一份能复现的 Python 源码和一篇讲清楚参数含义的文档。我见过太多人卡在下载数据后不知道从哪行代码写起最后对着 41 维特征发呆。这篇笔记就按我实际做过的方式把这个链路拆开讲适合正在做课设、毕设或者准备转行网络安全方向的 Python 开发者直接参照。2. 理解 NSL-KDD41 维特征、标签分布与数据预处理策略2.1 41 维特征到底在说什么从 TCP 连接属性到内容属性NSL-KDD 的每一条样本是一次网络连接记录的抽象41 个特征可以分成四组。第一组是 TCP 连接基本属性比如 duration连接时长、protocol_type协议类型TCP/UDP/ICMP、service目的端口对应的服务如 http、ftp、flag连接状态标志正常或错误标记。这组特征直接描述“这条连接长什么样”。第二组是内容特征包括 logged_in、num_failed_logins、root_shell 等这些是从连接载荷里提取的业务属性主要为了捕捉当年 KDD 数据里那些藏在正常流量里的攻击行为。第三、四组是流量特征统计过去 2 秒或 100 条连接里相同主机/相同服务的次数比如 count、srv_count、same_srv_rate、dst_host_count 等这一类特征对识别 DoS 和 Probe 类攻击特别有效。新手最容易犯的错是把 41 个特征全部当数值处理。protocol_type、service、flag 这三个是字符型必须转成数值具体怎么转下面会说。理解这四组特征的分布最大的价值是后续做特征筛选时能快速定位哪些列可以砍掉哪些列是模型真正依赖的。2.2 标签体系与训练集测试集划分KDDTrain 与 KDDTest 的差异NSL-KDD 的标准文件有三个KDDTrain、KDDTest、KDDTest-21。训练集包含 21 种攻击类型测试集包含 37 种其中多出来的 16 种是训练时没见过的攻击变体。这意味着你在训练集上做得再好测试集分数也可能明显跳水这是这个数据集本身设计出来的考察点不是 bug。标签有两种用法。第一种是二分类把正常流量标记为 normal其余所有攻击类型合并成 attack这是大多数入门项目采用的方式。第二种是五分类在 normal 之外把攻击归为 DoS、Probe、R2L、U2R 四大类。如果你想提高难度也可以做细粒度的多分类但 R2L 和 U2R 类别样本极少效果通常不理想。我一般建议先做二分类跑通流程再切换五分类观察召回率变化这能帮你理解不同攻击类型的检测难度差异。2.3 数值化、归一化与类别编码建模前必须完成的三个动作拿到 CSV 之后有三件事必须在喂给模型前做完字符特征转数值、特征缩放、标签统一。不做的后果很直接——sklearn 直接报错或者模型训练完精度看起来很高但召回率惨不忍睹。以下是我常用的一组预处理代码兼容 pandas 2.x 和 sklearn 1.ximport pandas as pd import numpy as np from sklearn.preprocessing import LabelEncoder, StandardScaler # 列名映射NSL-KDD 的 CSV 没有表头需要手动指定 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, label ] train_df pd.read_csv(KDDTrain.txt, headerNone, namescols) test_df pd.read_csv(KDDTest.txt, headerNone, namescols) # 标签统一二分类normal 为 0其他攻击为 1 train_df[label_binary] (train_df[label] ! normal).astype(int) test_df[label_binary] (test_df[label] ! normal).astype(int)这段代码里有一个常见的坑NSL-KDD 原始文件是 .txt 并且没有表头直接 pd.read_csv 会把第一行当列名。所以我先把 41 个特征名手动写好再通过 namescols 传入。加载以后先做二分类标签这样后面建模不用反复改标签列。字符特征的处理我推荐用 LabelEncoder 而不是 pd.get_dummies原因是测试集里可能出现训练集没见过的 service 值独热编码会导致列数不一致而 LabelEncoder 配合异常值兜底更稳# 对字符列做标签编码测试集出现新值就映射到 -1 char_cols [protocol_type, service, flag] for col in char_cols: le LabelEncoder() # 先 fit 训练集 le.fit(train_df[col]) # 测试集 transform 时兜底未知类别映射为 -1 test_df[col] test_df[col].map( lambda x: le.transform([x])[0] if x in le.classes_ else -1 ) train_df[col] le.transform(train_df[col])这里我故意没有用 sklearn 的 ColumnTransformer因为 NSL-KDD 的坑在于类别分布不均直接用 transform 会抛出 “unknown class” 异常。map 加 if 的判断在能跑的基础上把异常兜住了。LabelEncoder 把字符型转成了整数编号但这种编码没有距离含义tree-based 模型不受影响如果你换用神经网络建议改成 One-Hot 或 Embedding。最后做特征缩放feature_cols [c for c in cols if c ! label] X_train train_df[feature_cols].values.astype(np.float32) X_test test_df[feature_cols].values.astype(np.float32) scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test) y_train train_df[label_binary].values y_test test_df[label_binary].values注意 fit_transform 和 transform 的位置——scaler 只能用训练集拟合测试集只调用 transform这是后续避坑章节里会细说的经典问题。NSL-KDD 特征的量纲差异非常大duration 可能是 0 到几千src_bytes 可能到几百万不归一化的话线性模型和距离类模型会直接被大数值特征主导树模型影响较小但标准化也不会造成损失。3. 用 Python 构建入侵检测模型从随机森林到 XGBoost 的完整流程3.1 读取和清洗 NSL-KDD 数据Pandas 处理流程继续沿用上面预处理的结果。这里我再补充一个数据质量检查的环节。NSL-KDD 本身比较干净但你从网上找的 CSV 版本可能已经被人改动过常见的异常包括label 列被换成了 attack 大类、行尾多了一列、某些行缺少字段。# 检查行数、缺失、重复 print(train_df.shape, test_df.shape) print(train_df.isnull().sum().sum(), test_df.isnull().sum().sum()) # 去掉完全重复的行避免模型记住训练集中的个别样本 train_df train_df.drop_duplicates() test_df test_df.drop_duplicates() # 检查攻击类别 print(train_df[label].value_counts()) print(test_df[label].value_counts())打印出来的类别分布是你判断数据版本是否正确的依据。标准 KDDTrain 里 DoS 类样本占比最高R2L 和 U2R 非常稀少如果 value_counts 结果里 U2R 出现几百条那这个文件很可能被重新采样过评估结论会失真。3.2 特征工程把字符型协议类型转成可训练向量上一章的 LabelEncoder 是其中一种做法。这一节再从特征工程的角度多说明一下为什么不是 all in One-Hot。对于 tree-based 模型LabelEncoder 完胜独热编码因为决策树做切分时整数编码和独热编码都能切但独热会制造大量稀疏列训练耗时增加树模型感受不到多少收益。对于逻辑回归、SVM或者你想用 MLP那就应该用 One-Hot 或 Embedding。如果要做五分类特征工程里还有一个关键动作把原始 label 列映射成五大类。这里给出映射字典attack 大类是公开数据集的固定规则attack_map { normal: normal, neptune: dos, back: dos, teardrop: dos, pod: dos, smurf: dos, land: dos, apache2: dos, mailbomb: dos, processtable: dos, udpstorm: dos, satan: probe, ipsweep: probe, nmap: probe, portsweep: probe, mscan: probe, saint: probe, guess_passwd: r2l, warezclient: r2l, warezmaster: r2l, imap: r2l, ftp_write: r2l, multihop: r2l, phf: r2l, spy: r2l, xlock: r2l, snmpguess: r2l, snmpgetattack: r2l, httptunnel: r2l, named: r2l, sendmail: r2l, buffer_overflow: u2r, loadmodule: u2r, perl: u2r, rootkit: u2r, xterm: u2r, ps: u2r, sqlattack: u2r }直接把这个字典 apply 到训练集和测试集上就能得到五分类标签。映射规则里有个细节像 warezclient 属于 R2L但它早期文献里也被归到过 DoS不同论文口径略有差别你写文档时要把自己的映射规则固定下来否则复现的人会跑出和你不一样的结果。3.3 训练随机森林基线模型与 XGBoost 增强模型基线模型我建议从随机森林开始它不需要太多调参就能得到一个可用的分数。XGBoost 作为对比通常能比随机森林高 1 到 2 个百分点的准确率特别是对未知攻击的召回率有改善。下面是两个模型的训练代码from sklearn.ensemble import RandomForestClassifier from xgboost import XGBClassifier from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score # 随机森林基线 rf_model RandomForestClassifier( n_estimators200, max_depth15, min_samples_leaf2, random_state42, n_jobs-1 ) rf_model.fit(X_train_scaled, y_train) # XGBoost 模型 xgb_model XGBClassifier( n_estimators150, max_depth6, learning_rate0.1, subsample0.8, colsample_bytree0.8, eval_metriclogloss, random_state42, n_jobs-1 ) xgb_model.fit(X_train_scaled, y_train) # 统一评估 for name, model in [(RandomForest, rf_model), (XGBoost, xgb_model)]: y_pred model.predict(X_test_scaled) print(f{name} Accuracy: {accuracy_score(y_test, y_pred):.4f}) print(f{name} Recall: {recall_score(y_test, y_pred):.4f}) print(f{name} Precision: {precision_score(y_test, y_pred):.4f}) print(f{name} F1: {f1_score(y_test, y_pred):.4f})RandomForest 的 max_depth 我设成 15因为 NSL-KDD 特征数量不多深度太大容易过拟合到训练集里的重复模式上。min_samples_leaf2 是为了让树叶不要长太细。XGBoost 的 subsample 和 colsample_bytree 都是 0.8这能提高泛化能力特别是面对测试集里那 16 种未见过的攻击变体时随机性反而帮助模型不从训练集的噪声里学到太死板的规则。3.4 模型评估准确率、召回率、F1 与混淆矩阵准确率在入侵检测场景里不够用。因为 NSL-KDD 测试集里正常样本和攻击样本的比例大约是 1 比 1准确率高不代表攻击都被找到了。你需要重点看召回率——模型实际识别出的攻击占所有攻击的比例以及 F1 分数它是精确率和召回率的调和平均。混淆矩阵能告诉你模型到底错在哪from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay import matplotlib.pyplot as plt y_pred_rf rf_model.predict(X_test_scaled) cm confusion_matrix(y_test, y_pred_rf) disp ConfusionMatrixDisplay(confusion_matrixcm, display_labels[Normal, Attack]) disp.plot(cmapBlues) plt.title(RandomForest Confusion Matrix on KDDTest) plt.savefig(confusion_matrix_rf.png, dpi150) plt.show()二分类混淆矩阵的四个格子分别对应正常判正常、正常判攻击误报、攻击判正常漏报、攻击判攻击命中。入侵检测系统部署场景里漏报的代价远高于误报所以你调参时如果发现召回率不足 90%优先提高它必要时可以降低分类阈值这比盲目调模型参数见效更快。4. 源码结构与文档说明把项目组织成别人能复现的样子4.1 项目目录设计data、src、models、docs 怎么分工入侵检测项目源码包拿到手第一件事不是打开 main.py而是看目录结构。我习惯的项目骨架如下这套结构也适合集成到 VS Code 的 Python 环境里直接调试nsl-kdd-ids/ ├── data/ │ ├── KDDTrain.txt │ ├── KDDTest.txt │ └── KDDTest-21.txt ├── src/ │ ├── __init__.py │ ├── load_data.py │ ├── preprocess.py │ ├── train_model.py │ ├── evaluate_model.py │ └── predict.py ├── models/ │ ├── rf_model.pkl │ └── xgb_model.pkl ├── docs/ │ └── 说明文档.md ├── requirements.txt └── README.mddata 目录放原始数据不建议把预处理后的中间结果也放进来因为中间结果可能带着你的编码器状态和归一化参数别人直接加载容易踩数据泄露的坑。src 目录按功能拆文件train_model.py 只负责训练并保存模型evaluate_model.py 只负责加载模型并出指标这样一来换模型时不用动预处理代码。4.2 核心模块划分数据加载、特征工程、训练、评估、预测我在写这类课设项目源码时最看重模块之间的边界。load_data.py 负责读文件和列名映射preprocess.py 负责字符编码和归一化train_model.py 读取预处理后的数据训练模型evaluate_model.py 计算指标predict.py 对外提供单条样本预测接口。# src/train_model.py 核心片段 import joblib from preprocess import get_preprocessed_data from sklearn.ensemble import RandomForestClassifier X_train, X_test, y_train, y_test get_preprocessed_data() def train_random_forest(save_path: str models/rf_model.pkl): model RandomForestClassifier(n_estimators200, max_depth15, random_state42) model.fit(X_train, y_train) joblib.dump(model, save_path) return model if __name__ __main__: train_random_forest()这段代码里 get_preprocessed_data 从 preprocess.py 导入封装了前面所有数据清洗逻辑。save_path 用默认参数的好处是命令行调用时不用背参数。models 目录下保存 .pkl 文件joblib 是 sklearn 官方推荐的持久化方式比 pickle 更高效。predict.py 我单独拿出来强调一下因为它决定了你这个源码包能不能被别人直接拿去用# src/predict.py import joblib import numpy as np import pandas as pd MODEL_PATH models/rf_model.pkl # 相对路径要针对你的实际目录调整 model joblib.load(MODEL_PATH) def predict_one(raw_record: dict) - str: # raw_record 是 41 个特征组成的字典 df pd.DataFrame([raw_record]) # 这里要复用 preprocess.py 里的编码器和 scaler processed preprocess.transform(df) prob model.predict_proba(processed)[0][1] label Attack if prob 0.5 else Normal return label, round(float(prob), 4)我特别强调“复用 preprocess 里的编码器和 scaler”因为如果 predict.py 里重新做一次 fit编码结果就会和训练时不一致预测出的类别是乱码玄学。正确做法是训练完成后把 LabelEncoder 和 StandardScaler 也持久化保存预测时直接加载。4.3 文档说明怎么写需求描述、参数说明、复现步骤很多源码包翻车就翻在文档上。说明文档不需要长篇大论但要覆盖这几个板块环境依赖版本、数据文件放置位置、运行步骤、参数含义、预期结果。下面是我写文档时固定的模板# 环境依赖 Python 3.10 pandas2.1.4 numpy1.26.3 scikit-learn1.3.2 xgboost2.0.2 joblib1.3.2 matplotlib3.8.2 # 运行步骤 1. 将 KDDTrain.txt 和 KDDTest.txt 放入 data/ 目录 2. 执行 python src/train_model.py 训练随机森林模型 3. 执行 python src/evaluate_model.py 查看测试集指标 4. 执行 python src/predict.py --input single_record.json 预测单条数据 # 参数说明 - n_estimators: 树的数量越大越稳但训练越慢 - max_depth: 树的最大深度防止过拟合 - learning_rate: XGBoost 学习率越小越稳但需要更多树requirements 里要锁版本否则别人拿同样的代码在 sklearn 1.4 上跑API 行为可能有细微变化。参数说明不要写成教科书的罗列要写“调大/调小会怎样”这种实操指导读者才会觉得这个文档真能帮他改参数。5. 避坑与常见问题NSL-KDD 建模中的 5 个翻车现场5.1 数据泄露归一化必须在训练集上 fit不能在全集上 fit现象自己跑训练集准确率 99%测试集却掉到 80%怎么调参都回不去。原因有人在预处理时把训练集和测试集拼在一起然后 fit 了 StandardScaler。这样一来测试集的均值、方差信息混进了训练集模型训练时“偷看”了测试集的统计特性。在真实场景里没有这个步骤所以测试集分数必然跳水。解决严格按照“训练集 fit_transform 测试集 transform”的流程我的 preprocess.py 里就是这么写的。保险起见可以在训练完成前打印一下 scaler.mean_ 的前几个值确认它和你单独对训练集算的均值一致。5.2 二分类与多分类目标混用现象源码里标签明明是二分类但评估代码里用了五分类的标签列导致混淆矩阵形状对不上。原因NSL-KDD 的 label 列既是攻击类型又是攻击大类新手改标签时没有区分原始列和派生列。我见过不少项目里 train_df[“label”] 被覆盖成 0/1但测试集还是原来的攻击名字符串直接 prediction 比较时抛异常。解决在 preprocess 里创建新列 label_binary 和 label_multi永远不动原始 label 列。文档里明确写清楚哪个评估脚本对应哪个标签粒度。5.3 测试集未知攻击变体KDDTest 里 16 种训练集没见过的攻击现象训练集指标非常好测试集召回率只有 70% 左右尤其是 R2L 和 U2R 类几乎全部漏报。原因KDDTest 是刻意引入训练集不存在的攻击变体的这些样本在特征空间里离训练样本远模型没有学会它们的模式。R2L 和 U2R 在训练集里样本极少本身就是最难检测的类别。解决不要试图通过调参把 R2L 刷到 90%这是数据集的天然难度。合理的做法是分别报告四大类攻击的召回率承认 U2R 和 R2L 的检测率低然后从特征工程入手比如增加基于内容的特征组合或者用异常检测思路训练一个只描述 normal 样本的单分类模型。5.4 字符型特征未编码直接丢进模型现象运行代码时报 DataFrame 里的 object 列无法转换 float或者模型 fit 后出现 “could not convert string to float” 的类型错误。原因protocol_type、service、flag 没有做编码pandas 默认把它们当 object 类型而 ndarray 的 astype(np.float32) 直接失败。有些初学做法是给这些列随机赋整数导致模型认为不同服务的数值距离有意义严重影响效果。解决用前面 2.3 节的 LabelEncoder 方案或者在 preprocess 函数顶部加一个断言检查确保所有特征列都是数值类型不满足就直接报错中断而不是让错误发生在模型训练的中途。我有一次给别人调代码翻车原因就是这个花了半小时才发现是字符串没有编码。5.5 SMOTE 过采样导致验证集失真现象对全量数据做完 SMOTE 后训练集和测试集的 AUC 都很高但部署到真实流量时检测率骤降。原因SMOTE 必须在训练集内部做而且要在归一化之后、模型训练之前。如果先拼全量数据再做 SMOTE合成的样本会横跨训练集和测试集边界造成“验证集里到处是训练样本的插值”指标完全失真。这条血泪经验在入侵检测里特别常见因为 R2L 和 U2R 样本太少大家第一反应就是过采样。解决先切分训练测试集再只在训练集上做 SMOTE测试集永远保持原始分布。代码上使用 imblearn 库from imblearn.over_sampling import SMOTE sm SMOTE(random_state42, sampling_strategyauto) X_train_resampled, y_train_resampled sm.fit_resample(X_train_scaled, y_train)sampling_strategyauto 会把所有少数类都补齐到和多数类一样多如果你的目标是提高 U2R 的检出率可以手动指定 sampling_strategy 字典比如只对 u2r 过采样。6. 进阶验证与部署习惯把模型从实验台推到真实流量的最后一步模型在 KDDTest 上做得不错了下一步是让它能处理“一条新连接记录”而不是只能在训练测试集上跑 batch 预测。我习惯先做特征重要性筛选用随机森林输出的 feature_importances_ 找出前 15 个关键特征比如 src_bytes、dst_bytes、count、same_srv_rate 这些通常排在前列。这样做的价值有两个减少预测时的特征采集成本以及判断当前数据集和真实流量环境的偏差程度。importances rf_model.feature_importances_ feat_imp_df pd.DataFrame({ feature: feature_cols, importance: importances }).sort_values(importance, ascendingFalse) print(feat_imp_df.head(15)) # 只保留 top-15 特征重新训练验证分数是否下降 top_features feat_imp_df.head(15)[feature].tolist()我拿 NSL-KDD 试过砍到 20 个特征准确率几乎不降砍到 10 个就会开始掉召回率。这个结果写进文档里能体现你对特征分布的理解而不是只会跑代码。筛选完特征后把预处理器和模型一起打包用 joblib 持久化到 models 目录预测脚本里统一加载这样别人拿到源码包后不需要重新训练也能跑单条预测。关于持久化我有个习惯训练完就把模型加上 LabelEncoder 的三个对象、StandardScaler 的一个对象以及特征列名列表一起打包成更大字典再存避免预测时找不到编码器导致线上和训练时行为不一致import joblib full_package { model: xgb_model, encoders: {protocol: le_protocol, service: le_service, flag: le_flag}, scaler: scaler, feature_cols: feature_cols } joblib.dump(full_package, models/full_package.pkl)在此之后你可以考虑在原始数据上做交叉验证用 StratifiedKFold 分 5 折观察每折的召回率标准差。NSL-KDD 的类别分布不均匀直接取平均分不够可靠标准差过大说明模型对数据划分敏感这时候优先增加树的数量或者调节学习率而不是再堆模型复杂度。还有一个方向是引入 Deep Learning 做对比比如用论文里常见的 LSTM 或 Self-Attention 模型但我个人的结论是在这个数据规模下树模型已经够用深度模型提升有限而且训练时间翻几十倍。把这层对比写进文档别人会觉得你有自己的判断而不是只会抄默认参数。最后说一点个人习惯不管做什么项目我提交源码包之前会删掉数据集压缩包在干净环境里重新跑一遍 README 里的步骤跑不通就改文档。模型指标全部以复现为准不复现的数字再好看也是噪音。这套流程救了我很多次也希望帮到你。本文还有配套的精品资源点击获取
返回列表