拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

KDD-CUP99网络攻击检测实战:随机森林建模与Django部署

KDD-CUP99网络攻击检测实战:随机森林建模与Django部署

简介:这是一个基于KDD CUP99数据集的网络攻击检测项目,面向机器学习或网络安全方向的期末大作业与课程实训场景。源码采用Python编写,将经典入侵检测数据集与Web可视化分析结合,可帮助学习者完整走通数据预处理、特征分析、模型训练与结果展示流程,适合具备一定Python基础并希望快速搭建可运行项目的人群。压缩包共75个文件,大小约6.93MB,核心内容包含14个Python源码与10个pyc编译文件,前端部分含7个Vue组件、17个JavaScript脚本,另有Django配置、SQLite数据库、预处理后的npy与pickle数据文件及Markdown说明文档,整体结构清晰,便于按模块查阅。目前已有350人学习下载,资源内附带完整的KDD CUP99数据子集、预处理脚本与可交互的前端页面,运行后可直接观察不同攻击类型的检测效果,也可在此基础上调整算法参数、扩展模型对比,对于课程设计或毕业设计具有较高的参考价值。

1. 期末大作业的网络攻击检测,凭什么先看 KDD-CUP99 这份源码包

期末大作业选网络攻击检测方向,搜索 python 数据集 网络攻击检测项目源码,大概率会被拉到一个关键词上:KDD-CUP99。我第一次解压这类压缩包时,看见里面有 Django 目录,又有 net-analyze 这种 Vue 前端工程,第一反应是“资源把 Web 壳子也硬塞进来了”,心里其实先打了个问号。后来把 KDD-CUP99-Analysis-main 里的脚本从头过了一遍才明白,这个包的价值不在算法本身多新,而是把数据读取、特征工程、类别不平衡、模型包装这几条线全部串成了一个本地能跑的闭环,难度正好卡在课程作业的量级上。

它的完整链路是这样:读入 KDD-CUP99 的连接记录 → 编码与抽样 → 训练随机森林模型 → Django 暴露预测接口 → Vue 页面展示。对期末大作业来说,这恰好是最容易抄对、也最容易出故障的一段流程。下面我按自己复现的顺序把它拆开,新手能跟步骤走,已经跑过别的分类模型的熟手,重点看第 5 章和第 6 章的坑。

2. KDD-CUP99 数据解析:41 个特征里,真正影响攻击检测的是哪些

2.1 每一行连接记录到底由什么组成

KDD-CUP99 是 1999 年 KDD 竞赛整理的网络连接记录集,源头可以追溯到 DARPA 的模拟网络环境实验。每一条记录代表一次网络连接,从 TCP 会话建立开始,到会话结束。数据集中每一行有 41 个特征字段加一个攻击标签,所以用 pandas 读出来后应当是 42 列。

这 41 个特征按语义可以粗暴分成三段:

第一段是连接本身的基础属性,比如 duration、protocol_type、service、flag、src_bytes、dst_bytes。protocol_type 是 tcp、udp 这类协议,service 是 http、ftp_data、smtp 这类目标服务,flag 是连接状态标志,src_bytes 和 dst_bytes 是源到目标、目标到源的字节数。这几列决定了“这条连接是谁、什么协议、传了多少数据”。

第二段是基于连接所在时间窗口的统计特征,count、srv_count、serror_rate、srv_serror_rate、same_srv_rate 都在这一段。count 是过去 2 秒内与当前连接相同目标主机的连接数,serror_rate 是其中出现 SYN 错误的百分比。这一段的意图很直接:短时间内大量同目标连接、高比例错误,本身就是异常的强信号。

第三段是目标主机维度的统计特征,dst_host_count、dst_host_srv_count、dst_host_same_srv_rate、dst_host_serror_rate 等。它们把视角从“单条连接”抬到“目标主机在窗口内被访问的整体画像”。很多论文只选第一段和第二段特征,第三段直接丢掉,其实会丢掉不少对 Probe 攻击有用的判别力度。

2.2 读入 kddcup.data_10_percent.csv 的正确姿势

这类数据集多数是文本文件,没有表头,第一行就是数据。读进来最常犯的错是忘记传 header=None,结果第一行被当成列名,后面所有特征引用全乱。另一个错是不传 names,pandas 会按 0、1、2、3 当字段名,后面维护代码时完全不知道处理的是哪个字段。

我一般会把 42 个字段名显式写成一个列表,顺序不能错,KDD 官方文档里的字段顺序就是这样:

import pandas as pd 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_df = pd.read_csv("kddcup.data_10_percent.csv", header=None, names=COL_NAMES) print(train_df.shape) print(train_df.head(3))

train_df.shape 正常情况下会输出 (494021, 42),这是 10% 子集的标准行数。完整版 kddcup.data 解压后有将近 489 万行,课堂上直接跑完整版很容易把内存吃满,所以先用 10% 版是常规做法。如果打印出来的前几行里能看到 protocol_type 是 tcp、udp,service 是 http、smtp 这类真实取值,说明列顺序没有搞反。

2.3 把 22 种攻击名压缩到五个主类

KDD-CUP99 的标签不是只有四种攻击,而是有一个细粒度列表,常见的有 back、neptune、smurf、ipsweep、satan、buffer_overflow、warezclient 等二十多种。做多分类时要是直接拿这些细粒度标签当类别,会有一堆类别只有几十条样本,模型根本学不动。所以第一件事是把它们聚合到标准主类:Normal、DoS、Probe、R2L、U2R。

主类有代表性的原始攻击名
Normalnormal
DoSback、land、neptune、pod、smurf、teardrop
Probeipsweep、nmap、portsweep、satan
R2Lftp_write、guess_passwd、imap、multihop、phf、spy、warezclient、warezmaster
U2Rbuffer_overflow、loadmodule、perl、rootkit

映射代码我常写成这样,遇到没见过的攻击名不硬塞进某个类,而是标成 unknown,之后再回头检查:

attack_map = { "back": "DoS", "land": "DoS", "neptune": "DoS", "pod": "DoS", "smurf": "DoS", "teardrop": "DoS", "ipsweep": "Probe", "nmap": "Probe", "portsweep": "Probe", "satan": "Probe", "ftp_write": "R2L", "guess_passwd": "R2L", "imap": "R2L", "multihop": "R2L", "phf": "R2L", "spy": "R2L", "warezclient": "R2L", "warezmaster": "R2L", "buffer_overflow": "U2R", "loadmodule": "U2R", "perl": "U2R", "rootkit": "U2R" } def to_category(label: str) -> str: if label == "normal": return "normal" return attack_map.get(label, "unknown") train_df["attack_category"] = train_df["label"].map(to_category) print(train_df["attack_category"].value_counts())

这段代码的逻辑是把 normal 原样保留,其余按字典映射。实际跑完分布大约是这样:DoS 类别数量能到三十九万多,Normal 接近十万,Probe 四千左右,R2L 一千出头,U2R 只有五十多条。看到这个分布就该意识到,直接拿原始数据训练随机森林,必然会把少数类淹没掉。

3. 特征工程与训练集构造:协议文本归一、类别映射和切分顺序

3.1 文本特征编码:为什么用 LabelEncoder 而不是 OneHot

41 个特征里有三个是文本字段:protocol_type、service、flag。protocol_type 取值就 tcp、udp、icmp 几种,flag 大概十来种,service 则是几十种服务名。如果对 service 做 OneHot,特征矩阵一下会炸出几十列,虽然 49 万行数据扛得住,但对后面的模型解释性是个负担。

我在这套项目里更常用的做法是 LabelEncoder。它的核心是给每个文本值分配一个整数编号,比如 protocol_type 编码成 0、1、2,service 和 flag 同理。这样处理对随机森林这类树模型没有任何信息损失,树模型本身不关心特征之间的相对大小,只关心分裂点。

from sklearn.preprocessing import LabelEncoder def fit_text_columns(df, text_columns): mappers = {} for col in text_columns: le = LabelEncoder() df[col + "_enc"] = le.fit_transform(df[col]) mappers[col] = le return df, mappers train_df, label_maps = fit_text_columns( train_df, ["protocol_type", "service", "flag"] ) print(train_df[["protocol_type", "protocol_type_enc"]].head(10))

注意这里有个容易忽略的点:LabelEncoder 必须只 fit 一次,然后把同一个编码器保存下来,后续验证集、测试集、Django 接口里传入的数据都要用这个编码器 transform。最忌讳的做法是训练集 fit 一个、验证集又 fit 一个,后面 predict 时一旦遇到没见过的 service 取值,sklearn 直接抛 ValueError。

3.2 特征筛选、训练验证切分与数值归一化

特征列里有一列 num_outbound_cmds,它的缺点是训练数据集中所有值都是 0,一个常量列对模型没有贡献,反而制造冗余,我一般直接剔除。其余 40 维里,protocol_type、service、flag 用编码后的版本替代原文本列。

feature_columns = [ "duration", "protocol_type_enc", "service_enc", "flag_enc", "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", "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" ] X = train_df[feature_columns].copy() y = train_df["attack_category"] from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_val_scaled = scaler.transform(X_val)

train_test_split 里 stratify=y 是必须写的,它保证 y 的类别比例在训练集和验证集里一致。如果不写,随机切分那一下可能把原本就稀少的 U2R 样本全切到验证集,或者训练集里一个 U2R 都不剩。random_state=42 只是固定随机种子,方便对比结果。

StandardScaler 对随机森林不是必需的,树模型对量纲不敏感,但项目里后面往往还要对比逻辑回归或 MLP,那些模型对数值范围敏感,所以提前归一化更通用。注意 fit_transform 只能用在训练集,验证集用已经 fit 好的 scaler 去 transform,这是防止数据泄漏的基础操作。

4. 训练网络攻击检测模型:随机森林、多分类、以及不靠 accuracy 说话

4.1 多分类随机森林的参数怎么设

这套项目里我第一版跑的模型就是随机森林。原因很务实:49 万行的 10% 子集,40 维特征,随机森林在树模型里出结果快、误差相对低,而且不需要像神经网络那样做复杂的调参。参数先给一版能直接出结果的配置:

from sklearn.ensemble import RandomForestClassifier rf = RandomForestClassifier( n_estimators=120, max_depth=30, min_samples_split=8, class_weight="balanced_subsample", n_jobs=-1, random_state=42, ) rf.fit(X_train_scaled, y_train)

n_estimators=120 是绝大多数项目的常见做法,再往上加树的数量收益不大,训练时间却线性上涨。max_depth=30 给树一个合理深度上限,防止在几十万条样本上无节制分裂。min_samples_split=8 是让内部节点至少要有 8 条样本才继续分裂,这是在过拟合和拟合不足之间取平衡。

class_weight 这里要重点说。默认情况下随机森林对 DoS 这类大类和 U2R 这类小类“一视同仁”,但 U2R 只有几十条样本,类别占比 0.01%,不做任何处理的话,模型会把它们全部视作噪声并预测成 Normal。balanced_subsample 是在每棵树自助采样时按类别权重调整采样概率,比纯 balanced 更适合随机森林这种 bagging 模型。n_jobs=-1 表示用满本机所有 CPU 核心,训练几千棵树时能省不少时间。

4.2 评估指标:混淆矩阵和少数类漏检

训练完之后,第一步不是看 accuracy,而是立即打印分类报告和混淆矩阵:

from sklearn.metrics import classification_report, confusion_matrix, ConfusionMatrixDisplay y_pred = rf.predict(X_val_scaled) print(classification_report(y_val, y_pred, digits=3)) cm = confusion_matrix(y_val, y_pred, labels=rf.classes_) disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=rf.classes_) disp.plot()

这份报告大概率会给出 total accuracy 0.99 左右,但拆开看每一行,U2R 的 recall 可能只有 0.1 甚至 0.0,R2L 也高不到哪里去。原因是样本数量悬殊:模型只要把所有样本都预测成 DoS 或 Normal,整体准确率就已经很可观了,根本不需要认真学 R2L 和 U2R。

很多课程作业卡在“为什么 accuracy 这么高,老师说模型不行”,就是因为只看了 top 1 准确率,没看少数类。正确的做法是同时训练一个二分类模型,把标签压成 normal 和 attack 两类,判断系统“有没有被攻击”;再运行五分类模型,判断“被哪种攻击命中”。二分类视角的评估代码:

y_val_bin = (y_val != "normal").astype(int) y_pred_bin = (y_pred != "normal").astype(int) print(classification_report(y_val_bin, y_pred_bin, target_names=["normal", "attack"]))

这个二分类准确率通常会非常高,因为它把几个大类合并了,R2L、U2R 虽然少,但二分类时至少还有机会被识别成 attack。五分类报告里的 U2R 低,更多是类别不平衡导致的,不是特征没选对。另一个可尝试的方向是单独挑出 R2L 和 U2R 做二分类,或者用 imbalanced-learn 的 SMOTE 对少数类过采样。

5. 避坑笔记:加载、训练和部署 KDD-CUP99 时最容易翻的五个车

5.1 五条高频踩坑记录

下面五条都是我实际跑 KDD-CUP99 项目时见过的坑,按“现象、原因、解决”整理出来,照着排查能省小半天时间。

坑一:pd.read_csv 读出来列名不对

现象:train_df.head() 打印的列名是 0 到 41 的数字,代码里写 train_df["duration"] 直接 KeyError。

原因:读取时没传 names 参数,数据文件没有表头,pandas 默认把第一行数据当列名,后面的行全部错位。

解决:必须显式传 header=None 和 names=COL_NAMES,并且 COL_NAMES 的顺序要和官方字段顺序一致,多一个少一个都会串列。

坑二:验证集预测时报 LabelEncoder 的 unseen label

现象:训练集上 fit 和 predict 都没问题,到验证集或测试集 predict 时报 “y contains previously unseen labels”。

原因:三个文本特征在训练前被拆开各 fit 了一次 LabelEncoder,或者验证集里包含训练集中不存在的 service 值。

解决:在训练集合上 fit 一次,把编码器对象存入 label_maps 字典,之后所有数据集和预测接口都用同一个 le.transform。如果担心上线后遇到全新增量 service,可以在 transform 前先查 le.classes_,遇到新值映射到一个统一 unknown 编号。

坑三:整体 accuracy 99.8%,但 U2R 和 R2L 几乎全漏

现象:classification_report 里 U2R recall 0.00,R2L recall 0.02,大部分样本被分进了 normal 或 DoS。

原因:监督学习模型默认优化的是整体准确率,U2R 只有几十条样本,预测对了对整体数字贡献为零,模型自然选择放弃它们。

解决:第一,加 class_weight="balanced_subsample";第二,单独训练 R2L/U2R 的二分类模型,或者对少数类做 SMOTE 过采样;第三,评估时用 macro avg,别盯着 accuracy 看。

坑四:完整数据集训练撑爆内存

现象:把解压后的完整 kddcup.data 用 pd.read_csv 读入,notebook 卡死或报 MemoryError。

原因:完整训练集约 489 万行,普通笔记本内存扛不住大规模 DataFrame 加随机森林训练的多份拷贝。

解决:用 kddcup.data_10_percent.csv 这个 10% 子集做课程训练,494021 行足够出结论。资源里如果已经带了 corrected 测试集,用那个做最终验证,不要自己硬上全量数据。

坑五:Django 接口返回的类别编号和页面上显示的攻击名对不上

现象:后端 predict 返回数字 3,前端把它翻译成 U2R,但实际这条记录是 Probe,页面展示张冠李戴。

原因:sklearn 分类器内部 classes_ 的顺序不是按人类习惯排列的,是模型按自身规则排的;接口里硬编码“0=Normal、1=DoS”这种映射就会错位。

解决:训练完立刻读取 model.classes_,把它序列化成 JSON 放进模型包里,前端展示时按这份 classes_ 做翻译。第 6 章会具体给这段固化代码。

5.2 快速判断模型是不是“虚高”的排查套路

拿到一份 KDD-CUP99 检测结果时,如果只有准确率数字,先别急着写进报告。我一般按这个顺序排查:第一步打印 classification_report,重点看 macro avg 和 U2R、R2L 两行的 recall,哪个类 recall 趋近 0,哪个类就是被模型无视了。

第二步把误判样本抽出来看原始记录。写法是先用 y_val 和 y_pred 做布尔掩码,取出被预测错的行,打印它们的原始 label、attack_category 以及关键特征值,比如 serror_rate、count、dst_host_count。很多时候会发现所谓误判是特征本身不够区分,也可能是数据里该实例的标签本身有歧义。

第三步是把模型对验证集的预测结果和训练时的少数类分布放在一起对比,如果训练集里 U2R 类别本来就少得只能数出来,那任何模型都学不好,不是参数玄学,是数据问题。这类结论写进期末大作业里,反而比单贴一个高 accuracy 更得分。

6. 把模型装进 Django 服务:固定特征顺序、接口自检和前端收尾

6.1 把 model、scaler、classes 一起 pickle 固化

项目里的模型最终要落到 Django 服务里才方便展示。直接只存一个 model.pkl 是不够的,因为预测时还需要 scaler 做归一化、需要 feature_columns 固定特征顺序、需要 classes_ 告诉前端类别编号。正确做法是把它们打成一个字典整体保存:

import pickle bundle = { "model": rf, "scaler": scaler, "label_maps": label_maps, "feature_columns": feature_columns, "classes": list(rf.classes_), } with open("kdd99_model.pkl", "wb") as f: pickle.dump(bundle, f)

Django 启动加载时读同一个字典,接口里凡是需要特征列顺序的地方都从 bundle 里取,不要二次硬编码。这个习惯的好处是,哪怕你后续换了特征子集,前端和后端都不用跟着改数字。

6.2 接口自检:第一次打接口就拦住顺序错位

Django 视图里拿到前端传来的 features 列表后,最有效的自检是校验长度。如果长度不等于 len(feature_columns),说明前后端特征没对齐,直接返回 400,让前端知道问题出在哪一步。

import json from django.http import JsonResponse def predict_view(request): if request.method != "POST": return JsonResponse({"error": "POST only"}, status=405) payload = json.loads(request.body) features = payload.get("features") if features is None or len(features) != len(feature_columns): return JsonResponse( {"error": f"特征数量应为 {len(feature_columns)},实际收到 {len(features) if features else 0}"}, status=400 ) X_input = scaler.transform([features]) pred = model.predict(X_input)[0] return JsonResponse({"prediction": str(pred)})

验证阶段用 curl 打一次最直接。从原始数据里抽一条真实连接记录,按 feature_columns 的顺序拼成数组,POST 给接口,确认返回的攻击类别和训练时预期一致。

curl -X POST http://127.0.0.1:8000/api/predict \ -H "Content-Type: application/json" \ -d '{"features": [0, 1, 2, 0, 181, 5450, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 8, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 255, 254, 1.0, 0.01, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]}'

从那以后我每次跑这种 KDD-CUP99 项目,都强制自己先把 classes_ 和 feature_columns 跟模型存进同一个 pickle,再拿真实记录打一次接口;顺序不核对,页面上所有结果都是乱的,这一步只需要多花两分钟。希望帮到你。

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

返回列表