2026/10/17 21:08:28

单分类kNN实现设备异常检测:原理、Matlab代码与阈值调参实践

单分类kNN实现设备异常检测:原理、Matlab代码与阈值调参实践 做设备状态监测这类需求时最容易卡住的位置就是手里有大量正常样本但异常样本几乎没有。你说数据不平衡其实连不平衡都算不上——压根只有一类数据。普通的kNN分类器在这种场景里直接失灵因为它需要一个非正常类来投票。后来我仔细翻了单分类的思路用 one-class kNN 只拿正常样本建模把跟正常数据离得太远的样本统统判成异常问题一下就顺了。这篇文章就把其中涉及的核心原理、Matlab实现、阈值设定和评估方法完整捋一遍也把调参时踩过的几个坑一并写出来。1. 普通kNN分类器搞不定单分类问题从哪里来单分类问题的出现非常自然。设备状态监测里正常运行记录可以攒几十万条但是故障停机样本可能一年也攒不出来几条工业质检中良品照片到处都有不良品的种类却可能还在扩增日志异常检测也一样正常请求是海量的攻击流量是稀疏且不断变种的。所有这些场景的共同点是你只能拿到一个类别的样本却希望模型能识别出不属于这个类别的东西。这种问题的本质不是判断这个是A还是B而是判断这个是不是A。后者不需要定义B长什么样只需要把A的分布范围描述清楚。落在范围之内就是正常超出范围就报异常。听起来比多分类简单但如果拿任务硬套普通kNN分类器就会发现完全走不通。普通kNN的工作机制是投票一个新样本来了看它在特征空间中最近的k个训练样本分别属于哪些类少数服从多数。这个机制天然要求训练集里必须有多个类别的标签。现在标签只有正常一个值投票就没有第二方参与分界也就无从谈起。有人试过把所有样本都标成正常然后用fitcknn直接训练一个模型。结果是predict吐出来的永远是正常这一个类后验概率恒等于1完全没有判别能力。原因也不难理解分类器内部根本没有异常这个概念可以用来竞争。所以单分类问题需要换一个角度理解kNN——不是让样本去匹配某个类别而是衡量样本距离已知的正常区域有多远。总结一下one-class kNN 的核心思路就是一句话一个样本周围如果有足够多已知正常样本它大概率是正常的如果它附近空空荡荡说明已经离开正常数据密集的区域了。把这个距离算出来再定一个合理的防线异常识别就成立了。2. 核心判别逻辑平均k近邻距离为什么能当陌生感指标2.1 算法流程与异常得分定义正式定义前先约定符号。训练集是n个正常样本的特征矩阵每个样本是d维向量。对任意一个待测样本xone-class kNN 做这样几件事计算x到训练集里所有样本的距离取出其中最小的k个距离记为 d_(1), d_(2), ..., d_(k)计算平均距离作为异常得分 s(x) (1/k) * Σ d_(i)如果 s(x) 大于预先设定的阈值 θ判为异常否则判为正常。为什么平均距离可以衡量陌生感因为在数据分布正常的区域样本之间的间距小任意一个正常样本周围都能找到不少紧邻的训练样本取平均后距离自然很小。反过来一个异常样本跑到了正常数据的稀疏地带它最近的k个正常邻居也要跑很远才能找到平均距离就会明显拉大。这里有一个容易被忽视的步骤阈值θ也要从训练数据中来。具体做法是对每个训练样本也计算它到其他训练样本的平均k近邻距离得到n个训练得分。这n个训练得分的分布决定了正常数据的距离长什么样阈值就从这里面取。所以整个流程分成训练阶段算训练得分和推理阶段算测试得分两个部分缺一个都没法落地。2.2 一个手算例子看懂平均距离的含义举一个一维的极简例子。训练集有10个正常样本数值都在0.1到0.3之间彼此挨得很近。现在来两个测试样本a0.15b1.8。取k3a距离最近三个训练样本的距离大约是0.05、0.05、0.08平均约0.06b距离最近三个训练样本的距离大约在1.5到1.7之间平均约1.6。同样的计算口径下a的得分是0.06b的得分是1.6两者相差一个数量级。这就是局部密度在距离上的表现正常样本处在密集区邻居近异常样本处在稀疏区邻居远。从这个例子也能看出一个关键点真正起作用的是局部而非全局。如果把所有训练样本作为一个整体来进行密度估算某些正常样本因为处在整个分布的边缘全局密度偏低容易被误判。但kNN只看最近的k个邻居边缘样本仍然有自己较近的邻居不会被冤枉对复杂分布形状的适应能力比全局阈值方法强得多。2.3 为什么不用最近邻距离也不用最远邻距离既然是想衡量和正常人群的距离为什么不直接取最近一个邻居的距离k1或者取第k个邻居的距离最近邻距离的问题在于太不稳定。单个最近邻居可能是一个离群点或者采集噪声一个样本哪怕整体处在正常区域内部只要最近的那个邻居恰好是个异常噪点得分就会被拉高。取k个邻居做平均相当于给距离做了一次平滑抗噪性明显更好。只取第k个邻居也就是最远的那个近邻距离同样不稳。它只代表离第k近的正样本有多远丢失了前k-1个邻居的信息。两个得分可能完全相同但其中一个周围还有大量稍远一点的正常样本另一个周围除此之外就没有别的邻居了密度完全不同。平均距离把这些信息都保留下来了。当然平均距离也不是唯一选择。有些近邻密度估计方法会把k除以第k距离对应的球体积得到的是局部密度而非距离。那个思路在数学上更接近核密度估计但计算和解释都比平均距离复杂。单分类kNN之所以常用平均距离最大的优势就是直观、稳定、实现成本低。这也是我在工程里最看重的三点。3. Matlab实现骨架knnsearch怎么用训练得分为什么不能省3.1 前置条件工具箱依赖与数据准备用Matlab写one-class kNN核心函数是knnsearch它属于统计与机器学习工具箱。这个函数做的事情就是给定查询点集合和训练点集合返回每个查询点在训练点集合中的k个最近邻索引和距离省去手动写双重循环的麻烦。数据准备阶段要特别强调一件事标准化。因为kNN本质依赖距离计算如果两个特征的单位差着数量级——比如一个特征波动在0到1之间另一个特征波动在1000到5000之间——欧氏距离会被大量纲特征整个主导小量纲特征的作用就被淹没了。所以通常要对每个特征做z-score标准化公式是(x-mu)/sigma。mu和sigma只用训练集的统计量测试样本不能参与计算否则会造成信息泄露。3.2 用knnsearch实现训练得分与推理得分下面是整个实现的骨架代码。clear; close all; % 假设 Xtrain 是 n x d 的正常样本特征矩阵 % Xtest 是 m x d 的待测样本特征矩阵 % 标准化只从训练集统计 mu mean(Xtrain); sigma std(Xtrain); Xtrain_norm (Xtrain - mu) ./ sigma; Xtest_norm (Xtest - mu) ./ sigma; k 5; % 训练阶段计算每个训练样本到其他训练样本的 k 近邻距离 % K 取 k1因为第一个近邻是它自己距离为 0需要剔除 [idxTr, distTr] knnsearch(Xtrain_norm, Xtrain_norm, K, k 1); trainScores mean(distTr(:, 2:k1), 2); % 测试阶段计算每个测试样本到训练集的 k 近邻距离 [~, distTe] knnsearch(Xtrain_norm, Xtest_norm, K, k); testScores mean(distTe, 2); % 阈值取训练得分分布的 95% 分位数 th quantile(trainScores, 0.95); predLabel double(testScores th); % 1: 异常, 0: 正常代码很短但有两处不能省。第一处是训练阶段必须算训练样本自身的近邻距离因为阈值不能拍脑袋定它必须基于正常数据的距离分布。第二处是knksearch的K取k1因为当查询集和训练集是同一个集合时每个样本的第一个最近邻一定是自己距离为0这一列必须丢掉否则训练得分会被0严重拉低。推理阶段要注意测试得分走的是另一个流程直接用训练集作为搜索库对每个测试样本找k个最近邻不需要再加1因为测试样本不在训练集里不存在自己的问题。3.3 没有统计与机器学习工具箱时的替代方案如果环境受限没有knnsearch可以考虑用pdist2自己算距离矩阵。pdist2同样属于统计工具箱但它更底层很多基础教学环境自带。% 测试得分pdist2 计算测试集到训练集的成对距离 Dte pdist2(Xtrain_norm, Xtest_norm); Dsorted sort(Dte, 1); testScoresManual mean(Dsorted(1:k, :), 1); % 训练得分自己算训练集内部的成对距离需要排除对角线 nTr size(Xtrain_norm, 1); Dtr pdist2(Xtrain_norm, Xtrain_norm); trainScoresManual zeros(nTr, 1); for i 1:nTr drow Dtr(i, :); drow(i) Inf; % 排除自身 ds sort(drow); trainScoresManual(i) mean(ds(1:k)); end如果连pdist2也没有那就手动写一个欧氏距离函数两层循环就能算只是大数据量下速度会比较感人。单分类kNN的代码量本来就不大这个方案可以作为兜底但实际项目里还是建议直接上knnsearch。4. 阈值设置从分位数到验证集校准一层层讲清楚4.1 阈值的第一种设定训练得分分位数阈值是整个模型的决策边界它决定了误报率和漏报率之间怎么权衡。最常用的方法是取训练得分分布的某个分位数训练完成后得到n个训练得分把阈值设为sortedScores里第95%或99%的位置。这么做的直觉是我们在主动让模型忘掉一小部分训练样本。模型不可能完美覆盖正常数据的全部形态训练样本里总有一些处在边界上的特例如果阈值顶到最大值模型会对正常数据过度贴合一点点噪声都会触发报警实际用起来会非常脆弱。取95%分位数的意思是允许5%的训练样本被划到边界之外相当于给边界留了余量。代码实现很简单除了用quantile也可以手动排序后按下标取sortedScores sort(trainScores); idxTh ceil(0.95 * numel(sortedScores)); thManual sortedScores(idxTh);这个0.95不是神圣数字。正常数据越稳定可以适当提高到0.98或0.99噪声越多可能要降到0.9。实际项目里一般先把0.95当起点然后用历史数据或验证集往回验证。4.2 阈值的第二种设定均值加若干倍标准差另一种常见做法是取训练得分的均值加上若干倍标准差例如 th mean(trainScores) 3 * std(trainScores)。这个方法的隐含假设是训练得分近似服从正态分布3倍标准差大约覆盖99.7%的样本。但这个假设在单分类场景里经常不成立。训练得分的分布通常是右偏的大多数正常样本分数很低少数边缘样本分数偏高。用均值加标准差去刻画这种分布效果很不稳定而且容易被几个高分值的噪声样本影响。相比之下分位数对分布形态不敏感看的是排序位置所以我的经验是一般优先用分位数均值加标准差可以作为快速尝试的手段但不要当成默认方案。4.3 用少量已知异常样本校准阈值如果手头真的攒了一小批带标签的异常样本即使只有几十个阈值设定也完全可以做得更精细。做法是遍历一个候选阈值序列对每个阈值统计在验证集上的误报率和召回率然后挑一个误报可控、召回尽量高的点。thVec linspace(min(trainScores), max(trainScores), 200); fpRate zeros(size(thVec)); tpRate zeros(size(thVec)); for ii 1:numel(thVec) pred testScores thVec(ii); fpRate(ii) mean(pred(ytest 0)); % 正常样本被误报的比例 tpRate(ii) mean(pred(ytest 1)); % 异常样本被抓住的比例 end % 简单选点比如在误报率不超过5%的前提下选召回率最高的阈值 candidateIdx find(fpRate 0.05); [~, bestIdx] max(tpRate(candidateIdx)); bestTh thVec(candidateIdx(bestIdx));这个流程还可以进一步衍生出最小误报率下的最大召回或者F1最优点等不同选点策略具体看业务更在意哪个指标。工业报警场景通常宁可多报几次假警也不愿意漏掉真故障那阈值就可以适当往左压。5. 效果评估ROC曲线与可视化让模型好坏一目了然5.1 别只看准确率不平衡数据下的评估陷阱one-class kNN天然面对极度不平衡的数据因为异常样本相对正常样本几乎可以忽略。如果只看准确率一个全部判正常的模型也能轻松获得99%以上的准确率但它完全没有识别异常的能力。所以评估必须换一套指标。最基础的两个指标是真正例率TPR也叫召回率和假正例率FPR。TPR衡量的是真正的异常有多少被抓住了FPR衡量的是正常的样本有多少被误伤了。业务上真正关心的是这两个数而不是准确率。5.2 用perfcurve计算ROC与AUCROC曲线就是把阈值从最大值一路扫到最小值记录每个阈值下的FPR和TPR然后把点连起来。AUC是曲线下的面积它的含义是随机抽一个异常样本和一个正常样本模型给异常样本打更高分的概率。AUC越接近1说明分数排序能力越强。Matlab里直接用perfcurve就能完成整个计算% ytest 是真实标签0 表示正常1 表示异常 % testScores 是异常得分 [xroc, yroc, ~, auc] perfcurve(ytest, testScores, 1); figure; plot(xroc, yroc, LineWidth, 1.8); xlabel(False Positive Rate); ylabel(True Positive Rate); title(sprintf(ROC Curve, AUC %.3f, auc)); grid on;注意perfcurve的第三个参数是正类的标签值这里异常是1所以传1。它的返回结果里x是FPRy是TPR别搞反了。再看曲线形状时左上角越突出越好。实际调参时我不会只看AUC一个数还会盯着在某个给定FPR下的TPR比如FPR不超过5%时TPR能到多少这个数值在工程落地里更有意义。5.3 可视化二维特征下的决策边界观察如果原始特征只有两维或者降到了两个主成分可以把决策边界画出来这样对模型行为的理解会直观很多。方法是在平面上铺一块网格对每个网格点计算异常得分再利用contour把等于阈值的等高线画出来。% 假设只用两个特征维度做可视化 featureIdx [1, 2]; xRange linspace(min(Xtest_norm(:, featureIdx(1))), max(Xtest_norm(:, featureIdx(1))), 80); yRange linspace(min(Xtest_norm(:, featureIdx(2))), max(Xtest_norm(:, featureIdx(2))), 80); [xx, yy] meshgrid(xRange, yRange); gridPts [xx(:), yy(:)]; % 对网格点计算kNN异常得分 [~, distGrid] knnsearch(Xtrain_norm(:, featureIdx), gridPts, K, k); gridScore mean(distGrid, 2); figure; contour(xx, yy, reshape(gridScore, size(xx)), [th th], LineWidth, 2); hold on; plot(Xtrain_norm(ytest 0, featureIdx(1)), Xtrain_norm(ytest 0, featureIdx(2)), o, MarkerSize, 4); plot(Xtrain_norm(ytest 1, featureIdx(1)), Xtrain_norm(ytest 1, featureIdx(2)), x, MarkerSize, 6); legend({决策边界, 正常样本, 异常样本}, Location, best);如果数据实际不止两维就先对训练样本做PCA降到两维再按同样的投影矩阵去变换测试样本和网格点。可视化最大的价值是帮助你快速确认边界是不是大致贴住了正常区域的轮廓异常的聚集区是不是真的被圈在了边界外。如果边界形状完全不符合直觉多半是特征缩放或k值选择出了问题。6. 调参与避坑标准化、k值、高维距离失效这些事6.1 标准化差一个量纲距离就全乱套这是最常见的坑没有之一。我见过不少初学的实现特征矩阵里温度是0到100的数压力是0到5000的数直接喂给knnsearch。结果就是温度这个特征几乎不参与距离计算模型只按压力来分效果可想而知。标准化的正确姿势是先在训练集上算出mu和sigma然后训练集、测试集、未来实时数据全部沿用这一对参数。不要在测试集上重新算mu和sigma否则测试样本的分布信息就混进了特征转换里评估结果会虚高。6.2 k的选择太大太小都不行k是one-class kNN里最核心的超参数。k太小比如k1模型对单个邻居非常敏感一个噪声点就能让正常样本得分剧烈波动k太大平均距离会趋近于全局平均距离局部密度信息被抹平单分类的优势就没了。实践中我一般从k5开始小数据集上也可以试试k1、3、5、10、20这个序列用验证集AUC和误报率来选。大致规律是k太小的时候漏报和误报复现性差k逐渐增大后分数更稳但k超过样本量的1/10左右以后AUC往往开始回落。下表是我在某个二维特征测试集上看到的典型趋势具体数值随数据变化但趋势很常见k值验证集FPR验证集TPRAUC10.080.920.9530.050.950.9750.040.970.98100.030.930.96200.030.890.94工程上不必追求极致参数选一个U型曲线中间偏稳的位置就好。6.3 重复样本、高维数据和计算加速的坑训练集里如果存在大量重复样本k近邻距离会失真。设想某个正常样本在训练集中重复了10次那它无论怎么找最近邻距离都是0训练得分被严重低估整个阈值分布也跟着变形。所以建模前先做一步去重可能的话再剔除掉明显异常的噪声样本。高维数据则是另一个让人头疼的问题。特征维度一旦到了几十甚至上百欧氏距离会趋向集中就是说样本间最大距离和最小距离的差异变小异常和正常的距离分不开。这种情况可以先做PCA降维到20维以内再算kNN或者改用马氏距离。如果样本量远小于维度协方差矩阵也不可逆降维基本是必选项。大数据量下还有个计算问题knnsearch的默认搜索方法在数据量和维度不同的情况下表现差异很大。低维大数据建议用kd-tree高维数据kd-tree会退化不如直接用全搜索。可以用NSMethod参数显式指定对小规模数据集这一步影响不大但样本量过万以后值得注意。6.4 与SVDD、孤立森林的简单对比kNN的优势与代价做完one-class kNN不妨也了解一下同类方法各自的位置。支持向量数据描述SVDD需要调核函数和正则参数边界更灵活但模型解释性差孤立森林通过随机切分来隔离异常点对特征分布不敏感但切分质量在小样本上波动较大自编码器则需要充足数据和训练时间。相比之下one-class kNN的优点是无分布假设、实现简单、可解释性强缺点是每次预测都要扫描一遍训练集存储和计算成本随着训练集增长线性上升。样本量几十万以上的话通常我会先用聚类抽样做原型集再在原型集上跑kNN效果和速度都能兼顾。最后提一个训练阶段自激励偏差的问题训练样本自己就是分布的支撑点计算出的训练得分天然会比同等位置的未来样本略微偏小所以基于训练得分分位数定出的阈值实际应用时边界通常略紧。如果后续能收集到真实异常样本建议定期用验证集校准阈值如果一直收集不到就把95%分位数当成一个底线再结合实际生产环境的漏报反馈做微调。算法本身只有几十行真正决定模型能不能用的往往是这些不起眼的细节。