简介:这份资源围绕KDD-CUP99数据集,用KNN、高斯贝叶斯、BP神经网络以及决策树四种方法实现入侵检测,适合想入门机器学习分类实战的小白或进阶学习者,也可作为毕设、课程设计、大作业或工程实训的参考方案。压缩包共51个文件,约3.41MB,包含6个Python源码文件、4个pkl与2个pth模型文件、1个csv数据集,以及23张png结果图和9个html可视化页面,覆盖训练、评估与结果展示的完整流程。作者从原始数据中抽取约8万条数据包,测试集占20%,交叉验证折数设为5,并分别给出二分类与五分类下的准确率、混淆矩阵、ROC与P-R曲线等分析,KNN还对比了K=3至15的多种取值。目前已有93人学习,读者可借此理解不同分类器在入侵检测任务上的表现差异,掌握数据预处理、模型训练与性能评价的完整思路。
1. 入侵检测这件事,为什么我劝你先别急着上深度学习
入侵检测(Intrusion Detection)这个词听起来很安全圈,但落到工程上,它本质上就是一个二分类或多分类问题:给你一条网络连接记录,判断它是正常流量还是某种攻击。KDD-CUP99 是这个领域最经典的公开数据集,1999 年构建,至今仍是很多课程设计和入门项目的首选。但我在实际带人做这个方向时发现一个反直觉的现象:很多人一上来就堆神经网络,结果被 KNN 和朴素贝叶斯按在地上摩擦。
这不是说神经网络不行,而是 KDD-CUP99 这个数据集有它的特殊性——特征维度高、类别极不平衡、大量符号型特征需要编码。如果你不先把数据摸清楚,直接套一个全连接网络,大概率得到的是一个在正常流量上准确率 99%、在 U2R 和 R2L 攻击上召回率接近 0 的“废模型”。这篇文章我会把贝叶斯、KNN、神经网络三条路线在同一个数据集上的完整落地流程拆开讲,包括数据预处理、特征工程、模型调参、评估指标选择,以及我在实际跑的时候踩过的那些坑。适合已经了解机器学习基础、想把这个经典数据集真正跑出可用结果的人。
2. 先把 KDD-CUP99 读明白:41 维特征里藏着什么
2.1 数据集结构与三个容易忽略的细节
KDD-CUP99 的每条记录包含 41 个特征,外加一个标签。这 41 维可以分成四组:TCP 连接的基本特征(如 duration、protocol_type、service、flag)、连接的内容特征(如 hot、num_failed_logins、logged_in)、基于时间的流量统计特征(如 count、srv_count、serror_rate)、基于主机的流量统计特征(如 dst_host_count、dst_host_srv_count)。标签分为五大类:Normal、DoS、Probe、R2L、U2R。
三个容易忽略的细节:第一,protocol_type、service、flag 是字符串类型,必须编码;第二,标签在原始数据中是以攻击名称细分的,比如 neptune、smurf 都属于 DoS,你需要自己做映射;第三,训练集和测试集的攻击类型分布不一样,测试集里有一些训练集没出现过的攻击子类,这是 KDD-CUP99 故意设计的,用来测试模型的泛化能力。
我一般会先用 pandas 做一次快速探查,确认列名、缺失值和标签分布。原始数据没有表头,需要手动指定列名。
import pandas as pd import numpy as np # KDD-CUP99 的 41 个特征列名 + 标签列 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' ] train = pd.read_csv('KDDTrain+.txt', names=col_names) test = pd.read_csv('KDDTest+.txt', names=col_names) print(train.shape, test.shape) print(train['label'].value_counts().head(15))这段代码做了三件事:加载训练集和测试集、指定列名、查看标签分布。注意num_outbound_cmds这一列在 KDD-CUP99 中全部为 0,可以直接删掉,它不携带任何信息。另外is_host_login和is_guest_login也几乎全是 0,但保留它们不会有害。
2.2 标签映射与攻击大类合并
原始标签有 23 种左右,直接做多分类会导致某些类别样本极少,模型根本学不动。常见做法是合并成五大类,或者进一步合并成二分类(Normal vs Attack)。我建议先做五分类,再根据业务需求决定是否降为二分类。
# 攻击类型到大类的映射 attack_map = { 'normal': 'Normal', # DoS 'back': 'DoS', 'land': 'DoS', 'neptune': 'DoS', 'pod': 'DoS', 'smurf': 'DoS', 'teardrop': 'DoS', 'apache2': 'DoS', 'udpstorm': 'DoS', 'processtable': 'DoS', 'worm': 'DoS', # Probe 'satan': 'Probe', 'ipsweep': 'Probe', 'nmap': 'Probe', 'portsweep': 'Probe', 'mscan': 'Probe', 'saint': 'Probe', # R2L 'guess_passwd': 'R2L', 'ftp_write': 'R2L', 'imap': 'R2L', 'phf': 'R2L', 'multihop': 'R2L', 'warezmaster': 'R2L', 'warezclient': 'R2L', 'spy': 'R2L', 'xlock': 'R2L', 'xsnoop': 'R2L', 'snmpguess': 'R2L', 'snmpgetattack': 'R2L', 'httptunnel': 'R2L', 'sendmail': 'R2L', 'named': 'R2L', # U2R 'buffer_overflow': 'U2R', 'loadmodule': 'U2R', 'rootkit': 'U2R', 'perl': 'U2R', 'sqlattack': 'U2R', 'xterm': 'U2R', 'ps': 'U2R' } train['attack_type'] = train['label'].map(attack_map) test['attack_type'] = test['label'].map(attack_map) # 检查是否有未映射的标签 print(train['attack_type'].isna().sum(), test['attack_type'].isna().sum()) print(train['attack_type'].value_counts())映射完成后一定要检查isna()的数量,如果有未映射的标签说明你的字典漏了某些攻击名称。训练集中 R2L 和 U2R 的样本量非常少,U2R 可能只有几十条,这是后面模型评估时必须注意的问题。
2.3 符号特征编码与数值特征标准化
protocol_type、service、flag三列是字符串,需要编码。常见做法有两种:LabelEncoder 和 OneHotEncoder。service有 70 多个取值,OneHot 会产生大量稀疏列,我一般对protocol_type和flag用 OneHot,对service用 LabelEncoder 或者按频率分组后再 OneHot。
from sklearn.preprocessing import LabelEncoder, StandardScaler # 对 protocol_type 和 flag 做 OneHot train = pd.get_dummies(train, columns=['protocol_type', 'flag']) test = pd.get_dummies(test, columns=['protocol_type', 'flag']) # 对齐训练集和测试集的列 train, test = train.align(test, join='left', axis=1, fill_value=0) # 对 service 做 LabelEncoder le = LabelEncoder() train['service'] = le.fit_transform(train['service']) # 测试集可能出现训练集没有的 service,用 transform 会报错,需要处理 test['service'] = test['service'].map( lambda x: le.transform([x])[0] if x in le.classes_ else -1 ) # 数值特征标准化 num_cols = train.select_dtypes(include=[np.number]).columns.drop(['label', 'attack_type'], errors='ignore') scaler = StandardScaler() train[num_cols] = scaler.fit_transform(train[num_cols]) test[num_cols] = scaler.transform(test[num_cols])这里有个坑:pd.get_dummies之后训练集和测试集的列可能不一致,必须用align对齐。另外测试集里可能出现训练集没见过的service取值,直接用transform会抛异常,需要手动处理成 -1 或单独归为一类。标准化时注意label和attack_type列不能参与,否则会报类型错误。
3. 三条路线怎么选:朴素贝叶斯、KNN、神经网络的真实对比
3.1 朴素贝叶斯的假设与在 KDD-CUP99 上的表现
朴素贝叶斯的核心假设是特征条件独立,这在 KDD-CUP99 上显然不成立——src_bytes和dst_bytes明显相关,count和srv_count也高度相关。但有趣的是,即使假设不成立,朴素贝叶斯在文本分类和高维稀疏数据上仍然表现不错。在 KDD-CUP99 上,高斯朴素贝叶斯(假设连续特征服从正态分布)的训练速度极快,适合做 baseline。
from sklearn.naive_bayes import GaussianNB from sklearn.metrics import classification_report, confusion_matrix gnb = GaussianNB() gnb.fit(train[num_cols], train['attack_type']) y_pred_nb = gnb.predict(test[num_cols]) print(classification_report(test['attack_type'], y_pred_nb)) print(confusion_matrix(test['attack_type'], y_pred_nb))高斯朴素贝叶斯不需要调参,直接 fit 就行。但你会看到它在 U2R 和 R2L 上的召回率非常低,因为这两类样本太少,先验概率被 Normal 和 DoS 压制。如果业务上更关心攻击召回率,可以通过class_prior参数手动设置先验,或者对少数类做上采样。
3.2 KNN 的距离度量与 K 值选择
KNN 在 KDD-CUP99 上的表现往往出人意料地好,尤其是经过标准化之后。它的核心问题是K 值怎么选和距离度量怎么定。默认的欧氏距离在高维空间会失效(维度灾难),但 KDD-CUP99 经过特征选择后通常保留 15-20 维,欧氏距离还能用。我一般会用一个简单的循环扫一遍 K 值。
from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import f1_score best_k, best_f1 = 1, 0 for k in [1, 3, 5, 7, 9, 11, 15, 21]: knn = KNeighborsClassifier(n_neighbors=k, n_jobs=-1) knn.fit(train[num_cols], train['attack_type']) pred = knn.predict(test[num_cols]) f1 = f1_score(test['attack_type'], pred, average='macro') print(f'K={k}, macro-F1={f1:.4f}') if f1 > best_f1: best_k, best_f1 = k, f1 print(f'Best K: {best_k}')注意这里用的是macro-F1而不是 accuracy。因为 KDD-CUP99 类别极不平衡,accuracy 会被 Normal 和 DoS 主导,macro-F1 能更好反映少数类的表现。KNN 的另一个坑是预测速度慢,测试集有 2 万多条,K=1 时每条都要算距离,实际部署时需要考虑用 KD-Tree 或 Ball-Tree 加速,或者做原型选择(prototype selection)压缩训练集。
3.3 神经网络:不是越深越好,而是特征工程先做好
神经网络在 KDD-CUP99 上要跑出好结果,关键不在网络结构,而在输入特征的质量。我见过太多人直接把 41 维原始特征丢进一个 3 层 MLP,结果 U2R 召回率为 0。正确的做法是:先做特征选择(用随机森林或卡方检验筛掉冗余特征),再对少数类做 SMOTE 过采样,最后用一个浅层网络(2-3 层)就够了。
import torch import torch.nn as nn from sklearn.model_selection import train_test_split from imblearn.over_sampling import SMOTE # 特征选择:用随机森林的 feature_importances_ 筛前 20 维 from sklearn.ensemble import RandomForestClassifier rf = RandomForestClassifier(n_estimators=100, n_jobs=-1, random_state=42) rf.fit(train[num_cols], train['attack_type']) importances = pd.Series(rf.feature_importances_, index=num_cols) top_cols = importances.nlargest(20).index.tolist() # 对训练集做 SMOTE 过采样 smote = SMOTE(random_state=42) X_res, y_res = smote.fit_resample(train[top_cols], train['attack_type']) # 标签编码 from sklearn.preprocessing import LabelEncoder le_nn = LabelEncoder() y_res_enc = le_nn.fit_transform(y_res) y_test_enc = le_nn.transform(test['attack_type']) # 转 tensor X_tensor = torch.FloatTensor(X_res.values) y_tensor = torch.LongTensor(y_res_enc) X_test_tensor = torch.FloatTensor(test[top_cols].values) # 定义浅层 MLP class IDSModel(nn.Module): def __init__(self, input_dim, num_classes): super().__init__() self.net = nn.Sequential( nn.Linear(input_dim, 64), nn.ReLU(), nn.Dropout(0.3), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, num_classes) ) def forward(self, x): return self.net(x) model = IDSModel(len(top_cols), len(le_nn.classes_)) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() # 训练循环 for epoch in range(50): model.train() optimizer.zero_grad() out = model(X_tensor) loss = criterion(out, y_tensor) loss.backward() optimizer.step() if (epoch + 1) % 10 == 0: print(f'Epoch {epoch+1}, Loss: {loss.item():.4f}')这段代码的关键点:先做特征选择再过采样,顺序不能反,否则 SMOTE 会在冗余特征上生成噪声;SMOTE 只在训练集上做,测试集保持原始分布,否则评估结果会虚高;网络层数控制在 3 层以内,KDD-CUP99 的样本量(训练集约 12 万条)撑不起太深的网络,反而容易过拟合。
4. 避坑指南:我在 KDD-CUP99 上翻过的五次车
4.1 坑一:用 accuracy 评估,模型看起来 99% 其实没用
现象:模型在测试集上 accuracy 达到 99.2%,但实际部署后发现 U2R 攻击一条都检测不出来。
原因:KDD-CUP99 测试集中 Normal 和 DoS 占比超过 95%,U2R 只有几十条。模型只要把所有样本都预测成 Normal 或 DoS,accuracy 就能很高。这是典型的类别不平衡陷阱。
解决:改用 macro-F1 或 per-class recall 作为主要评估指标。如果业务上对漏报攻击的容忍度低,还要看 U2R 和 R2L 的召回率。我一般会同时打印 classification_report 和混淆矩阵,确认每个类的表现。
4.2 坑二:测试集出现训练集没有的 service 取值,编码直接报错
现象:用 LabelEncoder 对service编码后,在测试集上transform抛出ValueError: y contains previously unseen labels。
原因:KDD-CUP99 的测试集故意包含了一些训练集没有的攻击类型和 service 取值,这是为了测试泛化能力。LabelEncoder 的transform方法遇到未知类别会直接报错。
解决:用map加条件判断处理未知类别,归为 -1 或单独一类。或者改用 OneHotEncoder 的handle_unknown='ignore'参数。我一般会在编码前先检查测试集的service取值集合是否被训练集覆盖。
4.3 坑三:SMOTE 过采样后再标准化,数据分布被破坏
现象:先做 SMOTE 再标准化,模型在验证集上的表现波动很大,每次运行结果差异明显。
原因:SMOTE 是基于距离生成新样本的,如果特征没有标准化,量纲大的特征会主导距离计算,生成的样本质量很差。而且 SMOTE 后再标准化,新样本的统计特性会和原始数据不一致。
解决:严格按「先标准化 → 再 SMOTE → 再训练」的顺序。标准化时用训练集的均值和方差,测试集用同样的参数 transform。SMOTE 只在训练集上做,且要在标准化之后。
4.4 坑四:KNN 的 K 值设为 1,训练集噪声直接导致误报
现象:KNN 在 K=1 时训练集准确率 100%,但测试集上误报率很高,正常流量被大量判为攻击。
原因:K=1 时模型完全拟合训练集,包括噪声和异常点。KDD-CUP99 的训练集本身就有标注错误和重复样本,K=1 会把这些错误直接带到预测中。
解决:K 值至少设为 3,一般 5-7 比较合适。同时可以配合距离加权(weights='distance'),让近邻的投票权重更高。如果训练集很大,考虑先做原型选择压缩训练集,去掉冗余样本。
4.5 坑五:神经网络学习率设太大,loss 震荡不收敛
现象:训练时 loss 在前几个 epoch 下降,然后开始剧烈震荡,甚至变成 NaN。
原因:Adam 的默认学习率 1e-3 对某些初始化比较敏感,尤其是输入特征没有做标准化或者存在极端值时,梯度爆炸会导致 loss 发散。
解决:先把学习率降到 1e-4 试试,同时加梯度裁剪(torch.nn.utils.clip_grad_norm_)。另外检查输入特征是否有 inf 或 nan,标准化之前先做np.nan_to_num处理。如果还是震荡,换用 SGD + momentum 往往更稳定。
5. 把模型推到可用:阈值调优与集成技巧
前面三条路线各自跑通之后,真正决定能不能落地的是后处理。我一般会做两件事:一是对神经网络和 KNN 的输出概率做阈值调优,二是把三个模型的预测结果做投票集成。
先看阈值调优。以神经网络为例,模型输出的是每个类的 softmax 概率,默认取 argmax。但如果你更关心 U2R 的召回率,可以手动降低 U2R 的判定阈值。
model.eval() with torch.no_grad(): logits = model(X_test_tensor) probs = torch.softmax(logits, dim=1).numpy() # 默认 argmax pred_default = probs.argmax(axis=1) # 手动调阈值:U2R 的索引假设是 3,降低它的判定门槛 u2r_idx = list(le_nn.classes_).index('U2R') adjusted = probs.copy() adjusted[:, u2r_idx] *= 1.5 # 放大 U2R 概率 pred_adjusted = adjusted.argmax(axis=1) from sklearn.metrics import recall_score print('Default U2R recall:', recall_score(y_test_enc, pred_default, labels=[u2r_idx], average='macro')) print('Adjusted U2R recall:', recall_score(y_test_enc, pred_adjusted, labels=[u2r_idx], average='macro'))这个放大系数需要根据验证集上的 precision-recall 曲线来定,一般从 1.2 开始试,观察 U2R 召回率提升和整体误报率之间的权衡。如果业务上误报成本很高,就不要动阈值,宁可漏报。
再说集成。三个模型的预测结果可以做硬投票或软投票。硬投票是每个模型投一票,取多数;软投票是平均概率后取 argmax。我一般用软投票,因为概率包含了更多信息。
from sklearn.ensemble import VotingClassifier # 重新用 sklearn 接口包装三个模型 gnb_sk = GaussianNB() knn_sk = KNeighborsClassifier(n_neighbors=5, n_jobs=-1) # 神经网络用 sklearn 的 MLPClassifier 替代,方便集成 from sklearn.neural_network import MLPClassifier mlp_sk = MLPClassifier(hidden_layer_sizes=(64, 32), max_iter=200, random_state=42) voting = VotingClassifier( estimators=[('nb', gnb_sk), ('knn', knn_sk), ('mlp', mlp_sk)], voting='soft' ) voting.fit(train[top_cols], train['attack_type']) y_pred_vote = voting.predict(test[top_cols]) print(classification_report(test['attack_type'], y_pred_vote))软投票要求每个模型都能输出概率,GaussianNB、KNN、MLPClassifier 都支持predict_proba。集成的效果通常比单模型好 1-3 个百分点,尤其是在少数类上。但要注意,如果某个模型在某个类上表现特别差,软投票会被它拖累,这时候可以考虑加权投票,给表现好的模型更高权重。
最后一个技巧:保存预处理管道。实际部署时,你不能每次预测都重新 fit 标准化器和编码器,必须把scaler、le、top_cols一起保存下来。
import joblib joblib.dump({ 'scaler': scaler, 'label_encoder': le, 'top_cols': top_cols, 'model': voting }, 'ids_pipeline.pkl') # 加载时 pipeline = joblib.load('ids_pipeline.pkl')这个习惯是我踩了无数次坑之后养成的——训练时用一套预处理,部署时忘了同步,结果线上预测结果和离线评估完全对不上。把预处理和模型打包成一个文件,能省掉很多排查时间。希望帮到你。
本文还有配套的精品资源,点击获取