简介:这是基于Python开发的机器学习入侵检测系统完整源码与项目实战资源,面向计算机、网络安全及相关专业高年级学生,适用于课程设计、综合实践或毕业设计环节,解决利用机器学习识别网络异常行为的问题。系统中包含数据预处理、模型训练、实时检测三大模块,涉及特征工程、递归特征消除法,以及随机森林与支持向量机等算法,检测模块支持实时流量分析与威胁等级评估,帮助读者掌握从流量数据到安全告警的完整建模流程。资源为zip压缩包,共27个文件,主要类型包括3个Python源码文件(如Sniffer.py、DataProcessor.py、SVM.py)、9个工程配置文件、项目说明README及辅助工具脚本等,整体仅17KB,轻量易部署。目前已有59人学习浏览,适合作为网络安全课程的实践教学案例,也可为毕业设计提供可复用的代码框架与算法参考。
1. 为什么用机器学习做入侵检测:从特征库到行为识别
做过安全运营的应该都体会过那种无力感:规则库里堆了几千条特征,新变种一出来还是拦不住,告警风暴里翻半天全是误报。机器学习入侵检测系统换了个思路,不再问“这个包长得像不像已知攻击”,而是问“这段流量行为在不在正常范围”。这套基于 Python 的源码项目,就是用 NSL-KDD 数据集完整走一遍“预处理 → 特征工程 → 模型训练 → 评估 → 部署”的流程,随机森林、XGBoost、MLP 都能直接切换跑。适合三类人:有 Python 基础的安全从业者想落地 AI 检测、做课设的学生需要一份能讲清楚原理的完整工程、想转 AI 安全的开发。我拆完一遍,最大的感受是坑比模型多,后面会一条条记录下来。
2. 数据集与预处理:NSL-KDD 的读取、标签映射和标准化
2.1 NSL-KDD 的结构与读取
NSL-KDD 是 KDDCUP99 的修正版,核心改动是去掉了大量重复记录,让训练集和测试集的分布更接近真实网络环境。训练集约 12.6 万条,测试集约 2.2 万条,每条样本由 41 个特征加 1 个标签组成。这份源码里的数据加载部分用 pandas 实现,把列名按官方文档定义好,然后直接读入 CSV。
import pandas as pd import numpy as np # NSL-KDD 原始文件是 txt,无表头,需要手动指定 41 个列名 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", header=None, names=cols) test_df = pd.read_csv("KDDTest+.txt", header=None, names=cols) print("train shape:", train_df.shape) print("test shape:", test_df.shape) print(train_df["label"].value_counts())这段代码的逻辑是先把 41 个列名硬编码成列表,顺序必须和原始数据集完全一致,一旦错位后面所有特征工程全白做。header=None是因为 NSL-KDD 原始数据没有表头,列名全靠手动指定。label列是最后一列,其他 41 列全是特征。我见过有人图省事直接用pd.read_csv("KDDTrain+.txt")把第一行当表头,结果整个数据集错位,模型训练出来完全没法看,这种低级错误排查起来极其浪费时间。
2.2 标签映射与类别分布
NSL-KDD 训练集里的标签是 normal 加上 22 种具体攻击名,比如 neptune、smurf、buffer_overflow 这种。直接拿 22 类做分类不是不行,但很多攻击样本极少,模型根本学不动。常规做法是先聚合为五大类:normal、dos、probe、r2l、u2r,这也是学术界通用的处理方式。
attack_map = { "dos": ["back", "land", "neptune", "pod", "smurf", "teardrop", "apache2", "udpstorm", "processtable"], "probe": ["satan", "ipsweep", "nmap", "portsweep", "mscan", "saint"], "r2l": ["guess_passwd", "ftp_write", "imap", "phf", "multihop", "warezmaster", "warezclient", "spy", "snmpgetattack", "snmpguess", "httptunnel", "named", "xlock", "xsnoop", "sendmail"], "u2r": ["buffer_overflow", "loadmodule", "rootkit", "perl", "sqlattack", "xterm", "ps"] } def map_label(label): if label == "normal": return "normal" for k, v in attack_map.items(): if label in v: return k return "unknown" train_df["label"] = train_df["label"].apply(map_label) test_df["label"] = test_df["label"].apply(map_label) print(train_df["label"].value_counts()) print(test_df["label"].value_counts())聚合之后类别分布立刻清晰:训练集中 DoS 占比接近三分之一,normal 占一半左右,而 R2L 和 U2R 加一起只有几千条。这就是后面模型翻车的主要根源之一。还有一点容易被忽略,测试集里包含训练集从未出现过的攻击变种,比如 KDDTest+.txt 里某些攻击类型在训练集里根本没见过,这直接决定了模型的泛化能力不可能靠死记硬背,必须靠特征层面的本质规律。
2.3 数值化与标准化
41 维特征里protocol_type、service、flag三个是字符串,其他都是数值。字符串特征不能直接喂给模型,常见做法是 One-Hot 编码。数值特征里的坑在于量级差异,src_bytes可能是几万,duration是几秒,如果不做标准化,树模型还好,KNN 和 MLP 这类对距离敏感的模型会被大数值特征完全主导。
from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.model_selection import train_test_split # 先切分再预处理,这条铁律后面会反复提 train_df, val_df = train_test_split( train_df, test_size=0.2, random_state=42, stratify=train_df["label"] ) cat_cols = ["protocol_type", "service", "flag"] num_cols = [c for c in cols[:-1] if c not in cat_cols] X_train_cat = train_df[cat_cols] X_train_num = train_df[num_cols].astype(float) encoder = OneHotEncoder(handle_unknown="ignore") encoder.fit(X_train_cat) X_train_cat_enc = encoder.transform(X_train_cat).toarray() scaler = StandardScaler() X_train_num_scaled = scaler.fit_transform(X_train_num) X_train = np.hstack([X_train_num_scaled, X_train_cat_enc]) y_train = train_df["label"].values这里的关键点是先用train_test_split切出验证集,再做 One-Hot 和标准化,所有fit都只作用在训练集上。handle_unknown="ignore"是为了防止测试集里出现训练集没见过的特殊 service 值导致编码崩溃。标准化用的是 Z-score,即减去均值除以标准差,scaler.fit_transform只对训练集调用,验证集和测试集后续只能transform,不能重新fit。这个顺序错了,模型在验证集上的成绩会虚高,看起来 98 分,上线后直接打回原形。
3. 特征工程与选型:41 维特征压缩到 20 维还能涨点
3.1 特征分组的业务含义与攻击对应关系
NSL-KDD 的 41 维特征可以按来源分成四组,理解了这四组特征各自针对什么攻击,调参和裁剪时心里才有底。这个理解在面试和答辩时也很加分,不是只会调库。
| 特征组 | 代表特征 | 主要对应的攻击 | 说明 |
|---|---|---|---|
| 基础特征 | duration、protocol_type、src_bytes、dst_bytes | 所有类型 | 描述单条连接的基本属性 |
| 内容特征 | hot、num_failed_logins、root_shell、su_attempted | R2L、U2R | 描述连接内容里的危险行为,比如尝试登录失败次数 |
| 流量特征 | count、srv_count、serror_rate、same_srv_rate | DoS、Probe | 描述过去 2 秒内与目标主机的连接统计,能捕捉扫描和洪泛行为 |
| 主机流量特征 | dst_host_count、dst_host_srv_count、dst_host_serror_rate | 慢速扫描、R2L | 按目标主机维度统计,专门对付低频率的分布式扫描 |
R2L 攻击通常单看一条连接和正常流量没区别,但连续多次尝试登录失败这个行为会体现在num_failed_logins和hot上。Probe 攻击则体现在count系列特征上,短时间大量连接不同端口。这些业务含义决定了特征裁剪的方向,不能纯靠统计指标一刀切。
3.2 用树模型做特征重要性排序
特征重要性排序是裁剪的第一步。随机森林训练一次就能拿到feature_importances_,速度很快,可以作为后续所有模型的参考。如果你的数据量更大,也可以换成 LightGBM 或者 XGBoost 来取重要性,结果基本一致。
from sklearn.ensemble import RandomForestClassifier # 用一份快速训练的随机森林来评估特征重要性 temp_model = RandomForestClassifier( n_estimators=100, max_depth=10, n_jobs=-1, random_state=42 ) temp_model.fit(X_train, y_train) # 这里需要把拼接后的特征列名重建出来 encoded_cat_cols = encoder.get_feature_names_out(cat_cols) all_feature_names = list(num_cols) + list(encoded_cat_cols) importance = pd.Series( temp_model.feature_importances_, index=all_feature_names ).sort_values(ascending=False) print(importance.head(20)) print(importance.tail(10))拿我这边跑出来的结果说,排在前面的是srv_serror_rate、same_srv_rate、dst_host_srv_count、dst_host_serror_rate这类统计型特征,而urgent、num_outbound_cmds、is_host_login这种常年为 0 的特征重要性几乎为 0。这符合安全直觉:攻击流量在统计特征上必然有异常,而某些内容特征在 NSL-KDD 里本来就极少激活。get_feature_names_out是 scikit-learn 1.0 之后的接口,老版本用get_feature_names,这两个方法在拼接列名时容易踩版本坑。
3.3 模型选型对比与最终特征集
确定特征裁剪范围之后,模型选型也得有个依据。这套源码里同时提供了随机森林、XGBoost、MLP 三种模型,我拆完对比后整理了一张选型参考表:
| 模型 | 训练速度 | 对类别不平衡的容忍度 | 可解释性 | 适合场景 |
|---|---|---|---|---|
| 随机森林 | 快 | 中等,可配 class_weight | 好,有特征重要性 | 默认首选,基线模型 |
| XGBoost | 中 | 较好,可配 scale_pos_weight | 中,有特征重要性但非线性强 | 追求精度时的主力 |
| MLP | 慢 | 差,需要手动调权重或采样 | 差,黑匣子 | 特征工程做足之后才有优势 |
选型逻辑是:树模型天然能做非线性切分,不用归一化也能跑,n_jobs=-1直接吃满 CPU 多核,对 12.6 万条这个量级毫无压力。MLP 在这种规模下没有明显精度优势,反而要花大量时间调网络结构,我一般只把它当作对比实验来跑。最终特征集建议保留重要性 Top 20 到 25 维,具体怎么做在第六章会有完整对比实验。
4. 模型训练与评估:随机森林与 XGBoost 的实战对比
4.1 随机森林训练与关键参数
随机森林当作基线模型时,参数设置有章可循。n_estimators不是越大越好,超过 300 之后边际收益极低,训练时间却线性增长。max_depth控制在 15 到 20 之间,太深会导致 R2L 和 U2R 这些少数类被噪声带偏。class_weight这里用了balanced_subsample,它对每个自助采样子集自动加权,比全局balanced更稳。
from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report model = RandomForestClassifier( n_estimators=300, max_depth=18, min_samples_split=5, min_samples_leaf=2, class_weight="balanced_subsample", n_jobs=-1, random_state=42 ) model.fit(X_train, y_train) y_val = val_df["label"].values # 验证集要和训练集走完全相同的预处理链路 X_val_cat = encoder.transform(val_df[cat_cols]).toarray() X_val_num = scaler.transform(val_df[num_cols].astype(float)) X_val = np.hstack([X_val_num, X_val_cat]) y_pred = model.predict(X_val) print(classification_report(y_val, y_pred))参数说明:max_depth=18是我在多种深度下对比后的折中值,太浅时正常流量和 DoS 分不开,太深时 R2L 的 F1 反而下降;min_samples_leaf=2强制每个叶子节点至少两条样本,抑制过拟合;random_state=42必须固定,否则你两次训练结果会漂移,后面对比实验没法做。这段代码里验证集的预处理也值得注意,encoder和scaler都是训练集上 fit 好的对象,验证集只调用transform,不能重新 fit。
4.2 XGBoost 对比与早停机制
XGBoost 在这套数据上通常比随机森林的 macro-F1 高 2 到 3 个百分点,主要赢在少数类的召回率上。它的eval_set配合early_stopping_rounds是防止过拟合最有效的手段,训练过程中如果验证集 loss 连续 N 轮不降,直接截停,省时间又省心。
from xgboost import XGBClassifier xgb_model = XGBClassifier( n_estimators=500, max_depth=6, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, reg_lambda=1.0, eval_metric="mlogloss", use_label_encoder=False ) xgb_model.fit( X_train, y_train, eval_set=[(X_val, y_val)], early_stopping_rounds=30, verbose=False )几个关键参数:learning_rate=0.05配合n_estimators=500,实际用了大概 300 多棵树就被早停截断了;subsample=0.8让每棵树只用 80% 的样本,增加随机性降低方差;reg_lambda=1.0是 L2 正则,对类别不平衡有抑制作用;use_label_encoder=False是 xgboost 1.6 之后的必填项,不写会报警告甚至报错。如果你用的 xgboost 版本更老,可能还需要把标签手动编码成 0 到 4 的整数。
4.3 混淆矩阵与评估指标:为什么准确率会骗人
只看准确率是这个项目里最大的坑。NSL-KDD 测试集里正常流量占一半,模型把所有样本都预测成 normal 也有接近 50% 的准确率。真正要盯的是宏平均 F1(macro-F1)和混淆矩阵里少数类的召回率。
import matplotlib.pyplot as plt import seaborn as sns from sklearn.metrics import confusion_matrix class_names = ["normal", "dos", "probe", "r2l", "u2r"] cm = confusion_matrix(y_val, y_pred) plt.figure(figsize=(8, 6)) sns.heatmap( cm, annot=True, fmt="d", cmap="Blues", xticklabels=class_names, yticklabels=class_names ) plt.xlabel("Predicted Label") plt.ylabel("True Label") plt.tight_layout() plt.savefig("confusion_matrix.png", dpi=150)混淆矩阵要重点看两个位置:一是 R2L 和 U2R 这两行,真实攻击被预测成什么类;二是 normal 那一行的误报率,安全场景里误报率太高会导致告警疲劳,运营人员最后直接不管告警了。我拆这套源码时把 RF 和 XGBoost 的混淆矩阵对比着看,发现 RF 会把相当一部分 R2L 误判成 normal,而 XGBoost 在 R2L 上的召回率明显更好。这就是选 XGBoost 做最终模型的核心理由。
5. 常见问题与排查:五个把模型搞崩的坑
5.1 数据泄漏:最隐蔽的一次翻车
现象:训练集准确率 99%,验证集准确率只有 76%,而且每次跑结果都有细微浮动,怎么调参都救不回来。
原因:我在第一次跑的时候直接对全量训练数据调用了StandardScaler.fit,然后再切分训练集和验证集。这导致标准化时用到了验证集本身的均值和方差,验证集的信息提前泄漏进了训练过程。树模型对标准化不敏感,所以随机森林没怎么受影响,但换成 MLP 之后直接崩到 60 多分。
解决:严格先切分,再 fit 预处理对象。更省事的做法是用 sklearn 的 Pipeline 把标准化和模型串起来,Pipeline.fit内部会自动只对训练部分 fit。从那以后我给自己立了条规矩:凡是涉及fit_transform的代码,先看数据的切分发生在哪一行。
5.2 准确率幻觉与类别不平衡
现象:某个版本跑出了 93% 的准确率,我差点以为模型已经能上线了,结果一看混淆矩阵,R2L 的召回率是 0.03,U2R 全军覆没。
原因:NSL-KDD 里 R2L 和 U2R 样本占比本来就极小,模型学到的是“无脑猜 normal 和 dos 就能拿高分”。准确率在这种严重类别不平衡的数据集上就是个幻觉指标,没有任何参考价值。
解决:评估指标换成 macro-F1,即每个类别的 F1 算完取平均,少数类权重和多数类一样。训练层面可以给模型加class_weight="balanced"或者用 SMOTE 合成少数类样本。注意 SMOTE 只能在训练集上做,如果你对全量数据做了过采样再切分,验证集里会混入合成样本,评估结果直接失真。
5.3 二分类陷阱:把五类问题硬做成两类
现象:有同学把问题简化为“正常 vs 攻击”二分类,F1 飙到 0.95,觉得很完美。但部署后发现 U2R 攻击完全漏报,R2L 也被大量漏掉。
原因:二分类把 DoS 和 Probe 这些“流量特征明显”的攻击学得很好,因为这两类样本多、模式清晰。R2L 和 U2R 本来样本就少,模型根本分不清它们的边界,被归类为 normal 的概率很高。五分类任务里单看某一类的指标,远没有混淆矩阵来的直观。
解决:保持五分类。如果确实想做两阶段,正确姿势是先做一个正常/异常二分类用于粗筛,再在异常样本上跑五分类细分类器。这个方案适合生产环境,但需要维护两个模型,源码工程量翻倍。
5.4 类别特征直接数值化导致排序偏差
现象:把protocol_type、service、flag用LabelEncoder编码成 0、1、2,模型训练完精度反而倒退了。
原因:LabelEncoder给类别强加了一个不存在的顺序关系,比如tcp=2会被树模型理解成“比udp=0大 2 倍”,这种虚假顺序会误导特征切分。数据量小的时候偏差不明显,样本一多误差就累积。
解决:直接用OneHotEncoder,service字段基数高也没关系,编码后维度从 41 扩到 120 左右,树模型处理高维稀疏没问题。如果特征维度爆炸影响训练速度,可以对service做目标编码,即用该类别下攻击样本比例作为编码值,但要加平滑项防过拟合。
5.5 随机种子不固定导致结果无法复现
现象:同一套代码同一份数据,昨天跑 macro-F1 是 0.78,今天变成 0.80,你以为调参调好了,其实是运气。
原因:sklearn 和 xgboost 内部都有随机过程,树模型的随机分裂、自助采样都依赖随机种子。不固定种子时,模型每次训练结果都不一样,调参过程全是玄学。
解决:所有模型统一加random_state=42,xgb 还要额外设n_jobs=1和random_state,因为 xgboost 在多线程下即使设了种子在某些版本里仍可能不完全可复现。推荐的验证方式:固定种子跑三次取均值,记录均值上下浮动范围,这样对比不同模型参数才有意义。
6. 模型落地与进阶:从 pkl 到单条预测接口
6.1 导出模型与封装预测函数
训练完成之后,模型、标准化器、编码器、特征列名这四个对象必须一起保存,缺一个都跑不起来。很多新手只 dump 了模型文件,部署时发现输入特征格式对不上,完全没法用。
import joblib # 四个对象打包保存,部署时全部加载 joblib.dump(model, "ids_model.pkl") joblib.dump(scaler, "ids_scaler.pkl") joblib.dump(encoder, "ids_encoder.pkl") joblib.dump(all_feature_names, "ids_features.pkl") def predict_one(raw_record): """raw_record: 与原始数据集列名一致的单条记录 dict""" df = pd.DataFrame([raw_record], columns=cols[:-1]) cat = encoder.transform(df[cat_cols]).toarray() num = scaler.transform(df[num_cols].astype(float)) X = np.hstack([num, cat]) proba = model.predict_proba(X)[0] label = model.classes_[np.argmax(proba)] confidence = float(np.max(proba)) return label, confidence部署时把四个 pkl 文件和这个函数封装成服务,输入是实时流量解析出的 41 维特征,输出是攻击类别和置信度。predict_proba返回的置信度很有价值,可以设置两级阈值:置信度大于 0.8 直接告警,0.5 到 0.8 之间进入人工复核队列,低于 0.5 丢弃。这套思路能把误报率再压低一截,因为模型在少数类上的置信度普遍偏低。
6.2 特征裁剪实战:从 41 维降到 20 维
用第三章算出的特征重要性,取前 20 维重新训练,对比裁剪前后效果。这一步在源码里对应一份单独的对比脚本,跑出来的结论是:裁剪后训练时间缩短约 30%,macro-F1 和全特征版本基本持平,个别随机种子下还能微涨。
from sklearn.model_selection import cross_val_score top20_cols = importance.head(20).index.tolist() # 重建裁剪后的训练集与验证集 train_reduced = pd.DataFrame(X_train, columns=all_feature_names) val_reduced = pd.DataFrame(X_val, columns=all_feature_names) X_tr_reduced = train_reduced[top20_cols].values X_val_reduced = val_reduced[top20_cols].values model_reduced = RandomForestClassifier( n_estimators=300, max_depth=18, min_samples_leaf=2, class_weight="balanced_subsample", n_jobs=-1, random_state=42 ) model_reduced.fit(X_tr_reduced, y_train) print(classification_report(y_val, model_reduced.predict(X_val_reduced)))特征裁剪的价值不只是省训练时间。保留的特征越少,部署时要采集的流量字段就越少,如果未来要接入真实网络流量解析环节,每少一个字段都意味着少一个解析模块和可能出错的点。从这份源码最终的验证结果来看,urgent、num_outbound_cmds、is_host_login这类近零方差特征砍掉完全不影响精度,但有些importance排名靠后的内容特征也不能一刀切,R2L 相关的内容特征本来就稀疏,重要性被低估是正常的,所以最终建议保留到前 25 维而不是死磕 20 维。
这套源码我前后拆了两遍才完全理清,最大的教训是安全方向的机器学习项目,评估指标和生产环境的差距远比想象中大。从那以后,我每跑一个分类项目都强制走一遍固定流程:先切分再预处理、打印完整分类报告、看混淆矩阵的少数类行、固定全部随机种子,最后才谈调参。希望帮到你。
本文还有配套的精品资源,点击获取