☰
基于kNN的单分类异常检测:原理、Matlab代码与调参实战
2026/10/17 15:14:34 网站建设 项目流程

开头先聊一个场景。某设备厂商的现场监测项目里,工程师手里攒了上千条正常运行数据,但故障样本几乎没有——设备常年稳定运行,故障数据只能靠运气碰。这时候传统分类器根本训不了,因为二分类需要正负两类样本,而眼前只有"正常"这一类。这就是单分类问题(one-class classification)的典型处境。而one-class kNN的思路极其朴素:正常样本在特征空间里应该密集聚居,新样本离这个聚居区太远,就判定为异常。这个算法不需要训练分类面、不需要核函数、更不需要反向传播,几行Matlab就能落地。本文就把这个算法的原理、完整代码、调参心得一次讲透。

1. 单分类问题与kNN的组合逻辑

1.1 单分类问题的定义和适用场景

单分类问题的数学描述很简单:训练集只有一类样本,记为 $X = {x_1, x_2, ..., x_N}$,全部来自同一类别(通常称为"正常类"或"目标类")。目标是对任意新样本 $x_{new}$,判断它是否属于这个类别。注意这里没有反例样本参与训练,所以本质上是在估计"正常类"在特征空间中的支持域(support domain),也就是划出一个边界:边界内的算正常,边界外的算异常。

这个设定和传统分类有本质区别。传统分类器学习的是类与类之间的决策边界,而单分类器学习的是单个类别的密度分布或几何轮廓。适用场景非常广泛:工业设备的异常检测、金融交易的风险识别、网络入侵检测、医疗影像中的病灶筛查、数据质量监控中的漂移检测等等。共同特征都是——"正常"样本容易大量获取,而"异常"样本要么极其稀少,要么根本不知道长什么样。

1.2 为什么选kNN而不是其他单分类方法

单分类领域其实已经有不少成熟方法。one-class SVM用核函数在高维空间找最大间隔超平面,隔离森林用随机树对样本做递归划分,基于密度的LOF算法计算局部离群因子。这些方法各有千秋,但kNN在工程落地中的优势非常明显:

  • 无训练过程。kNN严格来说没有什么"训练"环节,只是把训练样本存起来,预测时算距离。这对模型迭代、快速验证非常友好。
  • 参数少且直觉清晰。one-class SVM要调核函数类型、核参数、nu参数,隔离森林要调树的数量和采样比例,而one-class kNN核心参数就两个:近邻数k和距离阈值。
  • 可解释性强。拒绝一个样本时,能明确指出"它距离最近的k个正常样本平均多远",这个数字业务人员也能看懂。
  • 天然适配增量更新。现场确认某个新样本确实是正常样本后,把它塞进参考集即可,边界自然更新,不需要重新训练。

当然kNN也有短板,最大的问题是计算复杂度:每个新样本都要和全部训练样本算距离。但在工程实践中,一两万条参考数据用向量化计算毫秒级就出结果,完全够用。

1.3 整体设计思路:从"正常聚集"到"距离判定"

one-class kNN的核心逻辑链条是三句话:

  1. 同类样本在特征空间中距离近,异类样本距离远。
  2. 对每个训练样本,算出它到第k个最近邻居的距离,这个距离反映了"正常样本之间的典型间距"。
  3. 用这些距离的统计量(如95%分位数)作为阈值,新样本的k近邻距离超过阈值就判定为异常。

这里面有一个关键点:统计的对象不是单一距离,而是"第k近邻距离"。为什么选第k个邻居而不是最近的那个?因为最近邻距离受单个噪声点影响太大,用第k个邻居距离相当于做了一个平滑,对局部密度变化更鲁棒。我实测下来,k取5到20之间时,判定结果对k的敏感度不高,这个区间内通常都能得到比较稳定的边界。

2. 核心原理与关键参数解析

2.1 距离度量:欧氏距离的前提是特征归一化

距离度量方式直接影响算法效果。最常用的是欧氏距离,马氏距离能考虑特征相关性但需要计算协方差矩阵的逆,在高维小样本场景下数值不稳定。工程上优先用欧氏距离,但必须做归一化预处理。

为什么归一化是硬性要求?看一个例子:某个特征的范围是0到1,另一个特征的范围是0到1000,那么在欧氏距离计算中,第二个特征的差异会完全淹没第一个特征。这等于在做特征加权,而且权重由各特征的数值范围随机决定,完全不受控制。归一化之后让每个特征尺度一致,距离计算才真正反映几何邻近性。

我常用的归一化方式有两种:一种是z-score标准化,$x_{norm} = (x - \mu) / \sigma$,适合特征分布近似高斯的情况;另一种是min-max归一化到[0,1]区间,适合特征分布没有明显尾部的情况。注意一个坑:归一化参数(均值、标准差或最小值、最大值)必须在训练集上计算,然后用同一套参数变换测试集,绝对不能让测试集参与归一化参数的估计,否则会有信息泄露。

2.2 k值选择:不是越大越好,也不是越小越灵敏

k值代表"参考多少个邻居来评估局部密度"。k太小时,判定依据过于局部,个别离群点或噪声点会把正常样本的第k近邻距离拉大,导致误报率升高;k太大时,局部信息被稀释,边界会过度平滑,原本清晰的异常点可能被远处的正常群体"拉回来",导致漏报率升高。

从经验来看,我给一个实用调参范围:特征维度低(二维三维)时k取5到10,特征维度中等(十维左右)时k取10到20,高维场景下k可以继续增大,但一般不超过训练样本量的5%。还有一个通用技巧:先固定k在10附近跑一遍,再看分类效果微调,而不是从头到尾盲试。

2.3 阈值确定:分位数法与均值方差法

阈值是从训练集内部的k近邻距离分布中提取的。统计出N个训练样本各自的k近邻距离后,有两种常用方案:

分位数法:取距离分布的p分位数作为阈值。比如95%分位数意味着训练样本中95%的正常样本会被正确接收,5%的正常样本会被误报为异常。这个方法直观可控,业务上可以直接对口误报率目标。

均值加倍数标准差法:$threshold = mean(k_dist) + \alpha \times std(k_dist)$。$\alpha$通常取2到4之间。这个方法假设距离分布近似高斯,如果数据分布偏斜严重会偏差较大。

我自己的习惯是两种都算,然后放在一起对比。如果差得不多,说明距离分布比较规整;如果差很多,说明训练集里可能有离群样本,先清洗数据再定阈值。另外,如果手头有一小批标注好的异常样本(哪怕数量很少),可以做ROC曲线选阈值,找误报率和召回率的平衡点。在3.3节我会给出完整实现。

2.4 高维场景下的降维预处理

维度诅咒在kNN类算法中表现很突出。维度高时,样本之间距离趋向一致,第k近邻距离的区分度急剧下降。我做过一个实验:在100维的随机数据上,所有样本距离几乎都在同一个量级,阈值完全失去判别力。所以特征维度超过30时,强烈建议先降维。

实践中可以先用主成分分析(PCA)降到10到20维保留主要方差,或者用t-SNE/UMAP做可视化辅助分析。但注意一点:在训练集上做PCA变换矩阵,测试集要乘以同一个变换矩阵,和归一化参数同理,不能重新拟合。

3. Matlab完整实现与代码解析

3.1 训练阶段:计算参考距离与阈值

训练阶段的核心是计算训练集内部成对距离矩阵,提取每个样本的第k近邻距离,再统计出阈值。Matlab里用pdist2一步到位,向量化算距离非常快。来看完整实现:

function model = trainOneClassKNN(X_train, k, quantileVal) % X_train: 训练样本矩阵, N x D, 每行一个样本 % k : 近邻数量, 建议取值 5~20 % quantileVal: 分位数值, 0~1 之间, 如 0.95 % 返回值 model: 包含参考集、参数和阈值的结构体 % 归一化参数在训练集上估计 mu = mean(X_train, 1); sigma = std(X_train, 0, 1); sigma(sigma < 1e-12) = 1; % 防止零方差特征导致除零 X_norm = (X_train - mu) ./ sigma; % 计算训练集内部成对欧氏距离 D = pdist2(X_norm, X_norm, 'euclidean'); % 每行排序, 自身距离为0排第一, 跳过自身取第k+1个值 D_sorted = sort(D, 2); k_dist_train = D_sorted(:, k + 1); % 用分位数确定阈值 threshold = quantile(k_dist_train, quantileVal); % 保存模型 model.X_ref = X_norm; model.k = k; model.threshold = threshold; model.mu = mu; model.sigma = sigma; end

训练阶段的关键细节在于跳过自身距离。每个样本和自己的距离为0,在排序后必然排在第一位。如果直接取D_sorted(:, k),取到的其实是第k-1个邻居。所以必须取第k+1列,才能正确得到"第k个最近邻居的距离"。这是新手最容易踩的坑。

3.2 预测阶段:距离计算与判定输出

预测阶段要做的事情是:把新样本用训练集的归一化参数变换,计算它到所有参考样本的距离,取第k个最近距离,与阈值比对。小于等于阈值判定为正常,大于阈值判定为异常。

function pred = predictOneClassKNN(model, X_test) % model : trainOneClassKNN 返回的结构体 % X_test: 测试样本矩阵, M x D % pred : 逻辑向量, true 表示正常, false 表示异常 % 用训练集的统计参数做归一化 X_norm = (X_test - model.mu) ./ model.sigma; % 到所有参考样本的欧氏距离 D = pdist2(X_norm, model.X_ref, 'euclidean'); % 新样本不包含自身, 第k个最近距离就是排序后的第k个值 D_sorted = sort(D, 2); k_dist_test = D_sorted(:, model.k); % 与阈值比对 pred = k_dist_test <= model.threshold; end

这里注意训练集和测试集在第k近邻取值上的对称性:训练样本算的是到其他N-1个训练样本的距离,因为包含自身所以要跳一位;测试样本算的是到N个训练样本的距离,不含自身,所以直接取第k个。两边的统计口径完全一致,这个对应关系必须保证,否则阈值会系统性偏移。

3.3 完整示例脚本:从模拟数据到可视化

用一个二维合成数据集演示全流程。生成两类数据:正常样本聚集在一个区域,异常样本出现在远处。跑训练、预测、评估、可视化。

%% 生成模拟数据 rng(42); % 训练集: 200个正常样本, 中心在 (2, 2) X_train = randn(200, 2) * 0.8 + [2, 2]; % 测试集: 80个正常样本 + 40个异常样本 X_test_normal = randn(80, 2) * 0.8 + [2, 2]; X_test_anomaly = randn(40, 2) * 1.5 + [7, 7]; % 远离正常区域 %% 训练模型 k = 10; quantileVal = 0.95; model = trainOneClassKNN(X_train, k, quantileVal); %% 预测 pred_normal = predictOneClassKNN(model, X_test_normal); pred_anomaly = predictOneClassKNN(model, X_test_anomaly); %% 评估 TP = sum(pred_normal); % 正常样本被正确接受 FN = sum(~pred_normal); % 正常样本被误报为异常 TN = sum(~pred_anomaly); % 异常样本被正确拒绝 FP = sum(pred_anomaly); % 异常样本被漏报为正常 fprintf('正常样本正确率: %.2f%%\n', TP / length(pred_normal) * 100); fprintf('异常样本检出率: %.2f%%\n', TN / length(pred_anomaly) * 100); %% 可视化 figure('Position', [100 100 560 420]); hold on; scatter(X_train(:,1), X_train(:,2), 20, [0.5 0.5 0.9], 'filled'); scatter(X_test_normal(:,1), X_test_normal(:,2), 30, [0.3 0.7 0.3], 'o'); scatter(X_test_anomaly(:,1), X_test_anomaly(:,2), 40, [0.9 0.3 0.3], 'x'); legend({'训练集(正常)', '测试正常样本', '测试异常样本'}, 'Location', 'best'); grid on; xlabel('特征1'); ylabel('特征2'); title('one-class kNN 模拟数据可视化');

跑完这个脚本,正常样本正确率应该接近95%,异常样本检出率应该接近100%,因为模拟数据里正常和异常区域的间距非常大。如果数据重叠区域多,检出率就会明显下降,这是数据本身可分性的问题,不是算法的锅。

3.4 边界可视化:画一条判定边界

二维场景下,把整个平面的判定区域画出来非常直观。做法是在坐标平面上撒密集网格点,对每个网格点调用预测函数,用颜色区分正常/异常区域。

%% 绘制决策边界 xmin = min(X_train(:,1)) - 2; xmax = max(X_train(:,1)) + 2; ymin = min(X_train(:,2)) - 2; ymax = max(X_train(:,2)) + 2; [xg, yg] = meshgrid(linspace(xmin, xmax, 200), linspace(ymin, ymax, 200)); grid_points = [xg(:), yg(:)]; % 批量预测网格点 pred_grid = predictOneClassKNN(model, grid_points); pred_grid = reshape(pred_grid, size(xg)); figure('Position', [100 100 620 480]); hold on; imagesc(xg(1,:), yg(:,1), pred_grid); colormap([0.95 0.2 0.2; 0.2 0.75 0.95]); % 红色区域=异常, 蓝色区域=正常 scatter(X_train(:,1), X_train(:,2), 20, 'w', 'filled'); scatter(X_test_anomaly(:,1), X_test_anomaly(:,2), 40, 'k', 'x'); axis tight; title('one-class kNN 决策边界');

边界可视化的价值在于快速判断"阈值是否合理":如果红色区域把训练样本之间的空隙都填满了,说明阈值偏大,区分能力弱;如果正常样本边缘紧贴的样本都落在红色区,说明阈值偏小,误报率会偏高。调阈值时一边调一边看边界图,比只盯数字指标直观得多。

4. 调参技巧、性能评估与实践对比

4.1 参数联动:k和阈值必须一起调

k和阈值不是独立参数,它们之间存在联动关系。k增大时,每个样本的第k近邻距离会变大(因为第k个邻居比第k-1个更远),距离分布整体右移,如果阈值保持不变,误报率会下降但漏报率会上升。所以改动k之后,阈值必须重新从训练集统计。

我建议的调参流程是:固定k为10,先用分位数法选一个阈值,跑一遍评估指标;然后按5的步长升降k,每轮重新算阈值再评估;取整体准确率最高的那组参数。如果评估结果差异不大,选更小的k,因为小k的局部灵敏度更高,边界更细致。

4.2 评估指标:不要只盯准确率

单分类场景下类别极端不平衡,准确率有很强的迷惑性。假设异常率只有1%,一个把所有样本都判为正常的"模型"准确率是99%,但完全没有检测能力。要重点关注三个指标:

  • 正常样本召回率(Recall正常)= TP / (TP + FN),衡量误报率。
  • 异常样本检出率(Recall异常)= TN / (TN + FP),衡量漏报率。
  • 综合评价,可以用两者的调和平均或F1分数。

业务场景决定了指标的优先级。设备故障检测场景,漏报一台故障设备可能造成重大损失,所以异常检出率优先,宁可牺牲一点误报率;垃圾内容过滤场景,误报一个正常用户可能引起投诉,所以正常召回率优先。调阈值时直接用这两个指标指导分位数取值。

另外,如果手头有少量带标签的异常样本,可以画出ROC曲线,用约登指数(Youden's J = 灵敏度 + 特异度 - 1)选择最佳阈值点。这一步能让阈值选择从"拍脑袋"变成"有依据"。

4.3 与其他单分类算法的横向对比

跑过一个对比实验,在同样的合成数据上比较one-class kNN、one-class SVM和隔离森林(Isolation Forest),记录三类数据:二维易分数据、十维中维数据、含5%噪声污染的数据。

方法二维易分数据十维数据含噪声数据参数数量训练耗时
one-class kNN表现优秀需要降维容易受噪声干扰2个无训练(仅存数据)
one-class SVM表现优秀表现优秀较稳健3个以上中等
隔离森林良好表现优秀较稳健2个快

结论是:one-class kNN的优势不在"最强精度",而在"最简单可靠"。它对数据分布没有假设,不依赖核函数选择,调参门槛低。数据干净、维度不高时,它就是最省心的答案。数据处理量特别大(十万级以上)且对计算耗时敏感时,隔离森林更快。高维复杂分布场景,one-class SVM配RBF核往往精度上限更高,但调参成本也更高。

4.4 训练集质量决定算法上限

所有基于距离的方法都有一个共同软肋:对训练集里的噪声极其敏感。如果训练样本里混入了几个异常样本,它们会拉大正常样本的k近邻距离,导致阈值整体抬高,边界向远处扩张,最终把真正的异常也包进去。

最简单的清洗办法:用训练阶段算出来的k_dist_train做检查,选取一个宽松的拒绝线(比如99.9%分位数),把距离特别大的训练样本剔除后再重建模型。也可以把距离降序排列画一个肘点图,距离忽然飙升的那个点之后就是可疑样本,人工确认后剔除。这一步做完,模型边界会明显收紧。

5. 常见问题与排查技巧实录

5.1 边界总是太松,异常样本漏报严重

现象:异常样本的k近邻距离和正常样本差距不大,阈值压不下去。

排查思路分三步。第一步检查归一化,确认所有特征尺度一致,是否存在某个方差特别大的特征主导了距离计算。第二步检查特征维度,如果维度超过30,先做PCA降维再跑。第三步检查训练样本数,如果N太小(少于100),距离分布不够稳定,可以采集更多正常数据或者把k调小让局部信息更突出。

我遇到过一次很有意思的情况:某个特征在归一化后仍然区分度极低,几乎全是噪声,但在欧氏距离里它照常贡献随机波动,有效稀释了有用特征的权重。把这列特征删除后,边界质量立刻提升。所以做特征筛选比调参更优先。

5.2 边界太紧,正常样本大量误报

现象:明明是从生产线上正常采样的数据,却被判成异常。

这个问题的常见原因有两个。一是训练数据覆盖不全面:设备运行有多个工况(空转、轻载、满载),训练集里只包含了一个工况,其他工况的数据自然落在边界外。解决方案是确保训练集覆盖所有正常运行模式,混合工况后再训练。二是阈值分位选得太低,95%分位数严格来说允许5%的正常样本落在界外。如果业务对误报零容忍,要把分位数提高到99%甚至更高,或者用均值加较高倍数标准差的方案。

这里给一个从实践里验证过的经验:阈值需要留一定余量,因为现场数据的分布通常比训练集更广。训练集上把误报率压到1%的阈值,到现场跑一个月,实际误报率可能到3%到5%。初期上线建议把阈值放宽10%左右,跑一段时间收集真实分布后再收紧。

5.3 训练集混入少量异常样本却不自知

这个问题隐蔽性很强。可以用迭代清洗的思路处理:第一轮用原始训练集训练模型,把预测为异常的训练样本挑出来,人工审查。如果确认是噪声或异常,剔掉后重新训练。这样迭代两三轮,训练集质量会显著提升。如果全部训练样本都被判为正常,说明初始阈值太宽,先降低分位数值再迭代。

5.4 计算量过大,预测太慢

pdist2直接算全量距离矩阵,训练集N=10000时,距离矩阵是10000乘10000,需要800MB内存(double类型),这还算能接受,但N到了50000就跑不动了。两个解决思路:

一是分块计算。把训练集切成块,逐块算距离再取最小k个值,避免一次性生成超大矩阵。

二是随机采样参考集。在参考集里随机抽5000个样本用于距离计算,控制精度损失。我实测过,5000个参考样本对一个20000规模的训练集做采样,检出率大概从98%降到96%,对大多数场景是可接受的。如果需要更精细,可以先聚类再按簇采样,让参考集保留分布结构。

5.5 归一化参数更新带来的坑

现场模型要持续运行,时不时会有新确认的正常样本想加入参考集。注意:如果新样本加入后重新计算归一化参数(均值和标准差),所有旧样本的距离都会被整体缩放,模型边界会漂移,影响线上服务的稳定性。

工程上的处理方式有两种。一是冻结归一化参数,新样本直接用旧参数变换后加入参考集,这样边界只会局部细化,不会全局漂移。二是定期离线重训,选定一个时间窗口重新估计归一化参数并更新整个模型,更新窗口内保持旧模型服务。这两个方案里我推荐第二种,定期重训更稳妥,冻结参数的方式短期能用,跑久了训练集和参考集特征分布差距越拉越大,还是得重来。

收尾:几个让我印象深刻的实践体会

这个算法最打动我的地方在于可调试性。有一回现场反馈某个型号的产品误报率偏高,我把特征数据拉出来画了个二维边界图,一眼就看到问题不在算法本身,而是训练数据里漏了一个低速空转工况。把工况数据补上之后,误报率直接降了一个数量级。这种问题用黑盒模型很难排查,基于距离的模型天然可以追溯到"这个样本离最近的正常样本多远、周围的人是谁",定位根因非常快。

再分享一个小技巧:上线之后可以把每个样本的k近邻距离存成时序曲线。正常情况下这个距离应该在一个稳定水平,一旦现场的设备性能缓慢退化,k近邻距离会逐渐漂移变大,这个趋势往往比突发异常提前很长时间出现。等于这个算法不光能做状态判定,还能兼职做趋势预警,算是意外收获。

如果后续数据量涨到十万级,建议保留kNN的思路但把参考集换成聚类中心加簇半径的压缩形式,或者直接切入隔离森林。无论怎么演进,one-class kNN作为一套容易理解、容易落地、容易排查的方案,特别适合做项目的第一个线上版本,先把基线跑起来,再逐步精进。

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

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

立即咨询