☰
机器学习入侵检测实战:从NSL-KDD到CICIDS2017的完整流水线
2026/10/2 5:26:04 网站建设 项目流程

简介:这是一份面向网络安全初学者与机器学习实践者的入侵检测系统实战资料,围绕Python与ML算法构建IDS展开,适合想将分类、特征工程等理论落地到安全场景的读者。压缩包共3个文件,约403KB,包含1个csv数据集、1个py实现脚本和1个md说明文档:csv用于模型训练与验证,py脚本串联数据预处理、特征选择、模型训练与评估流程,md则交代项目背景与使用方式。资源以KDD99、NSL-KDD类公开数据为参照,覆盖误用检测与异常检测思路,并涉及决策树、随机森林、SVM等算法的选型对比。已有285人学习,读者可借此理解从数据清洗、特征工程到交叉验证、指标评估的完整链路,并参考代码结构自行替换数据集或调参,快速搭建可复现的入侵检测实验原型。

1. 从告警洪水到可复现流水线:Intrusion-Detection-System-using-ML-Algorithms 到底在解决什么

凌晨两点,安全运营群里又炸了——WAF 吐了三千条告警,IDS 日志刷了满屏,值班的人翻到第三页就放弃了。这不是段子,是很多团队上 IDS 之后的日常。传统基于规则匹配的入侵检测系统,靠的是签名库和阈值,遇到变种流量、慢速扫描、加密隧道里的异常行为,基本就是睁眼瞎。Intrusion-Detection-System-using-ML-Algorithms 这个方向,说白了就是拿机器学习算法去替代或补强那套死规则,让系统自己从流量特征里学出「什么算异常」。它适合两类人:一是手里已经有 NSL-KDD、CICIDS2017 这类数据集、想跑通一条完整流水线的安全工程师;二是做 ML 工程、想找一个特征工程和模型评估都足够真实的落地场景的人。这篇文章不讲虚的,从数据预处理一路写到模型部署和误报压制,每一步都给可抄的命令和参数。

2. 数据管道:把原始流量日志变成模型能吃的特征矩阵

2.1 为什么 NSL-KDD 和 CICIDS2017 是绕不开的起点

做入侵检测的 ML 项目,第一个翻车点往往不是模型选错,而是数据没整明白。公开数据集里,NSL-KDD 是 KDD Cup 99 的去重修正版,优点是干净、标注明确、论文引用多,适合验证算法本身;缺点是太老,攻击类型和现代流量差距大。CICIDS2017 由加拿大网络安全研究所发布,包含正常流量和多种攻击(DDoS、PortScan、Web Attack、Brute Force 等),特征维度 80 左右,更接近真实场景。我一般建议:先用 NSL-KDD 把流水线跑通,确认预处理和评估逻辑没问题,再换 CICIDS2017 做更严肃的验证。两个数据集的列结构不同,但处理思路一致——数值特征归一化、类别特征独热编码、标签二值化或多元分类。

2.2 用 pandas 做清洗与特征对齐的最小脚本

下面这段代码是整条流水线的地基,处理 NSL-KDD 的 KDDTrain+.txt 和 KDDTest+.txt。注意:训练集和测试集的类别特征必须用同一套编码器,否则会出现维度不一致的玄学报错。

import pandas as pd import numpy as np from sklearn.preprocessing import LabelEncoder, MinMaxScaler # NSL-KDD 的 43 列:41 个特征 + label + difficulty col_names = [ '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','difficulty' ] train = pd.read_csv('KDDTrain+.txt', names=col_names) test = pd.read_csv('KDDTest+.txt', names=col_names) # 丢掉 difficulty 列,它不参与建模 train.drop('difficulty', axis=1, inplace=True) test.drop('difficulty', axis=1, inplace=True) # 把攻击细类合并成 5 大类,减少类别不平衡 attack_map = { 'normal':'normal', 'back':'dos','land':'dos','neptune':'dos','pod':'dos','smurf':'dos', 'teardrop':'dos','apache2':'dos','udpstorm':'dos','processtable':'dos','worm':'dos', 'satan':'probe','ipsweep':'probe','nmap':'probe','portsweep':'probe','mscan':'probe','saint':'probe', 'guess_passwd':'r2l','ftp_write':'r2l','imap':'r2l','phf':'r2l','multihop':'r2l', 'warezmaster':'r2l','warezclient':'r2l','spy':'r2l','xlock':'r2l','xsnoop':'r2l','snmpguess':'r2l', 'buffer_overflow':'u2r','loadmodule':'u2r','rootkit':'u2r','perl':'u2r','sqlattack':'u2r','xterm':'u2r','ps':'u2r' } train['label'] = train['label'].map(attack_map) test['label'] = test['label'].map(attack_map) # 类别特征统一编码:fit 在训练集上,transform 两边都用 cat_cols = ['protocol_type','service','flag'] for col in cat_cols: le = LabelEncoder() train[col] = le.fit_transform(train[col]) # 测试集出现训练集没见过的类别,统一映射为 -1 test[col] = test[col].map(lambda x: le.transform([x])[0] if x in le.classes_ else -1) # 数值特征归一化到 [0,1] num_cols = [c for c in train.columns if c not in cat_cols + ['label']] scaler = MinMaxScaler() train[num_cols] = scaler.fit_transform(train[num_cols]) test[num_cols] = scaler.transform(test[num_cols]) print(train.shape, test.shape) print(train['label'].value_counts())

逻辑说明:先按官方列名读入,避免列错位;把 20 多种攻击细类合并成 dos/probe/r2l/u2r/normal 五类,是因为细类下样本量差异极大,直接多分类会让模型偏向多数类。类别编码用 LabelEncoder 而不是 One-Hot,是为了控制维度——service 列有 70 多个取值,独热后维度爆炸。参数上,MinMaxScaler 对神经网络和 KNN 友好,但如果后面用树模型,可以跳过归一化,树模型对单调变换不敏感。测试集里出现训练集未见过的 service 值时映射为 -1,这是一个折中,更严谨的做法是留出未知类别桶。

2.3 特征选择:别把 41 维全塞给模型

NSL-KDD 的 41 维里有不少冗余,比如 serror_rate 和 srv_serror_rate 高度相关。全塞进去不会让模型更好,反而拖慢训练、增加过拟合风险。常见做法是用随机森林算特征重要性,取累计贡献 95% 的前 N 维,或者用卡方检验做过滤。我一般会跑一遍重要性排序,把重要性低于 0.005 的特征砍掉,通常能压到 20 维左右,F1 不掉甚至微升。这一步没有固定参数,取决于数据集,但原则是:先看重要性曲线拐点,再结合业务可解释性保留关键特征。

3. 模型选型与训练:从随机森林到 XGBoost 的取舍

3.1 为什么我默认先上随机森林而不是深度学习

很多人一上来就想用 LSTM 或 Autoencoder,觉得「深度」才高级。但在入侵检测这个场景,随机森林和 XGBoost 往往是性价比最高的选择。原因有三:第一,表格型数据上树集成模型的表现在大量实证中不输甚至优于深度网络;第二,训练快,几分钟出结果,方便快速迭代特征;第三,特征重要性可解释,安全团队能看懂「为什么这条流量被判为攻击」。深度学习适合流量序列建模或零日检测,但那是第二阶段的事。先用随机森林把 baseline 打出来,再考虑上复杂模型。

3.2 训练脚本与关键超参数

from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report, confusion_matrix from xgboost import XGBClassifier X_train = train.drop('label', axis=1).values y_train = train['label'].values X_test = test.drop('label', axis=1).values y_test = test['label'].values # 随机森林 baseline rf = RandomForestClassifier( n_estimators=200, # 树的数量,200 在 NSL-KDD 上足够收敛 max_depth=20, # 限制深度防过拟合,不设则树会疯长 min_samples_leaf=2, # 叶子最小样本,太小会记住噪声 class_weight='balanced', # 类别不平衡时自动加权 n_jobs=-1, random_state=42 ) rf.fit(X_train, y_train) rf_pred = rf.predict(X_test) print("RandomForest:") print(classification_report(y_test, rf_pred)) # XGBoost 对比 xgb = XGBClassifier( n_estimators=300, max_depth=6, # 树模型深度,6 是常用起点 learning_rate=0.1, # 学习率,调小需配合更多树 subsample=0.8, # 行采样,防过拟合 colsample_bytree=0.8, # 列采样 use_label_encoder=False, eval_metric='mlogloss', random_state=42 ) xgb.fit(X_train, y_train) xgb_pred = xgb.predict(X_test) print("XGBoost:") print(classification_report(y_test, xgb_pred))

逻辑说明:随机森林的 n_estimators 从 100 加到 200,F1 通常有微小提升,再加收益递减;max_depth 不限制的话,树会深到记住每个训练样本,测试集直接崩。class_weight='balanced' 在 NSL-KDD 上很关键,因为 u2r 和 r2l 样本极少,不加权模型会直接把它们全判成 normal。XGBoost 的 max_depth 默认 6,比随机森林浅,靠 learning_rate 和 n_estimators 配合。subsample 和 colsample_bytree 各 0.8 是防过拟合的常规操作。注意 XGBoost 对类别标签要求是 0 到 n-1 的整数,如果标签是字符串要先编码。

3.3 评估指标:准确率是最大的陷阱

NSL-KDD 测试集里 normal 占比约 43%,如果模型把所有样本都判成 normal,准确率也有 43%,但这模型毫无用处。所以必须看召回率和 F1,尤其是少数类(u2r、r2l)的召回。我一般会打印完整的 classification_report,重点盯 macro avg 的 F1 和每个攻击类的 recall。如果某个类的 recall 低于 0.5,说明模型根本没学到,要么加样本,要么调 class_weight,要么换算法。另一个坑是:不要用训练集准确率来判断模型好坏,树模型在训练集上轻松 99%+,那是过拟合的假象。

4. 避坑与排查:IDS 模型落地时最容易翻车的五件事

4.1 现象:测试集 F1 比训练集低 30 个点 → 原因:训练测试分布不一致 → 解决:检查编码器和归一化是否只在训练集 fit

这是最常见的翻车。NSL-KDD 的测试集里有很多训练集没出现过的 service 和 flag 值,如果归一化和编码在两边分别 fit,测试集的数值范围会和训练集对不上,模型看到的输入分布完全变了。解决方法是所有 fit 操作只在训练集上做,测试集只 transform。上面 2.2 的代码已经这么处理了,但很多人抄代码时会把 scaler.fit_transform 写成对两边都调用,那就埋雷了。

4.2 现象:模型把大量正常流量判成攻击,误报率爆表 → 原因:类别权重设得太激进 → 解决:用验证集调 class_weight,别拍脑袋

class_weight='balanced' 会自动按类别频率反比加权,在极端不平衡时会把少数类权重拉到很高,导致模型过度敏感,正常流量稍微偏一点就判攻击。实际部署中误报的代价很高,值班人员被狼来了搞几次就不信系统了。我一般会在验证集上试几组权重,比如 balanced、{0:1, 1:5, 2:10} 这种手动设置,找到误报和漏报的平衡点。没有万能参数,取决于业务能容忍多少误报。

4.3 现象:XGBoost 训练报错 label must be in [0, num_class) → 原因:标签是字符串或跳号 → 解决:先 LabelEncoder 再训练

XGBoost 的类别标签必须是连续的整数。如果 label 列是 'normal'、'dos' 这种字符串,或者编码后是 0、2、5 跳号,都会报这个错。解决很简单:在训练前加一步le = LabelEncoder(); y = le.fit_transform(y)。但要注意,预测后要用le.inverse_transform还原成可读标签,否则你拿到一堆数字不知道对应什么攻击。

4.4 现象:模型在 CICIDS2017 上表现远差于 NSL-KDD → 原因:CICIDS 特征里有大量常数列和 NaN → 解决:先做方差过滤和缺失值处理

CICIDS2017 的 CSV 里有些列全是 0 或者全是同一个值,这些列对模型没有任何信息量,还会干扰特征重要性计算。另外有些流量统计列存在 NaN 或 inf,直接喂给 sklearn 会报错。处理方法是:先df.dropna()或填充,再用VarianceThreshold(threshold=0)删掉零方差列,最后把 inf 替换成该列最大值。这一步不做,后面调参调到天亮也没用。

4.5 现象:离线指标很好,上线后效果崩了 → 原因:训练数据的时间分布和线上不一致 → 解决:按时间切分数据集,别随机切

这是最隐蔽的坑。随机切分会让训练集和测试集共享同一时间段的流量模式,指标虚高。真实场景里,攻击手法会演变,今天的正常流量明天可能变成异常。正确做法是按时间顺序切分:前 70% 时间做训练,后 30% 做测试。CICIDS2017 是按天采集的,可以按天切。这样评估出来的指标才接近上线后的真实表现,虽然数字会难看一些,但那才是真相。

5. 从离线模型到在线检测:阈值调优与增量更新

5.1 用 predict_proba 做阈值滑动,而不是硬分类

离线训练完模型,直接predict输出 0/1 或类别,上线后你会发现误报根本压不住。原因是模型输出的概率才是连续信号,硬分类丢掉了置信度信息。实际部署时,我一般用predict_proba拿到每个类别的概率,然后对「攻击」类设一个阈值,比如概率大于 0.85 才告警,0.6 到 0.85 之间记为可疑、降级处理。这个阈值不是拍脑袋定的,而是在验证集上画 precision-recall 曲线,找到业务能接受的误报率对应的点。

import numpy as np from sklearn.metrics import precision_recall_curve # 假设 rf 是训练好的模型,X_val 是验证集 proba = rf.predict_proba(X_val) # 取攻击类(非 normal)的概率之和 attack_idx = [i for i, c in enumerate(rf.classes_) if c != 'normal'] attack_proba = proba[:, attack_idx].sum(axis=1) y_val_binary = (y_val != 'normal').astype(int) precision, recall, thresholds = precision_recall_curve(y_val_binary, attack_proba) # 找 recall >= 0.95 时 precision 最高的阈值 valid = recall[:-1] >= 0.95 best_threshold = thresholds[valid][np.argmax(precision[:-1][valid])] print(f"推荐阈值: {best_threshold:.3f}")

逻辑说明:把多分类问题转成二分类——「是否攻击」,这样阈值调优更直观。attack_proba 是所有攻击类概率之和,代表模型认为这条流量是攻击的总置信度。precision_recall_curve 返回不同阈值下的 precision 和 recall,我们约束 recall 不低于 0.95(尽量不漏报),在这个前提下选 precision 最高的阈值。参数上,recall 下限设多少取决于业务:安全场景通常宁可误报不可漏报,所以 recall 要求高;但如果值班人力有限,就得放宽到 0.90 甚至 0.85。

5.2 增量更新:别指望一次训练管一辈子

攻击手法在变,流量分布在变,模型不更新就会慢慢失效。但全量重训成本高,常见做法是增量学习。随机森林本身不支持增量,但可以用warm_start=True加树,或者换用支持 partial_fit 的 SGDClassifier。更实用的方案是:每月用新数据重新训练一版,和旧模型在验证集上对比,新模型指标不降就替换,降了就保留旧模型并排查原因。这个流程可以用 Airflow 或 cron 定时跑,不需要多复杂。

5.3 一个容易忽略的细节:特征计算的一致性

离线训练时特征是从 CSV 里读的,上线后特征是从实时流量里算的。如果两边的计算逻辑有偏差——比如离线用的时间窗口是 2 秒,线上是 5 秒——模型看到的输入分布就变了,指标必然崩。我踩过这个坑,排查了一周才发现是窗口不一致。解决办法是把特征计算逻辑封装成一个独立模块,离线和线上共用同一份代码,输入输出格式严格对齐。这个模块的单元测试要覆盖边界情况,比如空窗口、超大流量。

希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询