PyTorch实现KNN入侵检测:从距离矩阵到多数投票
2026/9/14 23:30:10 网站建设 项目流程

简介:面向计算机相关专业学生,这份基于KNN算法的入侵检测系统源码是一套适合期末大作业、课程设计与毕业设计的完整项目。系统结合PyTorch与KNN算法实现入侵检测流程,涵盖数据预处理、训练评估与结果展示等环节,源码经由导师指导并在评审中获得99分,代码完整、可直接运行,即使是初学者也能自行部署。资源包共58个文件,总体积约15.18MB。核心包含Java源码、HTML页面与PNG图片等前端展示内容,同时提供XML、Properties、YAML等配置文件,以及KDD CUP 10%数据集的多种预处理版本和运行脚本。项目采用Maven工程结构,源码、测试、配置与部分说明分区存放,目录层次清楚,便于按模块阅读和二次修改。当前已有53人学习下载,适合需要获取完整项目方案、梳理入侵检测系统设计思路或进行项目实战练习的人群。除可运行代码外,还附带有数据清洗后的处理文件、前端页面以及构建运行所需配置,能够帮助使用者较快地理解从数据输入到检测结果输出的完整流程,也可作为进一步扩展的起始工程。

1. 为什么期末大作业选 PyTorch 写 KNN 入侵检测

很多人在做安全方向的期末大作业时,第一反应是上 CNN 或 LSTM,但 KNN(K Nearest Neighbors)在入侵检测里从没被淘汰。攻击流量在特征空间里往往有明显的聚团性,一个样本被哪些邻居包围,比一个复杂的决策边界更能直接说明问题。这个项目用 PyTorch 来实现 KNN,而不是直接调 sklearn,核心目的是把距离计算、张量索引、多数投票这一整条链路都放在自己手里,课程答辩时每一步都能讲出底层原理。

选 PyTorch 还有一个现实原因:49 万条 KDD Cup 99 记录展开成距离矩阵后,显存占用和计算效率会立刻暴露问题,这正好是理解向量化优化的最佳教材。项目里的kddcup.data_10_percent_corrected_NOString_NOString是已经去掉字符串标签的数值化版本,适合直接做张量运算。这套代码适合正在做课设、需要交完整实验报告、以及想搞明白 KNN 在安全数据上怎么落地的人,新手能跟着跑通,老手也能在距离矩阵实现里看到可优化的细节。

2. KDD Cup 99 数据集清洗与特征数值化

2.1 原始数据里哪些列要处理

KDD Cup 99 原始数据集的每条记录包含 41 个特征和 1 个标签,其中前 9 个特征里有 3 个是符号型,分别是协议类型(protocol_type)、服务类型(service)、连接状态标志(flag)。符号型特征不能直接参与欧氏距离计算,所以项目里用的文件是NOString版本,也就是已经把这些列映射成数字后的结果,标签也从normalneptune这类字符串变成了数值,通常 0 表示正常,非 0 表示攻击。

除了字符串映射,原始数据里还有几个特征的值域差异特别大,例如src_bytes可能是 0,也可能是上百万,而count这类特征只在 0~511 之间。如果不做标准化,KNN 的距离计算会被大数值特征完全主导,最终结果几乎只看源字节数这一个字段。因此拿到NOString文件后,第一件事不是训练,而是逐列检查最大值、最小值和缺失值。

head -5 kddcup.data_10_percent_corrected_NOString_NOString.txt awk -F',' '{print NF}' kddcup.data_10_percent_corrected_NOString_NOString.txt | sort -u

第一行能看到每列是不是纯数字,第二行确认列数是否为 42(41 个特征加 1 个标签)。如果某个字段出现非数值内容,需要在进入 PyTorch 之前就改掉。这里不能直接读进 torch 再处理,因为torch.load遇到字符串会直接报错,数据类型的清洗应该放在numpy阶段完成。

项目里的test.txt_marked_corrected这类文件是已经标记过的测试集,通常用于最后验证模型泛化能力,不要拿它参与训练。工程里还包含mark_markedaa.txt_marked,这看起来是作者做数据标注时留下的临时输出,实际使用时可以忽略。

2.2 从文本到张量的预处理脚本

先读取 CSV,然后分离特征和标签,最后做标准化。下面这段代码可以直接替换成preprocess.py,在训练之前执行:

import pandas as pd import numpy as np import torch DATA_PATH = "kddcup.data_10_percent_corrected_NOString_NOString.txt" def load_and_preprocess(path=DATA_PATH, test_size=0.2): df = pd.read_csv(path, header=None, sep=",") X = df.iloc[:, :-1].values.astype(np.float32) y = df.iloc[:, -1].values.astype(np.int64) # 手动切分,避免随机化带来的标签泄漏争议 split = int(len(df) * (1 - test_size)) X_train, X_test = X[:split], X[split:] y_train, y_test = y[:split], y[split:] # 标准化:用训练集的均值方差,测试集只做转换 mean = X_train.mean(axis=0, keepdims=True) std = X_train.std(axis=0, keepdims=True) std[std == 0] = 1.0 # 防止常量特征除零 X_train = (X_train - mean) / std X_test = (X_test - mean) / std return (torch.from_numpy(X_train), torch.from_numpy(y_train), torch.from_numpy(X_test), torch.from_numpy(y_test))

这里有几个关键决策:切分时没有打乱数据,因为 KDD Cup 99 原始记录本身就是按时间顺序排列的,前面是早期攻击类型,后面是后期攻击类型,保持顺序可以让测试集更接近现实中的时间分布;标准化参数只从训练集计算,测试集沿用训练集的meanstd,这是最容易出错的地方。如果先对整个数据集做标准化再切分,等于让模型提前看到了测试集的分布信息,实验报告里写出的指标会虚高。常量特征的标准差为 0,不处理的话除零之后会得到 NaN,所以必须手动替换成 1.0。

astype(np.float32)也很重要。原始文件按双精度读入时内存占用会翻倍,49 万行 41 列的数据不算小,float32 在 PyTorch 里正好对应默认的torch.float32,后续张量运算不需要额外转换。

3. PyTorch 向量化实现 KNN 分类器

3.1 欧氏距离矩阵的显式展开

KNN 的核心是求每个测试样本与全部训练样本之间的距离。朴素写法是两层 for 循环,测试集 5 万个样本、训练集 40 万个样本,循环次数是 200 亿,在课程设计展示现场能跑到你怀疑人生。正确做法是直接用张量广播展开成距离矩阵。

对两个形状分别为(M, D)(N, D)的矩阵,欧氏距离的平方可以展开成sum(|x-y|^2) = sum(x^2) + sum(y^2) - 2*x^T y。用 PyTorch 写出来就是:

def pairwise_distances(x, y): # x: (M, D), y: (N, D) # 返回 (M, N),dist[i][j] 是 x[i] 到 y[j] 的平方距离 x_norm = (x ** 2).sum(dim=1, keepdim=True) # (M, 1) y_norm = (y ** 2).sum(dim=1, keepdim=True) # (N, 1) xy = torch.mm(x, y.t()) # (M, N) dist_sq = x_norm + y_norm.t() - 2 * xy dist_sq = torch.clamp(dist_sq, min=0) # 浮点误差可能产生极小负数 return dist_sq

这里没有直接开平方,因为k个最近邻居的排序结果在平方距离下完全一致,开平方只会增加计算量。keepdim=True保留了维度,让x_norm能跟(M, N)矩阵直接广播相加;clamp的作用是避免浮点误差导致负数的平方根。如果后续要换用曼哈顿距离或余弦距离,只需要替换这个函数,KNN 的主体结构不需要改。

在实际运行时会发现,(M, N)距离矩阵的体积是 M 乘以 N。以 40 万训练样本和 5 万测试样本为例,float32 距离矩阵占用的内存是400000 * 50000 * 4 / 1024 / 1024 / 1024 ≈ 74.5 GB,这是个人电脑无论如何都扛不住的。所以工程代码里一般不会一次把全部样本放进距离矩阵,而是把测试集分块,每块 1000 或 2000 条,循环完成预测。这个分块大小是内存和速度的平衡点。

3.2 Top-k 索引与多数投票

拿到距离矩阵后,对每一行取最小的 k 个距离对应的训练集索引,然后看这些索引对应的标签,用torch.bincount做多数投票。

def knn_predict_block(x_block, X_train, y_train, k=5): dist_sq = pairwise_distances(x_block, X_train) _, topk_idx = torch.topk(dist_sq, k=k, dim=1, largest=False) topk_labels = y_train[topk_idx] # (block_size, k) preds = torch.zeros(x_block.size(0), dtype=torch.int64) for i in range(x_block.size(0)): counts = torch.bincount(topk_labels[i]) preds[i] = torch.argmax(counts) return preds def knn_predict(X_test, X_train, y_train, k=5, chunk_size=1000): preds = [] for start in range(0, X_test.size(0), chunk_size): x_block = X_test[start:start + chunk_size] preds.append(knn_predict_block(x_block, X_train, y_train, k=k)) return torch.cat(preds)

torch.topk用的是快速选择算法,不需要对整个距离矩阵做全量排序,复杂度远低于sort。我这里用了一个 Python 循环来对每行做 bincount,老实说这不是最高效的写法,但胜在直观,答辩时讲起来很清楚。如果你在意速度,可以对topk_labels做 one-hot 展开后用矩阵乘法一次性完成投票:

def knn_predict_block_fast(x_block, X_train, y_train, k=5, num_classes=2): dist_sq = pairwise_distances(x_block, X_train) _, topk_idx = torch.topk(dist_sq, k=k, dim=1, largest=False) topk_labels = y_train[topk_idx] one_hot = torch.nn.functional.one_hot(topk_labels, num_classes) votes = one_hot.sum(dim=1) return torch.argmax(votes, dim=1)

这种写法把循环里的 bincount 变成了张量求和,GPU 上能快 20 倍左右。num_classes在二分类问题里是 2,如果 KDD 原始标签已经映射成了 20 多种攻击类型,就要改成对应类别数。

3.3 基于张量操作的完整类封装

为了跟 sklearn 风格保持一致,方便后续做网格搜索,我一般会把 KNN 包装成一个类:

class TorchKNN: def __init__(self, k=5, chunk_size=1000): self.k = k self.chunk_size = chunk_size self.X_train = None self.y_train = None def fit(self, X_train, y_train): self.X_train = X_train.contiguous() self.y_train = y_train.contiguous() return self def predict(self, X_test): preds = [] for start in range(0, X_test.size(0), self.chunk_size): block = X_test[start:start + self.chunk_size] dist_sq = pairwise_distances(block, self.X_train) _, idx = torch.topk(dist_sq, k=self.k, dim=1, largest=False) labels = self.y_train[idx] one_hot = torch.nn.functional.one_hot(labels, self.y_train.max() + 1) votes = one_hot.sum(dim=1) preds.append(torch.argmax(votes, dim=1)) return torch.cat(preds)

fit里没有真正学习参数,只做了引用保存,这正好说明 KNN 是惰性学习模型。contiguous()是为了确保后续矩阵乘法不会因为张量不连续而报错,在把切片传给模型时尤其重要。chunk_size这个参数决定了距离矩阵的峰值内存,如果测试时显存溢出,优先把它调小,而不是调 k。

4. K 值选择、评估指标与交叉验证

4.1 评估指标:检测率与误报率怎么算

入侵检测不是简单的准确率游戏,因为原始数据集中正常流量和攻击流量比例大约是 3:7,哪怕把所有流量都判成攻击,准确率也有 70%。课程设计如果只摆准确率,评委一眼就能看出你没理解业务。实际项目里更要关注的是检测率(召回率)和误报率。正常样本记为正类还是负类,不同论文里习惯不一样,我这里按常见做法把攻击样本作为正类,正常样本作为负类:

指标公式含义
检测率 DRTP / (TP + FN)真实攻击中被检出的比例,越高越好
误报率 FARFP / (FP + TN)正常流量被判为攻击的比例,越低越好
F12 * P * R / (P + R)查准率与检测率的调和平均
准确率 ACC(TP + TN) / 总数全局正确率,只作参考

KNN 在 KDD Cup 99 上做到 90% 以上的检测率并不难,难的是把误报率压到 1% 以下。因为不少攻击类型(比如 U2R、R2L)在特征空间里跟正常流量混在一起,邻居投票容易出现平票。这时候单纯调 k 效果有限,应该去看特征重要性,把没有区分度的特征删掉。项目源码里test.txt_marked_corrected这个文件就带了详细标签,可以直接统计每个攻击类别的检出情况。

4.2 网格搜索 K 值与距离度量

K 值太小容易受单个噪声样本影响,K 值太大会把类别边界抹平。在 49 万条数据上,K 通常在 3 到 15 之间比较合理。我用一个在线验证的流程来选 K:

def evaluate_model(model, X_val, y_val): preds = model.predict(X_val) tp = ((preds == 1) & (y_val == 1)).sum().item() fp = ((preds == 1) & (y_val == 0)).sum().item() fn = ((preds == 0) & (y_val == 1)).sum().item() dr = tp / (tp + fn) far = fp / (fp + tp) return dr, far for k in [3, 5, 7, 9, 11, 15]: model = TorchKNN(k=k, chunk_size=1000) model.fit(X_train, y_train) dr, far = evaluate_model(model, X_val, y_val) print(f"k={k}, DR={dr:.4f}, FAR={far:.4f}")

k=1时训练集上的准确率永远是 100%,因为每个样本的最近邻就是它自己,但这没有任何意义,不能用训练集误差来选 K。上面代码里单独留了验证集,k的最终选择可以画一条 FAR 随 K 变化的曲线。如果曲线在某个 K 值之后开始上升,说明进入了过平滑区间,再增大 K 只会让误报持续恶化。

4.3 训练/测试划分与标准化陷阱

很多课程设计在做划分时直接random_state=42打乱,但这在入侵检测里会引入两个问题:一是同一时间窗口内的流量被拆到训练集和测试集,会造成数据泄漏;二是 KDD Cup 99 的测试集刻意包含了训练集中从未出现过的攻击类型,随机采样会掩盖模型对未知攻击的泛化能力评估。本项目文件里保留了原始顺序的kddcup.data_10_percent_corrected_NOString_NOString,按前 80% 后 20% 切分,虽然简单,但至少避免了不同时间段样本混洗的争议。

标准化参数只从训练集统计。如果对全量数据标准化再切分,会让测试集的均值方差反过来影响训练样本的数值范围,KNN 的距离计算结果会轻微失真。我看到很多开源代码是先StandardScaler().fit_transform(X_all)再做train_test_split,这是错误示范。正确顺序必须先切分,再 fit,再 transform。

5. 工程结构与调参边界

5.1 工程文件说明与 Maven 包装器的作用

解压项目后你会看到pom.xmlmvnw,这不是多余文件。很多入侵检测课程设计会把流量预处理或者规则引擎部分写成 Java,再用 Maven 管理,模型部分用 Python 和 PyTorch 实现。mvnw是 Maven Wrapper,它会在没有全局安装 Maven 的情况下自动下载指定版本,保证环境可复现。实际运行前执行./mvnw package可以把 Java 端的预处理工具打好包,然后再跑 Python 端训练脚本。

工程目录里的aa.txt_markedmark_marked这类文件是中间标注产物,可以忽略,不影响模型训练。真正核心的就是两个部分:kddcup.data_10_percent_corrected_NOString_NOString数据文件和几个.py脚本。我习惯把数据文件放在项目根目录,Python 脚本放在src/main/python下,跟 Maven 的src/main/java分开,这样评审老师一眼能看出技术栈边界。

5.2 样本量、数据维度对 KNN 的性能影响

KNN 的时间复杂度是O(M * N * D),M 是测试样本数,N 是训练样本数,D 是特征维度。在原始 41 维特征上,10% 数据集的训练样本约 39 万,如果全量测试,单次预测的矩阵乘法也要跑很久。我实测下来,chunk_size 设为 2000 时,5 万测试样本的预测时间大约在 8 到 12 分钟,具体取决于 CPU 还是 GPU。如果做交叉验证,这个时间会翻倍,所以课程设计里一般只做一次验证集划分,而不是 5 折。

维度的影响更直接。41 维到 20 维,距离计算量减少一半,但如果用 PCA 降到过低维度,攻击流量之间的区分度也会下降。可以先跑一遍全量特征,再对比去掉src_bytesdst_bytes这两个极值特征后的结果。很多情况下,删掉这两个特征后误报率反而下降,因为极值特征让少数大流量样本成了孤立的邻居。

5.3 常见误用:用训练集统计量归一化测试集

我们前面已经强调过标准化参数泄漏,这里再补一个容易踩的坑:测试集在做标准化之前必须去掉标签列。如果读入时把标签一起标准化,标签值 0/1 会被缩放到 -0.2 到 1.2 之类的范围,预测阶段索引训练标签时会出现越界。源码里的NOString_NOString文件格式就是特征和标签全为纯数字,分离标签时建议用iloc[:, :-1],不要手写索引列号。

另一个常见问题是距离矩阵溢出。float32 在x_norm + y_norm.t()阶段可能出现数值放大,训练样本上亿次累加后平方和可能达到 10^8 量级,减法之后仍然在 float32 表示范围内,但开平方应避免。如果你发现预测结果全是同一个类别,先打印一下dist_sq的最小值和最大值,看看是不是标准化没有生效。

6. 部署时的验证技巧与加载新流量数据

课程设计交上去之前,我建议把整个流程固化成一个main.py,从数据加载到预测一条新记录都能在命令行里复现。重点不是写漂亮的 API,而是保证第二天打开电脑还能跑出同样的指标。

def preprocess_new_record(raw_values, mean, std): arr = np.array(raw_values, dtype=np.float32).reshape(1, -1) arr = (arr - mean) / std return torch.from_numpy(arr) def predict_single(model, x_new): dist = pairwise_distances(x_new, model.X_train) _, idx = torch.topk(dist, k=model.k, dim=1, largest=False) labels = model.y_train[idx] return torch.bincount(labels[0]).argmax().item()

这里的关键是把训练时算出的meanstd存成文件,下次加载新流量时直接复用,不要重新计算。KNN 没有显式的模型文件,但距离计算依赖完整的训练集,所以部署时必须把X_trainy_train一起保存。用torch.save保存训练集会被认为太原始,常见做法是把TorchKNN对象序列化,但 KNN 对象里包含整个训练矩阵,文件体积跟数据本身一样大,这是惰性学习模型的固有代价。

验证整条链路时,取训练集中的最后 1000 条样本,先跑一遍预测,确认准确率跟实验报告里的偏差小于 0.5%。然后随便构造一条[0, 0, 0, ...]全零记录,此时标准化后的向量完全落在所有特征的均值位置,预测结果应该是数量占优的那一类,如果输出跟多数类一致,说明代码逻辑没串联错。最后再检查一次内存:当训练样本数翻倍时,chunk_size 必须同步减半,否则距离矩阵占用会超过物理内存,表现为 PyTorch 报CUDA out of memory或者系统 swap 急剧增长。调小 chunk_size 是最简单可靠的兜底方案。

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

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

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

立即咨询