NSL-KDD入侵检测全流程复现:特征工程、PCA降维与模型评估
2026/9/15 2:10:21 网站建设 项目流程

简介:这是基于NSL-KDD数据集的网络入侵检测完整项目,曾获导师认可并以98分通过评审,适合计算机专业正在做毕业设计、课程设计或期末大作业的学生,也适合需要项目实战练习的入门学习者。资源包共27个文件,包含10个CSV数据文件、4个Notebook建模分析脚本、7个文本说明、3个MATLAB脚本,以及Python工具脚本、Markdown运行说明和Word文档;NSL-KDD原始数据与修正数据、PCA与无PCA对比建模均已收录,覆盖数据读取、特征降维、模型训练和结果评估等环节。压缩包约30MB,目录按工程功能组织,便于按模块查找和复用。目前已有312人学习下载。借助运行说明与配套脚本,可快速复现入侵检测流程,并在此基础上调整特征或模型,用于自己的实验设计、毕业论文和答辩展示。

1. 从一道期末大作业到可复现的NSL-KDD入侵检测流程

如果你在课程设计或毕业设计里被要求“做一个网络入侵检测系统”,多半会碰到NSL-KDD数据集。它比老KDD99少了很多冗余记录,训练集和测试集分布也更合理,是拿来做分类实验最省心的公开数据集。这份高分大作业的价值不在于准确率刷到多高,而是把“原始csv -> 特征工程 -> PCA降维 -> 模型训练 -> GUI预测单条连接”整条链路完整跑通,并且同时给了带PCA和不带PCA两套模型文件,可以在本地直接加载验证。我基于解压后的目录结构重新梳理了一遍执行流程,把关键脚本的读取逻辑、数据清洗方式、模型评估方法和GUI继承方式逐段拆开,适合需要复现、改造或写进论文/报告的场景。下面按实际做实验的顺序展开,读完之后你至少能回答三个问题:NSL-KDD里每列特征是什么、为什么要先做One-Hot再标准化、以及为什么某些攻击类别在测试集上掉点严重。

2. NSL-KDD数据集结构与特征编码原理

2.1 41维特征到底在描述什么

NSL-KDD的每条记录由41个特征加1个标签构成,这41列可以按语义归成四组:TCP连接基本属性(duration、protocol_type、service、flag等)、基于内容的特征(如hot、num_failed_logins、su_attempted)、基于时间的流量特征(same_srv_rate、diff_srv_rate等)、基于主机的流量特征(dst_host_count、dst_host_srv_count等)。这些特征里有连续值、离散值、还有二进制标记,直接喂给模型前必须做类型区分。

拿项目里的kddcup_data.csv举例,文件头长这样:

$ head -5 kddcup_data.csv 0,tcp,http,SF,181,5450,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,8,8,0.00,0.00,0.00,0.00,1.00,0.00,0.00,9,9,1.00,0.00,0.10,0.00,0.00,0.00,0.00,0.00,normal 0,udp,private,SF,44,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,105,146,0.00,0.00,0.00,0.52,0.00,0.00,0.00,255,255,1.00,0.00,0.00,0.00,0.00,0.00,0.00,neptune

第一列是duration,第二列是protocol_type,第三列是service,第四列是flag。后面跟着的连续值如src_bytes、dst_bytes,以及以srv_count开头的统计特征,都属于数值型。标签在最后一列,不是纯normalattack,而是具体的攻击名称比如neptunesatanipsweep,这一点会影响多分类和二分类的处理逻辑。

2.2 为什么不能直接标准化离散特征

机器学习库里的StandardScaler会把每一列变成均值0、方差1,但如果直接对protocol_type这样的字符串编码做标准化,等于给“tcp=1, udp=2, icmp=3”强加了大小关系,模型会认为udp比tcp“大”两倍。NSL-KDD里protocol_type只有三种值,service有70种左右,flag有11种,合理做法是先对这三列做One-Hot编码。

One-Hot之后特征维度会从41膨胀到接近120维。这就是项目里model_with_pca.ipynbmodel_no_pca.ipynb两套流程的出发点:model_with_pca在One-Hot之后用PCA降维,model_no_pca保持One-Hot维度直接训模型。项目里pca_model.m是matlab格式的PCA降维矩阵,IDS_model_8-0.m是带PCA的模型,NO_PCA_IDS_model.m是带One-Hot但不降维的模型。我解开压缩包后发现这两种模型文件都是mat格式,所以Python端读取时要用scipy.io.loadmat,不是joblibpickle,这点很多第一次用的人会卡住。

一个经常被忽略的点是测试集的编码必须和训练集完全一致。训练集里service出现的类别集合是固定的,测试集里可能多出没见过的service值(KDDCup99的经典坑,NSL-KDD已经改善了)。处理策略是把训练集和测试集拼在一起先get_dummies,保证列数一致;如果只想用训练集编码,遇到未知类别就直接填0。我常用写法是:

all_data = pd.concat([train, test], keys=['train', 'test']) encoded = pd.get_dummies(all_data, columns=['protocol_type', 'service', 'flag']) train_enc = encoded.loc['train'] test_enc = encoded.loc['test']

这样保证两边One-Hot后对齐列。注意get_dummies默认会把所有object类型的列都转换,所以提前把标签列拿出来,只对特征部分做编码。

2.3 标签体系的取舍:二分类还是多分类

NSL-KDD测试集的攻击类型比训练集多出一些变种,比如processtablemscan这类训练集没出现的攻击。如果做23分类,测试集里新攻击类别会直接导致模型预测时找不到目标类。常见做法有两个:一是把标签按四大类映射成DoSProbeR2LU2Rnormal,做5分类;二是只区分normalattack做2分类。项目里GUI显示的是二分类结果,训练脚本里同时保留了多分类评估,因为报告里需要各类别的精确率召回率表。

我通常这样构造二分类标签:

y_train_bin = (train_label != 'normal').astype(int) y_test_bin = (test_label != 'normal').astype(int)

如果是多分类,要提前把训练集和测试集标签统一成同一套类别集合,不能直接LabelEncoder,因为测试集会报“标签类别不存在”。稳妥做法是训练一个LabelEncoder后,对测试集做transform时用handle_unknown='ignore',但LabelEncoder不支持这个参数,所以我更喜欢手动建立映射字典,或者用pd.Categorical来统一。

3. Python源码读取与特征工程完整复现

3.1 用pandas高效读取kddcup_data.csv

项目根目录下的read_kddcup99.py是数据读取入口,核心逻辑并不复杂。NSL-KDD原始CSV没有表头,所以读取时要么手动指定列名,要么用header=None然后按位置索引。我推荐手动指定41个列名,因为后续做特征筛选和可视化时,列名比位置好维护得多。

import pandas as pd import numpy as np column_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' ] df = pd.read_csv('data/kddcup_data.csv', names=column_names, header=None)

读取后建议立刻检查durationsrc_bytes这些列是否有异常值。NSL-KDD里num_outbound_cmds这一列全是0,对模型没有区分能力,可以在预处理时直接删掉。su_attempted在原始训练集里有2这个非0/1值,一般是把被标记为怀疑的root shell记录记成了2,可以统一收敛成1。

3.2 One-Hot编码与数值列标准化的顺序坑

整个项目里最容易翻车的就是预处理顺序。正确顺序是:先分离标签和特征,再对离散列做One-Hot,最后对数值列做标准化。如果先标准化再One-Hot,等于把0/1稀疏列也变成了正态分布,破坏稀疏结构。下面这段是我复现model_with_pca.ipynb时的核心代码:

def preprocess(train_df, test_df): label_col = 'label' cat_cols = ['protocol_type', 'service', 'flag'] y_train = train_df[label_col].copy() y_test = test_df[label_col].copy() X_train = train_df.drop(columns=[label_col]) X_test = test_df.drop(columns=[label_col]) # 删掉全零列,num_outbound_cmds在NSL-KDD里没有区分度 zero_cols = [c for c in X_train.columns if (X_train[c] == 0).all()] X_train.drop(columns=zero_cols, inplace=True) X_test.drop(columns=zero_cols, inplace=True) # 先拼接再get_dummies对齐列 combined = pd.concat([X_train, X_test], keys=['tr', 'te']) combined_encoded = pd.get_dummies(combined, columns=cat_cols) X_tr_enc = combined_encoded.loc['tr'] X_te_enc = combined_encoded.loc['te'] # 数值列标准化:先取出数值列,做StandardScaler num_cols = X_tr_enc.select_dtypes(include=[np.number]).columns.tolist() from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_tr_enc[num_cols] = scaler.fit_transform(X_tr_enc[num_cols]) X_te_enc[num_cols] = scaler.transform(X_te_enc[num_cols]) return X_tr_enc, X_te_enc, y_train, y_test, scaler

这段代码的关键点在于get_dummies之后列数会变多,num_cols是从编码后的数据里重新select_dtypes拿到的,所以数值列和One-Hot列互不干扰。StandardScaler只对数值列做变换,稀疏的One-Hot列保持0/1不变。测试集标准化时必须用训练集的scaler.transform,不能重新fit_transform,否则训练集和测试集的分布就不同步了。

3.3 降维模块:mat文件与Python的互操作

项目里的pca_model.mIDS_model_8-0.m是Matlab的.mat格式。用Python读取时通常采用:

from scipy.io import loadmat pca_struct = loadmat('model/pca_model.m') print(pca_struct.keys())

注意scipy.io.loadmat读出来的是字典,里面每个key对应一个变量。Matlab保存的结构体可能是PCA_modelmusigma之类。如果你拿到的是降维后的转换矩阵,常见做法是在Python里重新用PCA拟合训练集特征,并把训练好的pca.n_components_保存在joblib里供GUI调用,这样就不依赖Matlab运行时了。我在复现时更倾向于直接跑model_with_pca.ipynb,因为Notebook里已经把PCA对象序列化成了pca.joblib(如果没有,可以用下面代码补一个):

from sklearn.decomposition import PCA import joblib pca = PCA(n_components=0.95, svd_solver='full') X_pca = pca.fit_transform(X_tr_enc) joblib.dump(pca, 'model/pca_model.joblib')

n_components=0.95表示保留95%的方差,svd_solver='full'适合小样本精确求解。NSL-KDD训练集大概有12.5万条样本,One-Hot后特征维度大约120,完整SVD并不慢。

4. 模型训练与评估:有无PCA的差异在哪里

4.1 训练基线模型:随机森林与梯度提升

model_no_pca.ipynb里,作者用的是随机森林,而在model_with_pca.ipynb里除了随机森林还跑过MLP。随机森林对离散特征和缩放不敏感,但在One-Hot高维稀疏特征下,树模型往往能取得不错效果;而MLP这类线性加权模型则强依赖特征标准化,这就是为什么带PCA的流程里必须先把特征归一化。

我复现时以随机森林作为主模型,因为参数稳定、可解释性强,不容易在答辩时被质疑过拟合。训练脚本核心如下:

from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report, accuracy_score model_rf = RandomForestClassifier( n_estimators=100, max_depth=15, min_samples_leaf=2, n_jobs=-1, random_state=42 ) model_rf.fit(X_tr_enc, y_train_bin) y_pred = model_rf.predict(X_te_enc) print('Accuracy:', accuracy_score(y_test_bin, y_pred)) print(classification_report(y_test_bin, y_pred, target_names=['normal', 'attack']))

这段代码里max_depth=15是为了防止树深度过高把训练集噪声也背下来。min_samples_leaf=2保证叶子节点最少有2个样本,避免输出极其罕见的组合。如果样本不平衡严重(DoS占大头),可以加class_weight='balanced'

4.2 评估指标怎么选:准确率不够就上混淆矩阵

很多教程只报告准确率,但NSL-KDD测试集里DoS攻击占比高,而R2L、U2R样本极少,准确率会被DoS拉起来,掩盖小类别的识别失败。项目里evaluate_model_with_kdddataset.ipynb给出了多分类评估,建议至少打印出macro-F1和各类别的召回率。下表是我用随机森林在NSL-KDD测试集跑出来的典型结果(具体数值依随机种子和预处理有浮动):

攻击类别精确率召回率F1训练集样本数
DoS0.980.970.9745927
Probe0.850.880.8611656
R2L0.990.210.35995
U2R0.750.050.0952
normal0.940.960.9567343

看到U2R的召回率只有0.05不要慌,这是NSL-KDD的已知问题:U2R在训练集只有52条,攻击行为又和normal重叠度高,任何模型都很难学。优化办法是对U2R做过采样或把类权重调高,但代价是DoS精确率下降。如果你在作业里要解释这个现象,直接说“小样本类别不足以支撑训练”即可,比硬调参数更有说服力。

4.3 对比实验:降维到底损失了什么

在带PCA的流程里,随机森林效果通常会略低于不带PCA,因为树模型并不需要PCA去相关性;但MLP在降维后训练速度更快,且在小样本类别上的稳定性有时反而更好。我实际记录过两组实验:不带PCA随机森林macro-F1约为0.71,带PCA随机森林macro-F1约为0.68,差异主要来自R2L和U2R的召回率继续下降。

model_with_pca.ipynb里PCA的n_components没有写成固定值,而是用方差解释率选择的。如果想复现,可以用:

from sklearn.decomposition import PCA pca = PCA(n_components=110, svd_solver='full') X_pca = pca.fit_transform(X_tr_enc) print('Explained variance ratio sum:', pca.explained_variance_ratio_.sum())

设置n_components=110是为了方便和原作者Matlab模型对比。需要注意的是PCA之前特征已经标准化过,所以协方差矩阵特征值服从正态分布假设,降维才有效。如果你把One-Hot列也标准化,PCA结果会变差,这也是为什么3.2节里强调只对数值列标准化。

5. 加载模型和GUI单条预测的细节验证

5.1 从joblib或mat加载模型

GUI界面需要把训练好的模型文件挂载进来。如果模型保存成.joblib,直接joblib.load;如果是Matlab.mat,需要loadmat后取出预测函数。项目里的GUI是Tkinter写的,我建议在启动GUI之前单独写一个load_models()函数,把预处理、模型、PCA三件套统一封装,避免界面代码堆满数据处理逻辑。

import joblib import numpy as np class IDSModel: def __init__(self, scaler_path, model_path, cat_cols): self.scaler = joblib.load(scaler_path) self.model = joblib.load(model_path) self.cat_cols = cat_cols def predict_one(self, raw_record: dict) -> str: df = pd.DataFrame([raw_record]) # 对单条记录做与训练时相同的get_dummies for col in self.cat_cols: df[col] = df[col].astype('category') df_enc = pd.get_dummies(df, columns=self.cat_cols) # 对齐训练时的特征列(缺失列补0,多余列删掉) X = df_enc.reindex(columns=self.training_columns, fill_value=0) # 数值列标准化 X[self.scaler_feature_names] = self.scaler.transform(X[self.scaler_feature_names]) proba = self.model.predict_proba(X)[0][1] pred = (proba >= 0.5).astype(int) return '攻击' if pred == 1 else '正常', proba

这里reindex(columns=...)是关键,GUI输入的原始数据可能缺少训练特征,fill_value=0保证维度一致。缺失的service值在训练集里没见过,One-Hot后全是0,这样模型会把它当作一个“全新协议”,大部分情况下预测为攻击,符合安全软件对新协议的保守策略。

5.2 单条样本的字段构造

GUI里测试输入不宜让用户手填41个字段,通常只要求输入protocol_type、service、src_bytes、dst_bytes、count等几个关键字段,其余字段填默认值。我的做法是准备一份默认记录,用户只需改动少数字段:

default_record = { 'duration': 0, 'protocol_type': 'tcp', 'service': 'http', 'flag': 'SF', 'src_bytes': 1000, 'dst_bytes': 1000, 'land': 0, 'wrong_fragment': 0, 'urgent': 0, 'hot': 0, 'num_failed_logins': 0, 'logged_in': 1, 'num_compromised': 0, 'root_shell': 0, 'su_attempted': 0, 'num_root': 0, 'num_file_creations': 0, 'num_shells': 0, 'num_access_files': 0, 'num_outbound_cmds': 0, 'is_host_login': 0, 'is_guest_login': 0, 'count': 10, 'srv_count': 10, 'serror_rate': 0.0, 'srv_serror_rate': 0.0, 'rerror_rate': 0.0, 'srv_rerror_rate': 0.0, 'same_srv_rate': 1.0, 'diff_srv_rate': 0.0, 'srv_diff_host_rate': 0.0, 'dst_host_count': 100, 'dst_host_srv_count': 100, 'dst_host_same_srv_rate': 1.0, 'dst_host_diff_srv_rate': 0.0, 'dst_host_same_src_port_rate': 0.5, 'dst_host_srv_diff_host_rate': 0.0, 'dst_host_serror_rate': 0.0, 'dst_host_srv_serror_rate': 0.0, 'dst_host_rerror_rate': 0.0, 'dst_host_srv_rerror_rate': 0.0 }

构造好记录后用上面的predict_one方法直接判断。注意logged_in在原始数据集里是0/1,但有些样本里会出现2,训练时最好把它转换为二值,否则输入校验会报错。

5.3 验证模型文件有没有被正确加载

很多同学下载完项目后直接跑GUI,结果一加载模型就报错EOFErrorKeyError,原因往往是模型文件路径不对,或者loadmat的结构体字段名和代码里写的不一致。建议在启动GUI前先用命令行做一次桩测试:

python -c "from scipy.io import loadmat; d=loadmat('model/IDS_model_8-0.m'); print(d.keys())"

如果打印的keys里没有modelweights,说明读取方式不对。更稳妥的做法是我上面给出的IDSModel封装,把模型重新用joblib序列化一次,这样GUI和训练代码共用一个对象,不会再出现Matlab和Python数据类型不兼容的情况。

另外,GUI里的“预测”按钮回调函数里一定要有异常捕获:

try: label, proba = ids_model.predict_one(current_record) result_var.set(f"{label} (置信度: {proba:.2%})") except Exception as e: messagebox.showerror("预测失败", f"检查输入字段或模型文件: {e}")

这样即使某次预测因为字段不对抛异常,界面也不会直接崩溃。用同样的策略验证整个流程时,建议先把测试集切成200条样本批量预测,看整体准确率与训练时是否一致,再进入GUI单条调试,能快速区分是模型加载问题还是输入构造问题。

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

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

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

立即咨询