☰
机器学习驱动的入侵检测:从NSL-KDD特征工程到实时告警服务
2026/10/10 12:48:17 网站建设 项目流程

简介:这套基于机器学习的入侵检测系统Python源码项目,是面向计算机专业学生与实战学习者的完整解决方案,可支撑毕业设计、课程设计或期末大作业。系统覆盖数据预处理、特征工程、模型训练到分类预测的完整流程,内含CNN与LSTM两种深度学习模型代码,并附带技术方案文档,便于理解入侵检测的核心原理与实现路径。

包体结构清晰,共31个文件,包括14个Python脚本、5个文本说明、3个CSV数据文件、3个Markdown笔记,以及hdf5模型权重和数据集压缩包等,整体约26.58MB。源代码按模块分类组织,训练与预测脚本分离,并配有readme说明,便于按步骤复现实验结果。

目前已有166人浏览学习。项目源自研三高分作业,评审成绩99分,代码经导师指导并确保可运行,适合零基础到进阶的入门者。借助配套文档与数据,读者可快速掌握数据平衡处理、特征相关性分析、模型训练与评估等关键环节,是一个兼具教学与项目参考价值的实战资源。

1. 机器学习遇上入侵检测:为什么传统规则引擎越来越不够用

先给结论:用机器学习做入侵检测,不是拿个算法套上流量就能跑,而是要解决“数据怎么标、特征怎么提、模型怎么不误报”这三件事。这个标题里的项目,核心价值就是给出一套完整的 Python 源码和文档,让从业者不用从零搭框架,而是直接站在一个能跑通的基线上做二次开发。适合谁?适合刚接触安全分析的数据工程师、想在企业内部做流量审计的安全运维,也适合拿这个方向做毕业设计或竞赛项目的学生。传统 IDS 靠特征库匹配,遇到变种攻击和加密流量基本失效;机器学习方案能学出“异常行为模式”,但代价是数据预处理和模型调参的坑比想象中多。这篇文章会按一条完整落地路径来讲:从数据准备、特征工程、模型选型,到训练评估、实时检测和误报优化,最后把最容易翻车的几个点单独拎出来说清楚。

2. 先把数据集和标签搞清楚:入侵检测的“地基”是监督学习的前提

2.1 用公开流量数据集起步,别急着采真实业务流量

做入侵检测的机器学习模型,第一步不是选算法,而是找一份带标签的流量数据。常见做法是用公开的入侵检测数据集,比如 KDD Cup 1999、NSL-KDD、UNSW-NB15、CICIDS2017 这类。其中 NSL-KDD 是 KDD 的改进版,去掉了大量冗余记录,训练和测试分布更合理,适合做算法对比和课程设计;CICIDS2017 更贴近现代流量,包含 DDoS、暴力破解、Web 攻击等场景,但文件体积大、处理麻烦。

我自己一般会先用 NSL-KDD 跑通全流程,因为它的 CSV 格式干净、类别标签明确,省去大量抓包和清洗时间。等流程通了,再换 CICIDS2017 验证模型在更复杂数据上的表现。千万别一上来就接企业内部镜像流量,没有标签你根本没法做监督学习,只能退化为纯异常检测,效果很难验证。

2.2 下载与解析:把 ARFF 或 CSV 转成 Pandas DataFrame

NSL-KDD 官方提供的是 ARFF 格式(Weka 用的),也提供 CSV 版本。CSV 版本里有 train 和 test 两个文件,train 里大约 12 万条记录,test 约 2 万条。每条记录有 41 个特征加 1 个标签列,最后还带一个难度等级列,实际建模时通常只用前 42 列。

下面用 Pandas 读取并做初步探索:

import pandas as pd # 列名按 NSL-KDD 官方文档定义 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", names=cols) test_df = pd.read_csv("KDDTest+.txt", names=cols) print(train_df.shape, test_df.shape) print(train_df["label"].value_counts())

逻辑说明:这里直接按列名读取,是因为 NSL-KDD 的 CSV 文件没有表头;如果用header=0会把第一行数据当列名,后续所有特征对齐都会错位。打印标签分布的目的是确认类别是否均衡,如果某个攻击类型样本极少,模型很容易直接忽略它。

参数说明:names参数必须与数据文件里的列顺序一一对应,NSL-KDD 的 41 个特征顺序是固定的,不要凭印象改。如果数据文件是从别处下载的,先head -1看一下实际内容再定列名。

2.3 标签二分类还是多分类:先想清楚检测目标

NSL-KDD 的标签有两大类:正常(normal)和攻击(attack),攻击又细分为 DoS、Probe、R2L、U2R 四类。不同目标对应不同建模方式:

  • 做“异常检测”:把标签改成二分类,normal 为 0,其余为 1。模型目标是判断“是不是攻击”,适合告警场景。
  • 做“攻击类型识别”:做五分类,需要更多样本,尤其 R2L 和 U2R 样本极少,分类效果通常很差。

我建议先做二分类拿到一个高基线,再尝试五分类看差距。标签映射代码:

# 二分类标签映射 train_df["binary_label"] = (train_df["label"] != "normal").astype(int) test_df["binary_label"] = (test_df["label"] != "normal").astype(int) # 五分类标签映射 attack_map = { "normal": "normal", "neptune": "dos", "smurf": "dos", "pod": "dos", "teardrop": "dos", "land": "dos", "back": "dos", "apache2": "dos", "processtable": "dos", "mailbomb": "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", "xsnoop": "r2l", "xlock": "r2l", "snmpguess": "r2l", "snmpgetattack": "r2l", "httptunnel": "r2l", "named": "r2l", "sendmail": "r2l", "buffer_overflow": "u2r", "loadmodule": "u2r", "perl": "u2r", "rootkit": "u2r", "ps": "u2r", "sqlattack": "u2r", "xterm": "u2r" } train_df["attack_type"] = train_df["label"].map(attack_map)

逻辑说明:攻击类型映射表必须覆盖数据集里所有出现过的攻击名,漏一个就会变成 NaN,后面训练会报错。map比replace更安全,因为遇到未映射值会返回 NaN,方便你检查遗漏。

参数说明:二分类时用不等号判断直接生成 0/1,这里把binary_label保留为整数而不是布尔值,后续进 sklearn 不需要再转换。

3. 特征工程怎么做:数值化、标准化与特征选择一个都不能少

3.1 协议类型、服务、标志位:三个字符串特征必须编码

NSL-KDD 的 41 个特征里有 3 个是字符串类型:protocol_type(tcp/udp/icmp)、service(http/ftp/ssh 等几十种)、flag(SF/S0/REJ 等状态码)。绝大多数 sklearn 模型只接受数值输入,所以必须先编码。

常见的做法是先用LabelEncoder把字符串变成整数,再决定是否做 One-Hot。但这里有个分歧点:树模型(随机森林、XGBoost)可以直接吃整数标签,而线性模型和深度学习模型最好用 One-Hot。

我一般这样处理:

from sklearn.preprocessing import LabelEncoder cat_features = ["protocol_type", "service", "flag"] encoders = {} for col in cat_features: le = LabelEncoder() le.fit(pd.concat([train_df[col], test_df[col]])) # 合并 fit,避免测试集出现未知类别 train_df[col + "_enc"] = le.transform(train_df[col]) test_df[col + "_enc"] = le.transform(test_df[col]) encoders[col] = le # 丢弃原始字符串列,保留编码列 feature_cols = [c for c in train_df.columns if c not in cat_features + ["label", "binary_label", "attack_type"]] print(feature_cols[:10])

逻辑说明:合并fit是关键。如果只拿训练集 fit,测试集里出现了训练集没有的 service 值,transform会直接抛异常。LabelEncoder 只做标签编码,不做 One-Hot;对树模型来说这样够了,但如果后面换神经网络,建议用pd.get_dummies做 One-Hot,并且同样要先对齐训练集和测试集的列。

参数说明:feature_cols里此时包含原来的字符串列吗?不包含,因为我用列表推导式把cat_features排除了。但注意原始 41 个特征里num_outbound_cmds这一列在 NSL-KDD 里全是 0,可以留着也可以删掉,不影响结果。

3.2 数值特征分布差异大:用 StandardScaler 还是 RobustScaler

看 NSL-KDD 的特征分布,src_bytes和dst_bytes的单位跨度极大,有的记录只有几十字节,有的有几百万字节;而count、serror_rate这类值就比较集中。如果不做缩放,基于距离的算法(KNN、SVM)会被大数值特征主导,梯度下降类模型收敛也会变慢。

这里我推荐用RobustScaler,因为数据里存在明显的离群点,StandardScaler 会被极大值拉偏均值和方差:

from sklearn.preprocessing import RobustScaler scaler = RobustScaler(quantile_range=(25.0, 75.0)) X_train_num = scaler.fit_transform(train_df[feature_cols]) X_test_num = scaler.transform(test_df[feature_cols])

逻辑说明:RobustScaler使用中位数和四分位距(IQR)做中心化和缩放,对离群点不敏感。quantile_range默认就是 25 到 75,不需要额外改。注意只能用训练集fit,然后用同一缩放器转换测试集;如果分别 fit 训练集和测试集,两个数据集的分布尺度不一致,等于人为引入特征偏移。

参数说明:如果后续要解释模型特征重要性,建议保留特征名列表feature_cols,不要转成 numpy 数组就丢了列名。可视化的时候会用得上。

3.3 特征选择:别把 41 维全塞进去,先看相关性

NSL-KDD 里有不少特征是冗余的,比如num_outbound_cmds恒为 0,srv_serror_rate和serror_rate高度相关。直接全量训练不是不行,但会引入噪声,增加过拟合风险。我一般先用随机森林跑一遍,输出特征重要性,再手工筛掉重要性垫底的特征。

from sklearn.ensemble import RandomForestClassifier rf = RandomForestClassifier(n_estimators=100, random_state=42, n_jobs=-1) rf.fit(X_train_num, train_df["binary_label"]) imp_df = pd.DataFrame({"feature": feature_cols, "importance": rf.feature_importances_}) imp_df = imp_df.sort_values("importance", ascending=False) print(imp_df.head(20))

逻辑说明:随机森林的特征重要性基于平均不纯度减少,虽然不能严格代表因果重要性,但用来筛特征足够。你会发现dst_host_srv_serror_rate、dst_host_same_srv_rate、count这类基于连接统计的特征排名很靠前,而urgent、num_outbound_cmds、num_shells这类稀疏特征基本没贡献。

参数说明:选 100 棵树是速度和稳定性的折中;n_jobs=-1表示用满 CPU 核。跑完看输出,如果某些特征重要性为 0,下一轮训练直接删掉。

4. 模型选型与训练评估:从随机森林到神经网络的落地对比

4.1 先拿随机森林打底,再看 XGBoost 和 MLP

入侵检测场景里,模型的选择要同时考虑精度、训练速度、推理速度和可解释性。传统的 SVM 在 NSL-KDD 上效果不错,但数据量大时训练极慢,不适合实时检测。随机森林是性价比最高的起点,XGBoost 在精度上略胜但调参复杂度高,MLP 能捕捉非线性关系但需要更多数据。

下面给出一段完整的训练与评估代码,用随机森林作为基线:

from sklearn.metrics import classification_report, confusion_matrix train_labels = train_df["binary_label"].values test_labels = test_df["binary_label"].values rf_model = RandomForestClassifier( n_estimators=200, max_depth=20, min_samples_split=5, min_samples_leaf=2, class_weight="balanced", random_state=42, n_jobs=-1 ) rf_model.fit(X_train_num, train_labels) y_pred = rf_model.predict(X_test_num) print(classification_report(test_labels, y_pred, target_names=["normal", "attack"])) print(confusion_matrix(test_labels, y_pred))

逻辑说明:这里的class_weight="balanced"是应对攻击样本和正常样本数量不平衡的关键参数。NSL-KDD 测试集里攻击样本占比不低,但如果换到真实场景,正常流量占绝大多数,不平衡会更严重,这个参数能防止模型把所有样本都判成正常。max_depth=20限制了单棵树深度,防止过拟合;min_samples_split=5和min_samples_leaf=2是控制分裂门槛的常用值。

参数说明:classification_report里重点关注recall和f1-score。入侵检测场景里,漏报(把攻击当正常)比误报更危险,所以 attack 类的 recall 应该尽可能高。如果 recall 低,说明模型倾向保守,需要调整阈值或增大攻击样本权重。

4.2 阈值调优:默认 0.5 的决策边界不是最优解

随机森林输出的是概率,默认按 0.5 划分类别。在入侵检测里,我们希望“宁错杀、不漏报”,所以通常要把正类(attack)的判定阈值降低,比如 0.3。这样做的代价是误报率上升,需要通过告警聚合和人工确认来消化。

调阈值的方法有两种:

from sklearn.metrics import precision_recall_curve # 方法一:根据 PR 曲线找拐点 probs = rf_model.predict_proba(X_test_num)[:, 1] precision, recall, thresholds = precision_recall_curve(test_labels, probs) # 找到满足 recall >= 0.95 的最高 precision 阈值 valid_idx = [i for i, r in enumerate(recall) if r >= 0.95] best_threshold = thresholds[valid_idx[-1]] print("best_threshold:", best_threshold) # 方法二:简单粗调 custom_threshold = 0.3 y_pred_custom = (probs >= custom_threshold).astype(int)

逻辑说明:precision_recall_curve返回的thresholds是决策阈值序列,valid_idx[-1]对应满足 recall 条件下 precision 最高的点(注意 recall 和 precision 随阈值变化方向相反)。粗调 0.3 是安全经验值,但最好还是根据 PR 曲线定量选。

参数说明:阈值只影响预测阶段,不影响模型权重。生产环境中阈值应作为配置项暴露出来,方便安全团队根据每天的告警量动态调整。

4.3 用 XGBoost 替换随机森林:三行代码的提升

如果基线模型已经能满足需求,不必盲目上 XGBoost。但如果要追求更高精度,XGBoost 是常见选择。它的优势是内置正则化、支持自定义目标函数、训练速度快。迁移代码非常简单:

from xgboost import XGBClassifier xgb_model = XGBClassifier( n_estimators=300, max_depth=6, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, scale_pos_weight=3.0, eval_metric="aucpr", random_state=42 ) xgb_model.fit(X_train_num, train_labels) y_pred_xgb = xgb_model.predict(X_test_num) print(classification_report(test_labels, y_pred_xgb))

逻辑说明:scale_pos_weight=3.0是处理正负样本不平衡的核心参数,它相当于给正类样本的梯度乘以权重。这个值可以粗略按“负样本数/正样本数”的比例来设,再根据结果微调。subsample和colsample_bytree都是防止过拟合的随机采样参数,对流量这类高维表格数据很有用。

参数说明:eval_metric="aucpr"是专门针对不平衡分类的评估指标,比默认的logloss更贴近入侵检测诉求。训练时可以把early_stopping_rounds=50加到fit的eval_set里,但这里为简化代码省略了;实际跑大数据集时建议加上。

4.4 深度学习方案:MLP 与 CNN 的适用边界

神经网络在入侵检测上被大量论文验证过,但落地时要冷静看待。MLP(多层感知机)在 NSL-KDD 这种特征已经高度工程化的数据上,和 XGBoost 差距不大;CNN 则需要把 1D 流量特征重排成二维矩阵,更多是为了在论文里讲故事,实际收益有限。如果你的场景是原始流量字节级别的检测,那才轮到 CNN 或 LSTM 出场。

一个可用的 MLP 实现:

from sklearn.neural_network import MLPClassifier mlp_model = MLPClassifier( hidden_layer_sizes=(64, 32), activation="relu", solver="adam", batch_size=256, max_iter=50, early_stopping=True, random_state=42 ) mlp_model.fit(X_train_num, train_labels)

逻辑说明:hidden_layer_sizes=(64, 32)表示两层隐藏层,第一层 64 个神经元、第二层 32 个。这个配置对 41 维特征足够,过大的网络容易过拟合。early_stopping=True会在验证集 loss 不再下降时自动停止训练,防止跑太久浪费算力。

参数说明:MLP 对特征缩放敏感,所以必须先用前面提到的RobustScaler处理数据。如果发现训练不收敛,优先调learning_rate_init,默认 0.001 对中小数据集通常够用。

5. 避坑和排查:入侵检测项目里最常见的 5 个翻车现场

5.1 测试集里出现未知类别导致 LabelEncoder 报错

现象:执行le.transform(test_df[col])时抛出ValueError: y contains previously unseen labels。

原因:训练集和测试集的类别分布不一致,测试集里出现了训练集没有的service值(NSL-KDD 里测试集比训练集多出一些服务类型)。

解决:先pd.concat合并两个数据集的该列,再统一fit,然后分别transform。这是前面 3.1 节代码里强调过的点,实际最容易漏。

5.2 模型在测试集上 recall 很高,但真实流量里疯狂误报

现象:NSL-KDD 测试集 F1 值不错,一接真实流量,每分钟上百条误报告警。

原因:公开数据集特征分布和真实流量差异很大。NSL-KDD 只有 41 个离散统计特征,真实流量里还有 TLS 指纹、DNS 请求模式、时间窗口滑动统计等;模型学到的是数据集特有的分布,不是攻击本质。

解决:先用公开数据集做预研,上线前必须用真实流量重采样标注和微调。没有捷径,这是入侵检测项目里最大的“黑匣子”。

5.3 数值特征里有无穷大或 NaN,导致训练直接失败

现象:fit时报Input contains NaN,或者某些模型训练过程 loss 变成 NaN。

原因:数据下载不完整、CSV 解析错位、或者某些字段为空。NSL-KDD 原始数据虽然干净,但转格式时可能出现空行。

解决:训练前做一次全量检查,用train_df.isnull().sum()查看,对 NaN 行直接删除或用中位数填充。建议删除而不是填充,因为流量特征里的 NaN 通常代表采集异常,填充会造成误导。

5.4 用 accuracy 评估模型,被不平衡数据“骗”了

现象:打印 accuracy 高达 99%,但查混淆矩阵发现攻击样本几乎全被漏掉。

原因:正常样本占比远大于攻击样本,模型全判正常也能拿高 accuracy。

解决:只看recall、precision、f1-score和aucpr。安全场景的目标函数应该是“在可接受的误报率下最大化召回率”,accuracy 只能作为参考。这也是为什么 4.2 节要专门做阈值调优。

5.5 把时序特征当成普通特征,导致未来信息泄漏

现象:模型在离线测试集上效果极好,上线后效果崩盘。

原因:入侵检测的流量是时间序列。如果把count、srv_count这类特征直接整表训练,测试集里可能包含了与训练集时段重叠的统计信息,造成数据泄漏。换言之,模型可能不是在学攻击模式,而是在学时间片。

解决:按时间切分训练集和测试集,而不是随机切分。在真实场景里,训练数据应该来自历史时间段,测试数据来自未来时间段,并且滑动窗口的统计只能基于过去时刻。这是实践中最容易被忽略的“玄学”,但解释起来又很朴素。

6. 落地为实时检测:把模型包装成一个可用的告警服务

6.1 特征提取器:从原始网络流到模型输入的管线设计

训练完模型只是第一步,真正要落地,需要把在线采集的流量包转换成模型能吃的特征向量。常见做法是用scapy抓包并提取会话特征,或者用nfstream这类流特征提取库。这里我用nfstream演示:

from nfstream import NFStreamer streamer = NFStreamer( source="eth0", statistical_features=True, idle_timeout=30, active_timeout=300 ) for flow in streamer: features = [ flow.duration, flow.protocol, flow.src_port, flow.dst_port, flow.bidirectional_packets, flow.bidirectional_bytes, flow.bidirectional_duration_ms, flow.bidirectional_mean_packet_size, flow.bidirectional_stddev_packet_size, flow.bidirectional_max_packet_size ] # 拼上其他统计特征,与训练时的特征列顺序保持一致

逻辑说明:nfstream自动按五元组聚合流,并计算双向统计特征。idle_timeout=30表示流空闲 30 秒后判定结束,active_timeout=300表示流最长 300 秒主动截断。这样提取出的 flow 特征,虽然和 NSL-KDD 的 41 维不完全一致,但结构相似,你可以用自己的模型重新训练匹配特征维度。

参数说明:真实场景的特征提取器必须输出与训练时完全一致的特征顺序和数量,差一列模型就会报错。建议把特征名称列表保存成 JSON 文件,在线推理时加载校验。

6.2 一个轻量推理接口:基于 Flask 的 HTTP 告警服务

把模型封装成服务,安全团队只需调用 HTTP 接口就能检测单条流记录。Flask 足够轻量:

from flask import Flask, request, jsonify import joblib import numpy as np app = Flask(__name__) model = joblib.load("ids_model.joblib") scaler = joblib.load("scaler.joblib") feature_cols = joblib.load("feature_cols.joblib") @app.route("/predict", methods=["POST"]) def predict(): data = request.get_json() input_vector = np.array([data.get(col, 0.0) for col in feature_cols]).reshape(1, -1) scaled = scaler.transform(input_vector) prob = model.predict_proba(scaled)[0, 1] if prob >= 0.3: return jsonify({"alert": True, "score": prob}) return jsonify({"alert": False, "score": prob}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)

逻辑说明:joblib用来序列化和加载训练好的模型与缩放器,比 pickle 对 numpy 数组的兼容性更好。接口里取特征时用了data.get(col, 0.0),这样即使请求方漏传某个字段,也不会直接抛异常而是默认为 0,保证接口鲁棒性。阈值 0.3 是前面调出来的经验值,实际应该做成环境变量或配置项。

参数说明:生产环境不要用 Flask 自带的开发服务器,用 gunicorn 或 uWSGI 启动。另外要限流和加鉴权,否则检测接口会被打爆。

6.3 验证与回放:用历史流量验证模型在时间维度的稳定性

模型上线前,一定要做一次回放验证。方法是拿过去一周的真实流量 pcap 文件,用特征提取器跑一遍,生成特征 CSV,再用模型预测,然后把告警和实际安全事件做对比。这一步能发现两个隐患:一是模型对某些正常业务流量(比如数据库备份)误报严重;二是模型对新型攻击变种召回率下降。

我的习惯是每周做一次离线评估,记录召回率和误报率变化趋势。如果误报率连续升高,先查是不是业务流量模式变了,再查是不是模型需要增量训练。这个动作相当于给模型上“后悔药”,避免上线即翻车。

6.4 最后一点:模型与规则引擎不是替代关系

实践下来,最稳的方案是“规则引擎 + 机器学习”双层结构:规则负责高置信度的已知攻击,ML 负责低置信度的未知异常。ML 告警不要直接封禁 IP,而是进入二级研判队列。这样既保留规则的可解释性,又发挥机器学习的泛化能力。希望这套从数据到服务的流程能帮你在自己的环境里顺利跑通。

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

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

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

立即咨询