简介:这是一套面向计算机相关专业学生与开发者的机器学习实战项目资料,核心为基于机器学习的入侵检测系统完整源码与配套文档,适合用作毕业设计、课程设计、项目立项演示或进阶练手。项目已通过导师评审,答辩评分达95分,代码经测试可正常运行,具备较高参考价值。压缩包共23个文件,约19KB,以Python脚本、XML配置、Markdown说明及Git相关文件为主,其中Python文件承担数据处理与SVM等算法实现,XML与配置文件用于工程环境与依赖管理,README与LICENSE则提供使用说明与授权信息。目录中可见数据处理器、SVM算法模块及网络包嗅探组件,结构清晰,便于按模块阅读与二次开发。目前已有50人学习关注。读者可借此理解入侵检测从数据预处理、特征处理到模型训练与检测的完整链路,并在此基础上修改扩展功能,快速迁移至自身课题或作业场景。
1. 从一份「入侵检测系统」资料包说起:机器学习到底在里面干了什么
很多人第一次接触「基于机器学习的入侵检测系统」这个题目,是在课程设计或者毕设选题表里。名字看着唬人,真打开一份现成的资料包,往往是一堆 csv、几个 py 脚本、一份 Word 文档,外加一张准确率 99% 的截图。问题来了:这 99% 是怎么来的?换一批流量还能不能打?这套东西到底能不能落到真实网络里?
先把定位说清楚。入侵检测系统(IDS)要解决的核心问题是:从海量网络连接记录里,把「正常流量」和「攻击流量」分开,最好还能告诉你这是哪一类攻击。传统做法靠规则匹配,比如 Snort 写特征串,命中就报警。规则的问题是滞后——新攻击出来,规则库没更新就抓瞎;而且加密流量、变种攻击越来越难用固定特征描述。机器学习切入的价值就在这:不写死规则,让模型从标注好的流量特征里自己学出判别边界。
这份资料包适合谁?三类人。第一类是做课程设计、毕设的学生,需要一套能跑通、能写进论文的完整流程;第二类是刚转安全方向的工程师,想搞明白 IDS 里机器学习模块的输入输出长什么样;第三类是做安全运营的,想评估「要不要自建一个检测模型」。不适合谁?指望拿现成模型直接上生产、零调优就挡住真实攻击的人——那是不现实的,后面会讲为什么。
整篇内容我按一条主线走:先讲清楚数据和任务怎么定义,再落到特征工程和模型训练的具体命令,然后是评估指标怎么选才不骗自己,接着是部署和排查的坑,最后给一套能验证模型是否真的学到东西的进阶技巧。你照着做,至少能得到一个自己完全掌控、知道每个参数含义的检测原型,而不是一个黑匣子。
2. 数据与任务定义:NSL-KDD 这类数据集该怎么读、怎么切
2.1 先搞清楚一条「连接记录」里有什么
入侵检测公开数据集里最常被拿来练手的是 KDD Cup 99 及其修正版 NSL-KDD。别急着上模型,先看一条记录长什么样。每条记录描述的是一次网络连接,字段大致分四组:
- 基础 TCP 特征:duration(连接时长)、protocol_type(tcp/udp/icmp)、service(http/ftp/smtp 等 70 种)、flag(连接状态标志)、src_bytes、dst_bytes。
- 内容特征:登录失败次数、是否 root 登录、文件创建次数等,主要针对 U2R、R2L 这类需要看载荷内容的攻击。
- 流量统计特征:过去 2 秒内同目标主机的连接数、SYN 错误率、REJ 错误率等,用时间窗口统计出来。
- 主机统计特征:过去 100 次连接里同目标主机的统计量,用来抓慢速扫描。
标签字段是label,取值有 normal、neptune、smurf、guess_passwd 等几十种具体攻击名,另外还有一个difficulty字段,是 NSL-KDD 特有的,表示这条样本被多少种分类器误判过,难度越高越难分。
提示:KDD Cup 99 原始集有大量重复记录,直接训练会严重高估准确率。NSL-KDD 去掉了冗余,是更靠谱的起点,但它依然是 1998 年的模拟流量,和今天的真实网络有代差,这点心里要有数。
2.2 把多分类标签压成五类,再决定做二分类还是多分类
原始标签几十种,直接做多分类样本极不均衡。常见做法是映射成五大类:Normal、DoS、Probe、R2L、U2R。映射逻辑用字典写死,别用正则去猜。
import pandas as pd # NSL-KDD 的列名,官方 txt 没有表头,必须手动指定 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) # 攻击名到五大类的映射,只列常用项,完整版按数据集文档补全 attack_map = { "normal": "Normal", "neptune": "DoS", "smurf": "DoS", "back": "DoS", "teardrop": "DoS", "pod": "DoS", "land": "DoS", "apache2": "DoS", "udpstorm": "DoS", "processtable": "DoS", "worm": "DoS", "mailbomb": "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", "buffer_overflow": "U2R", "rootkit": "U2R", "loadmodule": "U2R", "perl": "U2R", "sqlattack": "U2R", "xterm": "U2R", "ps": "U2R" } for df in (train, test): df["category"] = df["label"].map(attack_map) # 映射不到的样本直接看有多少,别默默丢掉 print(df["category"].isna().sum())这段代码的关键点有三个。第一,列名必须手动给,因为官方 txt 文件不带表头,列数对不上后面全乱。第二,映射字典要覆盖测试集里出现的所有攻击名,否则map会产生 NaN,训练时被静默丢弃,导致测试集评估失真。第三,difficulty字段训练时要删掉,它是数据集自带的难度标注,不是流量特征,留着就是标签泄漏。
参数上,pd.read_csv默认按逗号分隔,NSL-KDD 正好是逗号分隔,不用改sep。如果读进来发现某列全是 NaN,八成是列名数量对不上,用df.shape核对一下是不是 43 列。
2.3 训练集和测试集的分布差异,是第一个大坑
NSL-KDD 官方给的 KDDTrain+ 和 KDDTest+ 不是随机切分的,测试集里包含训练集没出现过的攻击类型。这是故意的,用来模拟「遇到新攻击」的场景。所以你会在测试集上看到准确率比训练集低一大截,这不是代码写错了,是数据集设计如此。
我一般会做两件事:一是先统计两个集合的类别分布,心里有数;二是额外从训练集里切一个验证集出来调参,不要拿官方测试集调参,否则就是变相过拟合。
print(train["category"].value_counts(normalize=True)) print(test["category"].value_counts(normalize=True)) from sklearn.model_selection import train_test_split X = train.drop(columns=["label", "category", "difficulty"]) y = train["category"] X_tr, X_val, y_tr, y_val = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 )stratify=y保证切分后各类比例一致,random_state固定住方便复现。这一步看着简单,但不分层的话,U2R 这种占比不到 0.1% 的类可能验证集里一条都没有,评估直接失去意义。
3. 特征工程与模型训练:从 One-Hot 到随机森林的可复现流程
3.1 类别特征怎么编码,数值特征要不要归一化
protocol_type、service、flag是字符串类别特征,模型吃不了。两种常见处理:LabelEncoder 和 One-Hot。LabelEncoder 把 tcp/udp/icmp 编成 0/1/2,问题是模型会误以为 2 比 0 大,引入不存在的序关系。One-Hot 更稳妥,代价是service有 70 个取值,维度会涨。
我的做法是:protocol_type和flag取值少,直接 One-Hot;service取值多但高频项集中,也 One-Hot,让稀疏矩阵去扛。数值特征里,src_bytes、dst_bytes这种跨度从 0 到上亿,树模型不敏感可以不管,但如果你要用逻辑回归或者 SVM,必须做标准化,否则量纲大的特征会主导距离计算。
from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline cat_cols = ["protocol_type", "service", "flag"] num_cols = [c for c in X.columns if c not in cat_cols] preprocess = ColumnTransformer([ ("cat", OneHotEncoder(handle_unknown="ignore"), cat_cols), ("num", StandardScaler(), num_cols) ])handle_unknown="ignore"很关键。测试集里可能出现训练集没见过的service取值,不加这个参数会直接报错。加了之后,未知类别编码成全零向量,模型仍能给出预测,虽然这类样本准确率会打折,但至少不会让整个流程崩掉。
3.2 随机森林做基线,参数怎么设才不玄学
入侵检测的基线模型,我一般先上随机森林。原因很实在:对特征尺度不敏感、能处理高维稀疏、训练快、还能输出特征重要性帮你理解数据。别一上来就上深度学习,数据量和特征工程没到位之前,深模型大概率不如调好的树模型。
from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report, confusion_matrix clf = Pipeline([ ("prep", preprocess), ("rf", RandomForestClassifier( n_estimators=200, # 树的数量,200 是精度和耗时的平衡点 max_depth=None, # 让树长满,靠 min_samples 控制过拟合 min_samples_leaf=2, # 叶子最少 2 个样本,抑制噪声 class_weight="balanced", # 自动按类别频率加权,缓解不均衡 n_jobs=-1, random_state=42 )) ]) clf.fit(X_tr, y_tr) y_pred = clf.predict(X_val) print(classification_report(y_val, y_pred, digits=4)) print(confusion_matrix(y_val, y_pred))参数逐个说。n_estimators=200不是越多越好,超过 300 之后精度提升很小,训练时间线性涨。min_samples_leaf=2是我踩过坑之后固定下来的值,默认 1 会让树去拟合噪声,验证集上 U2R 这类小样本类波动很大。class_weight="balanced"直接按n_samples / (n_classes * bincount)给每类加权,让模型不敢忽略小类。如果你不加这个,Normal 和 DoS 会吃掉绝大部分注意力,R2L 和 U2R 的召回率会低到没法看。
3.3 用 XGBoost 再压一压,以及特征重要性怎么看
随机森林跑通之后,可以换梯度提升试试。XGBoost 在表格数据上通常比随机森林强一点,代价是调参更麻烦。
from xgboost import XGBClassifier xgb = Pipeline([ ("prep", preprocess), ("clf", XGBClassifier( n_estimators=300, max_depth=6, # 树深,6 层对这类特征够用 learning_rate=0.1, # 学习率,调小要配合更多树 subsample=0.8, # 行采样,防过拟合 colsample_bytree=0.8, # 列采样 objective="multi:softmax", num_class=5, eval_metric="mlogloss", random_state=42 )) ]) xgb.fit(X_tr, y_tr)max_depth=6是经验值,再深容易记住训练集里的具体样本。subsample和colsample_bytree都设 0.8,是给模型加随机性,效果类似随机森林的 bagging。objective用multi:softmax做五分类,如果你更关心概率输出,换成multi:softprob。
训练完看特征重要性,能帮你判断模型是不是学到了合理的东西。如果排第一的是src_bytes或者dst_bytes,正常,流量大小本来就是强特征。如果某个统计特征重要性异常高,比如dst_host_serror_rate,那可能模型抓住了 DoS 攻击的 SYN 错误率,合理。但如果difficulty混进去了还排前面,那就是标签泄漏,回去检查列有没有删干净。
注意:特征重要性高不等于因果。它只说明这个特征在树的分裂里被用得多,不代表它是攻击的本质原因。写报告的时候别把重要性直接当成「攻击原理」。
4. 评估指标与阈值:为什么准确率 99% 的模型可能一文不值
4.1 类别不均衡下,accuracy 是最会骗人的指标
NSL-KDD 训练集里 Normal 占一半左右,DoS 占三分之一,R2L 和 U2R 加起来不到 1%。这种情况下,一个把所有样本都判成 Normal 的模型,准确率也有 50% 以上。如果测试集分布更偏,准确率还能更高。所以看到「准确率 99%」先别激动,问一句:少数类的召回率是多少?
入侵检测场景里,漏报(把攻击判成正常)的代价远大于误报。误报多了顶多是运维烦,漏报一次可能就是一次入侵。所以评估要盯住这几个指标:
| 指标 | 含义 | 在 IDS 里的意义 |
|---|---|---|
| Recall(召回率) | 该类攻击被检出的比例 | 漏报率 = 1 - Recall,越低越好 |
| Precision(精确率) | 报出来的里有多少是真的 | 误报率 = 1 - Precision |
| F1 | 召回和精确的调和平均 | 综合看,但别只看它 |
| 混淆矩阵 | 每类被判成什么 | 看具体哪类被混淆 |
4.2 用 classification_report 逐类看,别只看 macro avg
classification_report输出的macro avg是各类指标的算术平均,weighted avg是按样本数加权。在不均衡数据上,weighted avg会被大类主导,看着漂亮但掩盖了少数类的糟糕表现。我一般直接看每一行的 recall。
from sklearn.metrics import classification_report report = classification_report(y_val, y_pred, output_dict=True) for cls in ["Normal", "DoS", "Probe", "R2L", "U2R"]: r = report[cls]["recall"] p = report[cls]["precision"] print(f"{cls}: recall={r:.4f}, precision={p:.4f}")如果 U2R 的 recall 只有 0.1 甚至 0,别急着换模型。先看验证集里 U2R 有多少条,可能就十几条,模型根本没足够样本学。这时候要么做过采样,要么接受「U2R 检测率低」这个现实,在报告里如实写。
4.3 阈值调整:把决策边界往漏报方向推
分类器默认输出概率最大的类。如果你更怕漏报,可以手动调阈值:只要某类攻击的概率超过一个较低的门槛,就报警。代价是误报上升,但安全场景通常愿意接受。
import numpy as np proba = clf.predict_proba(X_val) classes = clf.classes_ # 对攻击类设低阈值,Normal 保持默认 attack_idx = [i for i, c in enumerate(classes) if c != "Normal"] threshold = 0.3 y_adj = [] for row in proba: if row[classes.tolist().index("Normal")] < 0.7: # Normal 概率不够高,看攻击类里概率最大的 best_attack = max(attack_idx, key=lambda i: row[i]) y_adj.append(classes[best_attack] if row[best_attack] > threshold else "Normal") else: y_adj.append("Normal")这段逻辑是:Normal 概率低于 0.7 时,检查攻击类概率是否超过 0.3,超过就报攻击。阈值 0.3 和 0.7 不是拍脑袋,是在验证集上按「漏报优先」原则扫出来的。你可以写个循环扫 0.1 到 0.5,画一条 recall 和 precision 的权衡曲线,挑一个业务能接受的平衡点。
提示:阈值调完一定要在独立测试集上再验一遍,别在验证集上调完就宣布胜利,那是过拟合阈值。
5. 部署与排查:模型上线前后最容易翻车的五个地方
5.1 训练时特征顺序和推理时对不上
现象:离线评估 F1 0.95,一接实时流量,预测结果全是 Normal 或者乱跳。
原因:训练时用 DataFrame 的列顺序喂给 ColumnTransformer,推理时从 Kafka 或者日志里拼出来的特征字典,键的顺序、类型和训练时不一致。One-Hot 编码器按训练时的类别顺序展开,顺序一乱,整个特征向量就错位了。
解决:把训练时的列顺序固化下来,存成 json 或者 pickle,推理时严格按这个顺序组装。更稳的做法是把预处理和模型打包成一个 Pipeline,推理时直接喂原始字段的 DataFrame,让 Pipeline 自己去对齐。
import joblib joblib.dump(clf, "ids_pipeline.pkl") # 推理时 loaded = joblib.load("ids_pipeline.pkl") raw = pd.DataFrame([{ "duration": 0, "protocol_type": "tcp", "service": "http", ... }]) print(loaded.predict(raw))5.2 类别特征出现训练集没见过的取值
现象:线上报ValueError: Found unknown categories,或者预测结果异常。
原因:训练集里service只有 70 种,线上来了个新服务名,OneHotEncoder 没见过。
解决:初始化时加handle_unknown="ignore",未知类别编码成全零。同时加监控,统计未知类别的出现频率,如果某个新服务频繁出现,说明训练数据该更新了。
5.3 数据漂移:三个月后模型悄悄失效
现象:上线头一个月召回率 0.9,三个月后掉到 0.6,没人改代码。
原因:网络流量分布变了。新业务上线、攻击手法变化、甚至工作时间变化,都会让线上数据的统计分布偏离训练集。模型没变,数据变了。
解决:定期用线上数据算特征分布,和训练集做对比。简单做法是监控几个关键特征的均值和方差,比如src_bytes、count。偏差超过阈值就触发重新训练。别指望一次训练管一年。
5.4 误报淹没运维:Normal 被大量判成 Probe
现象:运维反馈每天几百条 Probe 告警,查下来全是正常扫描行为,比如内部漏洞扫描器。
原因:Probe 类攻击和正常扫描在特征上高度重叠,模型分不开。训练集里 Probe 样本的分布和实际环境不一致。
解决:两条路。一是加白名单,把已知扫描器的源 IP 排除;二是把这类样本补进训练集,标成 Normal,让模型学会区分「授权扫描」和「恶意探测」。后者更根本,但需要标注成本。
5.5 模型文件版本和代码版本脱节
现象:回滚代码后,加载模型报维度不匹配。
原因:模型是用旧版特征工程训的,代码回滚了但模型文件没回滚,或者反过来。
解决:模型文件命名带上特征版本号和训练日期,比如ids_rf_v2_20240115.pkl。部署时校验模型的特征维度与当前代码产出的维度是否一致,不一致直接拒绝启动。这个检查写进启动脚本,能省掉很多半夜排查。
6. 让模型真正学到东西:特征消融与对抗验证的实操技巧
到这一步,你手上应该有一个能跑通、指标看得过去的模型了。但「指标好看」和「模型学到了正确的东西」是两回事。我一般会用两个技巧来验证。
第一个是特征消融。把特征按组删掉,看性能掉多少。如果删掉「内容特征」整组,性能几乎不变,说明模型主要靠流量统计特征在判,那 U2R、R2L 这类需要看内容的攻击,检测能力大概率是虚的。具体做法:
groups = { "basic": ["duration", "protocol_type", "service", "flag", "src_bytes", "dst_bytes"], "content": ["hot", "num_failed_logins", "logged_in", "root_shell", "num_file_creations"], "traffic": ["count", "srv_count", "serror_rate", "rerror_rate", "same_srv_rate"], "host": ["dst_host_count", "dst_host_srv_count", "dst_host_serror_rate"] } for name, cols in groups.items(): drop_cols = [c for c in X_tr.columns if c in cols] X_tr_d = X_tr.drop(columns=drop_cols) X_val_d = X_val.drop(columns=drop_cols) # 重新构建 preprocess 和模型,跑一遍评估 # 对比 recall,尤其是 R2L 和 U2R跑完你会看到一张表,哪组特征对哪类攻击关键,一目了然。这个分析写进文档,比单纯报一个准确率有说服力得多。
第二个是对抗验证。思路是:训练一个分类器去区分「训练集样本」和「测试集样本」,如果它能轻松区分,说明两个集合分布差异大,模型在测试集上的表现不可信。具体做法是把训练集标 0、测试集标 1,混在一起训练一个二分类器,看 AUC。AUC 接近 0.5 说明分布一致,接近 1 说明差异巨大。
from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import roc_auc_score adv_X = pd.concat([X_tr, X_val], axis=0) adv_y = np.array([0] * len(X_tr) + [1] * len(X_val)) adv_clf = RandomForestClassifier(n_estimators=100, random_state=42) adv_clf.fit(adv_X, adv_y) adv_pred = adv_clf.predict_proba(adv_X)[:, 1] print("Adversarial AUC:", roc_auc_score(adv_y, adv_pred))如果 AUC 高得离谱,比如 0.95 以上,说明训练集和验证集分布差异很大,你在验证集上看到的指标要打折扣。这时候要么检查切分逻辑,要么承认数据本身就有偏,在报告里如实说明。
最后一个习惯:每次改完特征工程或者模型参数,把配置、指标、混淆矩阵存一份到带时间戳的目录里。我吃过亏,调了十几版之后想回到某一版,发现没记参数,只能重跑。现在我的做法是训练脚本开头就把所有超参数写进一个 dict,连同结果一起 dump 成 json。这个习惯不花什么时间,但能省掉很多后悔药。
希望帮到你。
本文还有配套的精品资源,点击获取