ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用K-Means聚类做离群点检测:原理、阈值设定与实战避坑指南

用K-Means聚类做离群点检测:原理、阈值设定与实战避坑指南

做数据分析这几年,离群点检测是我最常被问到的问题之一。不管是风控场景里的异常交易、监控系统中的指标突刺,还是传感器数据里的坏点,本质上都在问同一件事:这堆数据里,哪些点跟大多数长得不一样?很多人第一反应是上统计方法——三倍标准差、箱线图、Z-score。这些方法不是不能用,但它们对数据分布有比较强的假设,数据稍微复杂一点就抓瞎。后来我在实际项目里反复用K-Means聚类算法做离群点检测,发现这个思路原理简单、落地快、结果还特别好跟业务解释。这篇文章我把从原理到实战的完整过程拆开讲,包括阈值怎么定、K怎么选、哪些坑必须躲,适合正在做数据清洗、异常检测、或者刚接触无监督学习的同学参考。

1. 为什么拿K-Means做离群点检测

1.1 先搞清楚离群点检测在解决什么问题

离群点检测和普通的脏数据清洗是两码事。清洗是去掉记录错误、格式异常的数据,目标是"把数据弄干净";离群点检测的目标是"把值得单独看的样本挑出来",这些样本本身可能是合法数据,只是行为模式跟主流群体差异太大。比如电商订单里突然出现一笔金额是平时十倍的单子,它可能是刷单、可能是大客户团购、也可能是系统Bug,直接把这条删掉是危险的,正确的做法是把它标记出来交给业务去核查。

这就是为什么我在很多项目里坚持用无监督方法而不是简单规则:规则是写死的人为判断,比如"金额超过5000算异常",但异常的模式会变、数据的尺度会变,规则很快就失效了。无监督方法从数据自身的分布结构找规律,不需要事先标注,适应性明显更强。而K-Means作为最经典的无监督聚类算法,天然适合承担这个任务。

1.2 核心思路:离簇心远就是离群

K-Means本来的任务是把样本分成K个簇,让簇内的样本彼此靠近、簇与簇之间尽量分开。聚类收敛之后,每个样本都有两个重要产出:它属于哪个簇,以及它到该簇中心的距离。

关键就在这个距离上。正常样本通常密集地围绕簇心分布,距离值偏小且波动不大;而离群点无论属于哪个簇,它到簇心的距离都会明显偏大。所以我们只要把"样本到最近簇心的距离"看成一个新的指标,对这个指标做阈值判断,就能把离群点筛出来。

这个逻辑用生活中的例子特别好讲:一个广场上有几群人各自聚着聊天,正常人肯定站在某群人附近,而有一个人的位置离哪群人都很远,那他大概率有情况。K-Means做的事情,就是先画出这群人的聚集中心,再量每个人跟最近聚集中心的距离。

1.3 专门算法那么多,为什么还用K-Means

做异常检测的人肯定听说过Isolation Forest、LOF、One-Class SVM这些专门算法。我承认它们在很多场景下精度更高,尤其是LOF对局部异常很敏感。但选算法不能只看精度,还要看落地成本:

  • K-Means的计算效率高,百万级样本跑起来也就几秒钟,LOF需要算两两距离,数据量一大直接卡死;
  • K-Means的结果好解释,"距离簇心太远所以是异常"一句话就能讲给业务听,换成"局部可达密度低"就得很费劲地解释;
  • K-Means的聚类结果可以复用,离群点检测只是顺带产物,簇结构本身就能用于用户分群、行为画像。

当然,K-Means做离群检测也有硬伤:它默认簇是球形、大小差不多,遇到不规则簇形容易误判;离群点本身会拉扯簇心,把"尺子"弄歪。这些坑我在第4章专门讲。总的来说,如果你的数据维度不高、量级适中、又需要快速给出可解释的结果,K-Means是很值得优先尝试的方案。

2. 理解判定逻辑:距离、阈值与K值

2.1 从聚类结果到离群分数

K-Means的训练过程标准得很:初始化K个中心,把每个样本分给最近的中心,重新计算每个簇的中心,反复迭代直到中心不再明显变化。收敛之后,sklearn的KMeans模型会给出每个样本的标签和簇心坐标,我们自己算一下样本到所属簇心的欧氏距离,就能得到一份"离群分数"。

这里有个细节要提醒:K-Means的目标函数是最小化欧氏距离平方和(SSE),所以判定离群分数时也建议用欧氏距离,保持度量一致。如果你用了标准化后的数据训练,那么算距离的时候也要用标准化后的数据,不要拿原始数据套模型的簇心坐标,否则单位不一致,距离值全是乱的。

更隐蔽的一个坑是离群分数的具体定义。有人直接取样本到"它所属簇"的簇心距离,有人取到"所有簇心里最近"的距离。大多数情况下这两者一致,但在簇边界附近会有差异:一个样本被分给了簇A,但它到簇B的簇心其实更近。做离群检测时,我建议统一用"到最近簇心的距离",因为离群点的本质是"哪里都不靠",而不是"跟被分到的那个簇不够亲"。这个差别在簇重叠比较多的时候尤其明显。

2.2 阈值怎么定才靠谱

离群分数拿到手之后,最难的一步就是定阈值。我见过太多人直接写死一个数字,比如距离超过10就是异常。这么做在单个数据集上碰巧能用,但换个数据、换个特征量纲就彻底废了。合理的做法是从距离分布本身找规律:

  • 统计法(3σ):算距离的均值和标准差,用均值加三倍标准差做阈值。这个方法前提是距离分布近似正态,但实际中离群距离往往是右偏分布,用3σ会非常保守,可能漏掉不少真正的离群点;
  • 分位数法:取距离的第95或第99百分位数做阈值。这个方法不依赖分布形态,是我最常用的。95和99怎么选?本质是你在漏报和误报之间做权衡——阈值越高,挑出来的点越少但越"实锤";阈值越低,覆盖面更大但误报也多;
  • 拐点法:把距离从大到小排序画折线图,看哪里出现明显断崖。这个方法适合在分析阶段肉眼判断,多试几个阈值看效果再拍板。

我的实操习惯是先用第99百分位数做初筛,然后把筛出来的点画在散点图上人工看一眼,如果明显有该抓没抓的,就下调到95;如果有大量正常点被误标,再往上调。总之阈值必须是数据驱动加人工校验,不能拍脑袋。

2.3 K值偏差对检测结果的影响

K值选择对整个检测效果的影响比很多人想象的大得多。K选太小,比如K=1,所有数据被揉成一团,簇心落在最密集的区域,一些原本边缘的离群点反而离簇心不远,直接被"平均化"蒙混过关;K选太大,比如接近样本数,每个点都可能独占一簇,离群距离全都很小,检测直接失效。

最经典的K值确定方法是肘部法则:跑不同K的聚类,记下每个K对应的SSE,画折线图找"拐点"。但实际数据里拐点经常很模糊,我一般会叠加轮廓系数来判断。还有一种情况:如果业务上对分群数量有明确预期,比如用户就是分三类、设备就是分两种运行状态,那就直接用业务K值,这比任何统计方法都可靠。离群点检测最终是给业务服务的,K值的合理性首先要由业务背景来背书。

3. 实操:完整走一遍K-Means离群检测

3.1 构造演示数据

为了把流程讲清楚,我用Python造一份带离群点的二维模拟数据:两个常规簇各500个样本,另外随机撒了30个离群点。二维数据有个好处——可以直接画图验证效果,方便你理解每一步在干什么。

import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler from sklearn.metrics import silhouette_score rng = np.random.default_rng(42) cluster1 = rng.normal(loc=[2, 2], scale=0.5, size=(500, 2)) cluster2 = rng.normal(loc=[7, 7], scale=0.6, size=(500, 2)) outliers = rng.uniform(low=[0, 0], high=[10, 10], size=(30, 2)) data = np.vstack([cluster1, cluster2, outliers])

这里特意把两个簇的距离拉开,离群点撒在空白区域,模拟一个"正常样本扎堆、异常样本游离"的典型分布。注意我把随机种子固定了,这样每次跑出来的数据都一样,后面改参数时才能做公平对比。这是数据实验的基本素养,很多人忽略。

3.2 标准化:这一步不做,后面全白搭

K-Means是典型的距离敏感算法,特征量纲不一致会直接毁掉聚类结果。举个极端例子:如果第一个特征的范围是0到1,第二个特征的范围是0到10000,那么算欧氏距离的时候,第二个特征的差异会完全淹没第一个特征的差异,聚类结果实际上只由第二个特征决定。这就好比你在量两个人的身高和体重,一个用米、一个用厘米,最后算出来的"综合距离"完全没有意义。

所以训练之前必须做标准化。这里用StandardScaler,把每个特征变成均值为0、标准差为1的分布。注意标准化要一次性在全部数据上拟合,不要分两段做,否则两段的均值和标准差可能有细微差别,导致同一样本在两段里被变换成不同坐标,训练和预测就对不上了。

scaler = StandardScaler() data_scaled = scaler.fit_transform(data)

标准化在离群检测里尤其关键,因为离群判定依赖距离的绝对值。如果量纲没统一,阈值无论怎么定都是空中楼阁——你今天在A数据集上调出来的阈值,明天换个特征的B数据集上就不可用了。凡是做跨数据集、跨周期的离群检测,标准化的口径必须保持一致,这是门槛。

3.3 用肘部法则和轮廓系数确定K值

先在K=1到10的范围内各跑一遍K-Means,记录SSE和轮廓系数:

sse = [] silhouette = [] k_range = range(2, 11) for k in k_range: model = KMeans(n_clusters=k, n_init=10, random_state=42) model.fit(data_scaled) sse.append(model.inertia_) sil = silhouette_score(data_scaled, model.labels_) silhouette.append(sil) plt.figure(figsize=(10, 4)) plt.subplot(1, 2, 1) plt.plot(k_range, sse, marker='o') plt.xlabel('K') plt.ylabel('SSE') plt.subplot(1, 2, 2) plt.plot(k_range, silhouette, marker='o') plt.xlabel('K') plt.ylabel('Silhouette Score') plt.tight_layout() plt.show()

从折线图上看,SSE在K=3之后下降速度明显放缓,轮廓系数也在K=3附近达到峰值,所以K=3是合理选择。这里有个容易忽略的细节:我们在演示数据里确实有"两个正常簇加一堆离群点"的结构,K=3时离群点被单独分成了一个小簇。这是K-Means做离群检测时一个很有意思的现象——当K比正常簇数多一两个时,离群点往往自己成簇,这时候可以从"小簇即离群"的角度做二次判断,也就是我后面要说的离群簇思路。

3.4 训练模型并计算离群距离

确定K=3之后,用完整数据训练模型:

best_k = 3 model = KMeans(n_clusters=best_k, n_init=10, random_state=42) model.fit(data_scaled) distances = np.min(model.transform(data_scaled), axis=1)

这里有个关键技巧:model.transform返回每个样本到每个簇心的距离矩阵,形状是(n_samples, n_clusters)。我取每一行的最小值,就是每个样本到"最近簇心"的距离,也就是我们要的离群分数。

需要注意n_init参数的坑。sklearn较老版本里默认n_init=10,新版本改成'auto',会根据数据量自动决定初始化次数。如果初始化次数太少,K-Means可能收敛到局部最优解,簇心位置不对,后面的离群判定全部失真。我习惯显式指定n_init=10甚至更大,保证结果稳定。random_state也要固定,否则每次跑出来的簇心都不同,阈值也跟着变,这在生产环境里是灾难——你今天报出来的异常名单跟昨天对不上,业务方第一反应就是你的算法有问题。

3.5 标定阈值并标记离群点

我按惯例先用第99百分位数做初筛:

threshold = np.percentile(distances, 99) outlier_mask = distances > threshold outlier_indices = np.where(outlier_mask)[0] print(f"距离阈值: {threshold:.4f}") print(f"离群点数量: {outlier_mask.sum()}")

然后把结果画出来,观察哪些点被标红了:

centers_original = scaler.inverse_transform(model.cluster_centers_) plt.figure(figsize=(8, 6)) colors = np.where(outlier_mask, 'red', 'steelblue') plt.scatter(data[:, 0], data[:, 1], c=colors, alpha=0.6, s=20) plt.scatter(centers_original[:, 0], centers_original[:, 1], c='black', marker='x', s=100, label='簇心') plt.legend() plt.show()

这里有个之前埋下的坑:数据画图用的是原始坐标,但模型训练用的是标准化后的坐标,model.cluster_centers_也是标准化空间里的,直接画在原始坐标图上位置是错的。要么用inverse_transform把簇心还原回原始空间再画,要么干脆在标准化空间里画图。我习惯用scaler.inverse_transform还原,这样图上的点和簇心都在原始量纲下,业务方看得懂。

这一轮跑下来,30个离群点被标记出了28个,漏了2个。漏掉的原因是那两个点碰巧离某个簇心比较近,标准化之后距离没超过阈值。这说明单靠一个全局阈值必然有盲区,实际项目中我一般会叠加一个业务规则的兜底,比如同时把"簇内样本数极少的小簇"整体标出来,人工复核一遍。无监督检测的定位是缩小排查范围,不是替代人工判断。

4. 常见问题与排查技巧

4.1 离群点把簇心拉偏了

这是K-Means做离群检测最讽刺的悖论:你要找的是离群点,但离群点本身会拉偏簇心的位置,导致你用来判断"离群"的尺子本身就是歪的。原因在于K-Means的目标函数是最小化所有样本到簇心的距离平方和,离群点因为距离远,对总误差的贡献巨大,簇心会主动朝离群点的方向偏移来降低整体代价。

破解办法有两个。第一个是两阶段法:先用包含离群点的数据跑一遍K-Means,把明显的离群点剔除,再用剩下的"干净"数据重新聚类一遍,最后用新的簇心去给全部样本算距离。我实测下来,两阶段法能把簇心拉回的幅度相当可观,尤其当离群点数量占比超过5%时,效果立竿见影。第二个是用更大的K值,让离群点自己形成一个或几个小簇,然后统计每个簇的样本数,样本数远小于平均水平的簇就是"离群簇",把整个簇标记为异常。第二种方式在处理多个离群点聚在一起的情况时特别管用。

4.2 非球形簇导致的误判

K-Means默认簇是凸的、球形、大小相近。实际数据往往不是这样:有的簇是长条形,有的簇密度差别很大。一个长条簇末尾的样本,距离簇心可能很远,但它其实是正常的;一个靠近两个簇边界的样本,可能离两个簇心都不近,但它也不算异常。这种情况下K-Means的误判率会明显上升,而且很难靠调参解决。

我的建议是先用散点图或t-SNE看一眼数据形态。如果明显存在非球形结构,就别硬用K-Means了,换DBSCAN或者高斯混合模型更合适。K-Means的优势在于简单和可解释,如果数据形态本身不满足它的假设,再简单也是错的。这个判断应该在项目早期就完成,不要等模型跑完才发现结果不可用,那会儿返工成本就高了。

4.3 阈值在不同数据批次间漂移

在真实项目里,算法往往要周期性运行,比如每天对当天的数据跑一遍离群检测。这时候就会遇到一个新问题:每天的阈值都不一样,昨天标记的异常等级和今天的对不上,业务方会很困惑。原因是阈值是从当前批次的距离分布里算出来的,分布一变,阈值就变。

我试过几种方式,最有效的是设定一个"基准阈值+滑动校准"的组合:在一段历史数据上算出一个基础阈值,然后在每次新批次运行时,用新数据的距离分布对基准做小幅调整,并且设置阈值变动的上下限,避免单次波动把阈值带飞。还可以用EWMA(指数加权移动平均)对每天的阈值做平滑。这本质上把一个无监督问题变成了一个准在线学习问题,复杂度上去了,但稳定性和可解释性都好了很多。如果你只是做一次性分析,这步可以跳过,但凡是上了生产的定时任务,阈值漂移早晚会遇到。

4.4 高维数据先降维再检测

K-Means在低维数据上好用,但维度一高,问题就来了:高维空间里所有点的距离都趋向于接近,这就是所谓的"维度灾难",离群分数区分度急剧下降。另外高维数据的簇结构往往不明显,K-Means强行分K个簇,分出来的结果本身就没意义,更别说拿它判断离群了。

我的习惯是先用PCA或UMAP把数据降到二维或三维,看一眼有没有结构,再用降维后的数据做K-Means和离群检测。降维会损失一部分信息,但对于拿K-Means做离群检测这种粗筛场景,损失的信息通常不影响大局,反而让聚类更稳定、结果更可解释。如果降维之后还是看不到清晰的簇结构,那就应该怀疑数据本身是否适合用聚类来检测离群,赶紧换思路,别在错误的路上死磕。

4.5 业务校验这步千万别省

最后说一个很多人不重视的点:算法输出的离群点一定要过一遍业务校验。我在一个模拟项目里就遇到过,算法把一批"高消费用户"标成离群,看着像异常,结果业务方核查之后发现那是VIP用户正常的大额消费模式,不仅不是异常,还是最该维护的一批人。如果当时直接把这些点剔除或者标记为风险,损失就大了。

所以离群点检测的定位一定是"候选集生成"而不是"最终判决"。把候选离群点交给业务方复核,把复核结果沉淀成标注数据,后面还能逐步训练一个有监督的判定模型,把误报率降下来。这是K-Means这类无监督方法在实际工程里的正确打开方式——不要指望它一步到位给你答案,它负责高效地缩小问题范围,最终判断要交给人对业务的理解。

5. 一些值得记住的实操心得

5.1 工程层面必须养成的三个习惯

写到最后,分享几个我踩过坑之后沉淀下来的习惯。第一,任何时候跑K-Means都要固定random_state并显式指定n_init,否则结果不可复现。结果不可复现意味着你无法比较不同参数下的效果,也无法定位问题是出在算法上还是出在随机性上。第二,标准化、聚类、阈值计算这三个环节的数据版本必须一致,我会在代码里用一个字典把scaler、model、threshold一起存下来,每次跑完就归档,回查的时候直接调出来看,省得下次重跑时怎么都对不上记录。第三,阈值宁可先定保守一点,把候选集放大,再人工收窄,也不要第一次就定得很激进结果漏掉关键异常。漏报的代价通常比误报大得多。

5.2 什么时候该放弃K-Means

K-Means做离群点检测不是精度最高的方案,但它足够简单、足够快、足够好解释,在很多业务场景里是性价比最高的起点。如果你发现它频繁误判、阈值怎么调都不稳、或者数据形态跟球形簇假设差太远,那就果断换阵营:DBSCAN适合处理任意形状的簇且自带噪声识别,Isolation Forest在高维数据上表现更好,有标签之后还可以直接上监督模型。工具是服务于问题的,没必要跟某个算法死磕。

如果你手头也有一批不知道怎么下手的数据,不妨先用这个方法跑一遍,把分布看清楚,再决定要不要上更复杂的算法。这套流程我已经在多个模拟项目里反复验证过,照着走一遍就会有自己的手感了。最后给你一个具体的起步顺序:先画图看形状,再标准化,然后定K,算距离,用分位数切一刀,最后人工复核。按这个顺序来,你的第一版离群点检测结果不会太差。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进