简介:这份资源是面向计算机、通信、人工智能、自动化等专业学生与从业者的网络入侵检测实战项目包,可作为毕业设计、课程设计或期末大作业的完整参考方案。项目基于UNSW_NB15官方数据集,围绕42列特征与二分类标签,完整实现了特征处理、Pearson相关性过滤、随机森林特征选择以及Keras搭建CNN网络训练的全流程,并配套项目说明与答辩PPT,便于理解整体设计思路与实验结论。压缩包为zip格式,大小约10.02MB,内含源码、说明文档与演示材料,代码按StrTonum、Min-max、Balanced、Pearson、Find_feature、Pro_Testdata、CNN等模块组织,运行流程清晰,安装sklearn、TensorFlow与Keras依赖后即可复现。目前已有339人学习关注,适合希望掌握机器学习与深度学习入侵检测方法、需要快速上手或进行二次开发的读者借鉴使用。
1. 从一份毕设源码说起:CNN加随机森林做入侵检测到底靠不靠谱
拿到「Python基于CNN网络和随机森林的网络入侵检测源码+项目说明+答辩PPT.zip」这个标题的人,大概率处在两种状态之一:要么是正在做网络安全方向毕设的学生,需要一套能跑通、能讲清楚、能应付答辩的完整方案;要么是刚入行的安全工程师,想找一个把深度学习落地到流量分类的练手项目。不管哪一种,核心诉求都一样——这套东西能不能真的把攻击流量从正常流量里挑出来,CNN和随机森林为什么要串在一起用,以及我拿到代码之后怎么在自己的机器上跑起来。
网络入侵检测(Network Intrusion Detection)本质是一个多分类问题:把一条条网络连接记录分成Normal、DoS、Probe、R2L、U2R等类别。传统做法是拿NSL-KDD或CIC-IDS2017数据集,手工做特征工程,然后喂给随机森林、SVM、XGBoost。但手工特征有个天花板——流量里的时序模式和局部相关性很难用统计量表达。CNN擅长从原始特征矩阵里提取局部模式,随机森林擅长在CNN输出之上做非线性决策,两者串起来正好互补。这套组合在毕设和入门项目里出现频率极高,不是没有道理的。
2. CNN与随机森林的分工:为什么不是二选一
2.1 CNN在流量特征上到底卷出了什么
网络流量数据经过预处理后,通常被整理成二维矩阵的形式:行是样本(每条连接记录),列是特征(如duration、protocol_type、src_bytes、dst_bytes、flag等)。直接把这个矩阵喂给CNN看起来有点奇怪,因为图像有空间局部性,而特征列之间未必有像素那样的邻域关系。但实际做法是:先对特征做归一化和编码,然后把一维特征向量reshape成类似“1×N”或“M×N”的伪图像结构,让卷积核在特征维度上滑动,提取局部组合模式。
比如NSL-KDD有41个特征,经过独热编码后维度会膨胀到120+。把这120+维向量reshape成比如12×10的矩阵,卷积核就能捕捉“某几个特征同时出现时意味着什么”。这不是玄学,本质是在做特征交叉,只不过用可学习的卷积核代替了手工的叉乘。
import numpy as np from tensorflow.keras import layers, models def build_cnn(input_dim, num_classes): model = models.Sequential([ # 将一维特征向量reshape成二维,模拟“特征图” layers.Reshape((input_dim, 1), input_shape=(input_dim,)), layers.Conv1D(64, kernel_size=3, activation='relu', padding='same'), layers.BatchNormalization(), layers.MaxPooling1D(pool_size=2), layers.Conv1D(128, kernel_size=3, activation='relu', padding='same'), layers.BatchNormalization(), layers.GlobalAveragePooling1D(), layers.Dense(128, activation='relu'), layers.Dropout(0.4), layers.Dense(num_classes, activation='softmax') ]) model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy']) return model这段代码里几个关键点:Reshape层把一维特征变成(特征数, 1)的“单通道信号”,Conv1D在特征维度上做卷积。BatchNormalization放在卷积之后、激活之前还是之后有讲究,这里放在激活之后是Keras的常见写法,但严格来说BN应该放在卷积和激活之间。GlobalAveragePooling1D替代Flatten可以减少参数量、降低过拟合。Dropout设0.4是经验值,流量数据噪声大,太低容易过拟合,太高欠拟合。
2.2 随机森林为什么接在CNN后面而不是替代它
CNN最后输出的是softmax概率或者倒数第二层的128维嵌入向量。如果直接用CNN的softmax结果做分类,小样本类别(比如U2R只有几十条)很容易被淹没。随机森林接在CNN后面有两种接法:
第一种,把CNN当作特征提取器,取倒数第二层的128维向量作为新特征,训练随机森林做最终分类。第二种,把CNN的softmax输出概率(比如5类就是5维概率)作为随机森林的输入特征,再叠加原始特征一起训练。
第一种更常见,因为128维嵌入已经包含了CNN学到的抽象表示,随机森林在这个表示空间里做决策树分裂,对类别不平衡的鲁棒性比softmax强。随机森林的bagging机制和特征随机选择,天然适合处理“某些类别样本极少”的情况。
from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report # 假设cnn_model是训练好的CNN,X_train是原始特征 feature_extractor = models.Model( inputs=cnn_model.input, outputs=cnn_model.layers[-3].output # 取Dropout之前的Dense层输出 ) X_train_deep = feature_extractor.predict(X_train) X_test_deep = feature_extractor.predict(X_test) rf = RandomForestClassifier( n_estimators=200, max_depth=20, min_samples_split=5, class_weight='balanced', n_jobs=-1, random_state=42 ) rf.fit(X_train_deep, y_train) y_pred = rf.predict(X_test_deep) print(classification_report(y_test, y_pred))n_estimators=200是起点,树再多边际收益递减。max_depth=20防止单棵树太深导致过拟合。class_weight='balanced'是关键——NSL-KDD里U2R和R2L样本极少,不加这个参数随机森林会倾向于预测多数类。n_jobs=-1用满CPU核心。
2.3 串行结构的信息流与梯度问题
CNN和随机森林串行时,CNN部分用反向传播训练,随机森林部分用决策树分裂准则训练,两者不共享梯度。这意味着你不能端到端地fine-tune整个pipeline。常见做法是分两阶段:先单独训练CNN到收敛,固定CNN参数,提取嵌入特征,再训练随机森林。
这种两阶段训练有个隐患:CNN学到的嵌入空间未必对随机森林最优。如果CNN过拟合了训练集,嵌入特征在测试集上分布偏移,随机森林的表现会跟着崩。缓解办法是在CNN训练时加早停和验证集监控,不要等到训练准确率冲到99%才停。
3. 从NSL-KDD到模型输入:数据管道的四个关键步骤
3.1 数据集加载与类别映射
NSL-KDD的KDDTrain+.txt和KDDTest+.txt是标准文件,每行41个特征加一个类别标签。类别有Normal、DoS、Probe、R2L、U2R五大类,但原始标签有20多种细分类别,需要先做映射。
import pandas as pd # 列名定义(NSL-KDD标准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","difficulty"] train_df = pd.read_csv("KDDTrain+.txt", names=col_names) test_df = pd.read_csv("KDDTest+.txt", names=col_names) # 攻击类别映射到5大类 attack_map = { 'normal': 'Normal', 'back': 'DoS', 'land': 'DoS', 'neptune': 'DoS', 'pod': 'DoS', 'smurf': 'DoS', 'teardrop': 'DoS', 'mailbomb': 'DoS', 'apache2': 'DoS', 'processtable': 'DoS', 'udpstorm': 'DoS', 'ipsweep': 'Probe', 'nmap': 'Probe', 'portsweep': 'Probe', 'satan': 'Probe', 'mscan': 'Probe', 'saint': 'Probe', 'ftp_write': 'R2L', 'guess_passwd': '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', 'buffer_overflow': 'U2R', 'loadmodule': 'U2R', 'rootkit': 'U2R', 'perl': 'U2R', 'sqlattack': 'U2R', 'xterm': 'U2R', 'ps': 'U2R' } train_df['label'] = train_df['label'].map(attack_map) test_df['label'] = test_df['label'].map(attack_map)映射表必须完整,漏掉任何一个细分类别都会导致该行变成NaN。NSL-KDD的测试集里有些攻击类型在训练集里没出现过,这是故意设计的,用来测试模型对未知攻击的泛化能力。
3.2 类别特征编码与数值归一化
protocol_type、service、flag是三列类别特征,需要做独热编码。service有70种取值,独热编码后维度会大幅增加。数值特征用MinMaxScaler归一化到[0,1]。
from sklearn.preprocessing import MinMaxScaler, OneHotEncoder from sklearn.compose import ColumnTransformer cat_cols = ['protocol_type', 'service', 'flag'] num_cols = [c for c in col_names[0:41] if c not in cat_cols] preprocessor = ColumnTransformer( transformers=[ ('num', MinMaxScaler(), num_cols), ('cat', OneHotEncoder(handle_unknown='ignore'), cat_cols) ]) X_train = preprocessor.fit_transform(train_df.drop(['label','difficulty'], axis=1)) X_test = preprocessor.transform(test_df.drop(['label','difficulty'], axis=1))handle_unknown='ignore'很重要——测试集里可能出现训练集没见过的service值,忽略而不是报错。fit_transform只在训练集上做,测试集用transform,避免数据泄露。
3.3 类别不平衡处理与样本权重
NSL-KDD训练集里Normal占53%,DoS占36%,Probe占9%,R2L和U2R加起来不到2%。直接训练模型会严重偏向多数类。除了随机森林的class_weight='balanced',CNN训练时也要用class_weight。
from sklearn.utils.class_weight import compute_class_weight import numpy as np classes = np.unique(y_train) weights = compute_class_weight('balanced', classes=classes, y=y_train) class_weight_dict = dict(zip(classes, weights)) # 训练CNN时传入 # cnn_model.fit(X_train, y_train, class_weight=class_weight_dict, ...)compute_class_weight的'balanced'模式会让少数类的权重按反比放大。U2R可能只有几十条样本,权重会到几十,训练时梯度会被这些样本主导。如果发现模型在Normal上准确率暴跌,说明权重给得太猛了,可以手动调低少数类权重。
3.4 训练集/验证集划分与CNN输入形状
CNN需要三维输入:(样本数, 特征数, 通道数)。这里通道数设为1。
from sklearn.model_selection import train_test_split X_tr, X_val, y_tr, y_val = train_test_split( X_train, y_train, test_size=0.2, stratify=y_train, random_state=42) # 如果X_tr是稀疏矩阵,先转稠密 if hasattr(X_tr, 'toarray'): X_tr = X_tr.toarray() X_val = X_val.toarray() X_tr_cnn = X_tr.reshape(X_tr.shape[0], X_tr.shape[1], 1) X_val_cnn = X_val.reshape(X_val.shape[0], X_val.shape[1], 1)stratify=y_train保证划分后各类别比例一致。稀疏矩阵转稠密会吃内存,NSL-KDD只有12万条左右,120维特征转稠密大概100MB,可以接受。如果是CIC-IDS2017那种几百万条的数据,得用生成器分批。
4. 训练、评估与调参:把准确率从90%推到98%
4.1 CNN训练的超参数与早停策略
CNN训练最容易翻车的地方是过拟合。流量数据里Normal样本太多,模型很快学会“全猜Normal”就能到50%以上准确率。早停和验证集监控是必须的。
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau callbacks = [ EarlyStopping(monitor='val_loss', patience=5, restore_best_weights=True), ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=3, min_lr=1e-6) ] history = cnn_model.fit( X_tr_cnn, y_tr, validation_data=(X_val_cnn, y_val), epochs=50, batch_size=256, class_weight=class_weight_dict, callbacks=callbacks, verbose=1 )patience=5表示验证损失连续5轮不下降就停。restore_best_weights=True把权重回滚到最佳轮次。ReduceLROnPlateau在验证损失停滞时降低学习率,factor=0.5每次减半。batch_size=256在12万条数据上大概每轮470个batch,训练速度适中。
4.2 随机森林的调参:n_estimators、max_depth与max_features
随机森林的调参比CNN直观。n_estimators从100开始试到500,看OOB误差或者验证集准确率。max_depth控制树深度,太深过拟合,太浅欠拟合。max_features是每次分裂时考虑的特征数,默认是sqrt(特征数),在嵌入特征上可以调大。
from sklearn.model_selection import GridSearchCV param_grid = { 'n_estimators': [100, 200, 300], 'max_depth': [10, 20, 30, None], 'max_features': ['sqrt', 'log2', 0.5], 'min_samples_leaf': [1, 2, 4] } rf = RandomForestClassifier(class_weight='balanced', n_jobs=-1, random_state=42) grid = GridSearchCV(rf, param_grid, cv=3, scoring='f1_macro', n_jobs=-1, verbose=1) grid.fit(X_train_deep, y_train) print(grid.best_params_)scoring用f1_macro而不是accuracy,因为类别不平衡时accuracy会骗人。cv=3在12万条数据上每轮训练4万条,200棵树大概几分钟。如果嫌慢,先用小网格粗调,再在最优附近细调。
4.3 评估指标:为什么accuracy会骗人
NSL-KDD测试集里Normal占43%,如果模型全猜Normal,accuracy也有43%。但U2R的召回率会是0。所以必须看混淆矩阵和每类的precision/recall/f1。
from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt cm = confusion_matrix(y_test, y_pred) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=classes, yticklabels=classes) plt.xlabel('Predicted') plt.ylabel('True') plt.title('Confusion Matrix') plt.show() print(classification_report(y_test, y_pred, target_names=classes))重点看U2R和R2L的召回率。如果U2R召回率低于0.5,说明模型基本没学到U2R的模式。这时候要么增加U2R样本(SMOTE过采样),要么调整class_weight,要么在CNN嵌入层加对比学习损失。
4.4 模型保存与推理管道封装
训练完之后要把preprocessor、CNN特征提取器、随机森林三部分一起保存,推理时按顺序调用。
import joblib import pickle # 保存preprocessor joblib.dump(preprocessor, 'preprocessor.pkl') # 保存CNN特征提取器 feature_extractor.save('cnn_feature_extractor.h5') # 保存随机森林 joblib.dump(rf, 'random_forest.pkl') # 推理时 def predict(raw_df): X = preprocessor.transform(raw_df) if hasattr(X, 'toarray'): X = X.toarray() X_cnn = X.reshape(X.shape[0], X.shape[1], 1) X_deep = feature_extractor.predict(X_cnn) return rf.predict(X_deep)保存feature_extractor而不是整个CNN,是因为推理时只需要嵌入向量,不需要softmax层。这样加载更快,也避免softmax层的数值不稳定问题。
5. 避坑与排查:那些让准确率一夜回到解放前的问题
5.1 现象:测试集准确率远低于训练集,差距超过20%
原因:CNN过拟合。流量数据里Normal样本的模式太单一,CNN在训练集上记住了这些模式,但测试集里Normal的分布略有不同就崩了。
解决:先检查早停是否生效。如果val_loss在训练后期还在下降但test准确率不涨,说明验证集和测试集分布不一致。NSL-KDD的测试集是故意设计成分布偏移的,这时候要在CNN里加Dropout和L2正则化,或者减少CNN层数。另一个办法是冻结CNN前几层,只训练后几层。
5.2 现象:U2R和R2L的召回率始终为0
原因:类别极度不平衡,U2R在训练集里只有52条,随机森林的每棵树在bootstrap采样时可能一条U2R都没抽到。
解决:除了class_weight='balanced',还要在随机森林的bootstrap采样时用stratified sampling。sklearn的RandomForestClassifier不支持直接分层,但可以用imbalanced-learn的BalancedRandomForestClassifier。或者对U2R做SMOTE过采样到500条以上再训练。
5.3 现象:独热编码后特征维度爆炸,训练极慢
原因:service列有70种取值,独热编码后增加70维。如果再加上protocol_type和flag,总维度从41涨到120+。CNN的参数量随输入维度线性增长。
解决:对service列做目标编码(target encoding)或者频率编码,把70维压缩到1维。或者用Embedding层代替独热编码,把每个类别映射到低维稠密向量。Embedding层在Keras里就是layers.Embedding(input_dim=70, output_dim=8),8维比70维省太多。
5.4 现象:随机森林的feature_importances_全是零
原因:CNN嵌入特征已经是稠密向量,每个维度都是原始特征的线性组合,随机森林在嵌入空间里做分裂时,单个维度的区分度不高。
解决:这是正常现象,不代表模型有问题。如果非要看特征重要性,应该在原始特征上训练一个单独的随机森林做对比,而不是在CNN嵌入上。或者用SHAP值分析CNN嵌入的每个维度对最终预测的贡献。
5.5 现象:推理时单条样本预测结果和批量预测不一致
原因:CNN的BatchNormalization层在推理时用的是移动平均的均值和方差,如果批量预测时batch_size和训练时差异大,BN的统计量会偏移。
解决:推理时固定batch_size,或者把BN层换成LayerNormalization。另一个常见原因是preprocessor的transform在单条样本上行为不同,比如MinMaxScaler在单条样本上会除以零。确保推理时至少传一个batch,哪怕batch_size=1也要保持二维形状。
6. 把CNN嵌入喂给随机森林之外:三个值得试的进阶方向
第一个方向是用一维CNN直接处理原始流量字节序列,跳过特征工程。CIC-IDS2017提供了pcap文件,可以把每个流的前N个字节作为输入,CNN直接学字节级模式。这比手工特征更接近端到端,但需要处理变长序列和字节对齐问题。我试过用TextCNN的结构处理字节序列,在CIC-IDS2017上F1能到0.95左右,但训练时间比特征工程方案长三倍。
第二个方向是把随机森林换成LightGBM或XGBoost。随机森林的bagging对少数类不够友好,boosting会逐步聚焦难分类样本。在CNN嵌入上跑LightGBM,U2R的召回率通常能比随机森林高5到10个百分点。代价是调参更麻烦,学习率和叶子数要仔细调。
第三个方向是给CNN加注意力机制。流量特征里不同维度的重要性差异很大,比如src_bytes和dst_bytes通常比urgent重要。在CNN后面加一个SE block或者Self-Attention层,让模型自己学每个维度的权重。我一般会在GlobalAveragePooling之前加一个通道注意力模块,参数量增加不到5%,但Probe类的召回率能涨3个点左右。
验证方法上,我习惯用分层K折交叉验证而不是单次划分。NSL-KDD的测试集分布偏移太严重,单次划分的方差很大。5折分层交叉验证能给出更稳定的评估,虽然训练时间乘以5,但调参时值得。如果时间紧,至少跑3折。
最后说个血泪教训:不要等到答辩前一天才跑完整流程。CNN训练加随机森林调参,在普通笔记本上至少需要两三个小时。如果中途发现数据加载有bug或者类别映射漏了,重跑一遍时间根本不够。我一般会在第一天就把数据管道跑通,保存中间结果,后面调参只动模型部分。希望帮到你。
本文还有配套的精品资源,点击获取