资讯动态

K-Means聚类算法做异常检测:从原理到代码实战的离群点识别指南

发布时间:2026/10/21 17:02:07 来源:尧图企业网站定制
我第一次听到“用K-Means聚类算法检测离群点”这个做法时第一反应是有点疑惑聚类不是用来分组的吗怎么还能拿来找异常后来在实际项目里试过几次发现这个思路不但可行而且在大规模数据场景下很有优势。简单来说它就是利用“离群点离所有簇中心都很远”这个直觉把每个样本到最近簇中心的距离当作离群分数再配合合理的阈值把远到离谱的样本挑出来。这篇文章面向的是有一定Python基础、想给自己的数据加一道“异常发现”能力的同学。你不需要深厚的数学背景只要理解K-Means的基本概念就能跟着下面的步骤跑通一套完整的离群点检测流程。我会把原理、代码、调参经验和踩坑实录都写清楚尽量做到看完就能在自己数据上落地。1. 为什么用K-Means来检测离群点1.1 离群点检测的主流思路与K-Means的定位离群点检测在工业界还有个更常叫的名字异常检测。它的目标很明确就是从一堆数据里把“不正常”的样本找出来比如电商风控里的异常交易、工业传感器里的故障信号、日志数据里的恶意请求。目前主流的做法大致可以分成四类统计方法假设数据服从某个分布比如正态分布下用3σ准则或者用箱线图的IQR法则。简单快速但对数据分布有较强假设遇到复杂分布很容易失灵。距离方法计算每个样本与近邻样本的距离比如K近邻距离。思路直观但计算量偏大对高维数据不太友好。密度方法代表算法是LOF和DBSCAN通过比较局部密度来识别离群点。效果不错但参数选择比较敏感尤其DBSCAN的ε和MinPts需要反复试。树模型方法代表算法是孤立森林用随机划分的方式把离群点“孤立”出来速度很快工程上用得很多。那K-Means在这个版图里扮演什么角色它本质上属于“聚类方法”这一类。和前面几类方法相比它最大的优势有三个第一计算效率高。K-Means的复杂度大约是O(n·k·t)其中n是样本量k是簇数量t是迭代次数。在百万级样本下也能跑得动而LOF这种距离方法在这种规模下基本要跑到怀疑人生。第二输出结果可解释。K-Means不只会告诉你“哪些点是离群点”还会告诉你每个样本属于哪个簇、离簇中心有多远。这个“距离”本身就是一个连续分数可以排序、设阈值、做Top N人工复核方便业务落地。第三复用成本低。很多团队本来就在用K-Means做用户分群、客户画像既然聚类模型已经有了顺手拿距离做离群检测不需要额外引入一套复杂系统。当然它也有明显的短板假设正常数据能形成大致球形、且规模相近的簇。如果你的数据分布是长条形、半月形或者高度重叠K-Means的效果会大打折扣。这种情况我更推荐DBSCAN或者孤立森林而不是硬用K-Means。1.2 K-Means离群点检测的核心逻辑与原理K-Means做的事情是把样本分成K个簇每个簇由一个中心点质心代表。它的优化目标是最小化所有样本到各自簇中心的欧氏距离平方和这个指标也叫簇内平方和SSE。标准K-Means本身并没有“离群点检测”这个功能但我们可以在训练完成后做一个关键转换每个样本到离它最近的簇中心的距离就是它的离群分数。这个逻辑成立的前提是正常样本会形成若干个密集簇而离群样本不属于任何一个簇它们要么离所有簇中心都比较远要么只能被一个簇“勉强收留”但距离依然很大。举个例子假设你有一张两维的散点图正常情况下数据聚成两团团中心分别在(0, 0)和(10, 0)附近。此时来了一个点坐标是(15, 8)。K-Means训练完后这个点无论被分到哪个簇它到质心的距离都会明显大于大多数正常样本。只要设定一个合理的距离阈值就能把它挑出来。这里有个容易忽略的细节离群点对簇中心的“带偏效应”。K-Means在更新质心时是把簇内所有样本的均值作为新质心所以离群点也会参与计算。如果一个簇里混进了极端离群点质心会被拉向离群点方向导致正常样本到质心的距离反而变大出现误判。这个问题我在后面的调优章节会详细展开。另外还有一种变体思路叫“离群簇识别”。如果K选得比较大离群点可能不会成为某个大簇的成员而是自己形成一个小簇。这种簇的特点是样本数量非常少比如整个簇只有三五个样本。实际操作中我经常把“样本到质心距离”和“所在簇的样本量”结合起来如果某个簇的成员数量占总样本的比例过低直接整簇标记为离群。这在高维数据中尤其有用因为高维下单纯用距离阈值会越来越不敏感。总结一下K-Means离群点检测的完整逻辑链是先假设正常数据成簇分布再用K-Means找到这些簇最后用距离或簇大小作为离群信号。理解这条链后面写代码就是水到渠成的事了。2. 动手实现K-Means离群点检测的完整流程2.1 准备工作与数据构造环境准备很简单我用的是Python 3.10主要依赖scikit-learn、pandas、numpy、matplotlib这几个库。如果你还没装直接执行下面的命令pip install scikit-learn pandas numpy matplotlib为了演示完整流程我先构造一份模拟数据。这里刻意做了两个正常簇再往里面撒一些离群点方便我们直观看到检测效果。import numpy as np import pandas as pd from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler from sklearn.datasets import make_blobs import matplotlib.pyplot as plt # 生成两个正常簇共800个样本 n_normal 800 X_normal, _ make_blobs( n_samplesn_normal, centers2, cluster_std1.2, random_state42, center_box(-8, 8) ) # 生成20个离群点均匀撒在整个平面上 n_outliers 20 rng np.random.RandomState(42) X_outliers rng.uniform(low-15, high15, size(n_outliers, 2)) # 拼接成完整数据集 X np.vstack([X_normal, X_outliers]) y_true np.array([0] * n_normal [1] * n_outliers) print(f正常样本: {n_normal} 个, 离群点: {n_outliers} 个, 总计: {len(X)} 个)这里用make_blobs生成两个高斯簇用均匀分布生成离群点是为了模拟“正常数据成簇、离群数据散落”的典型场景。注意我固定了random_state这样每次运行结果都一样方便复现和调试。接下来这一步很多人会忽略但它非常关键标准化。K-Means依赖欧氏距离如果特征之间的量纲差异大比如一个特征是“金额”范围是0到10000另一个特征是“频次”范围是0到10那么金额维度会完全主导距离计算频次维度形同虚设。标准化之后每个特征均值归零、方差归1距离计算才相对公平。scaler StandardScaler() X_scaled scaler.fit_transform(X)2.2 聚类训练与离群距离的计算标准化之后先初始化KMeans模型并训练。这一步里有几个参数值得注意n_clusters、n_init、random_state、max_iter。kmeans KMeans( n_clusters2, n_init10, max_iter300, random_state42 ) kmeans.fit(X_scaled)n_init这个参数的意思是算法会从不同的随机初始质心开始跑10次取其中SSE最小的那一次作为最终结果。如果不设置scikit-learn在新版本里默认也是10但显式写出来是个好习惯既明确又保险。训练完成后用transform方法计算每个样本到每个簇中心的距离distances_to_centers kmeans.transform(X_scaled) # 取最近距离作为离群分数 min_distances distances_to_centers.min(axis1)transform返回的是一个二维数组形状是n_samples × n_clusters第i行第j列表示第i个样本到第j个簇中心的欧氏距离。我们需要的只是每个样本的最近距离所以取axis1方向上的最小值。其实还有一种算法方式是先用predict拿到每个样本的簇标签再通过标签找到对应的质心坐标手动计算距离。两种方法结果一致但transform更简洁也更快。唯一要注意的是transform里的距离是基于训练时的特征空间计算的所以新数据进来时需要先用同一个scaler做标准化再用训练好的kmeans做transform。2.3 离群阈值的选择方法拿到min_distances之后最关键的问题来了距离多大才算离群我最初犯过的错误是直接套用“均值3倍标准差”。这个做法在距离分布接近正态时还行但离群距离的分布往往是右偏的也就是绝大多数样本距离都很小少数样本距离特别大。用均值加标准差算出来的阈值会被少数极端值拉高导致离群点漏判。更稳妥的做法是用百分位数。比如我们想找出大约5%的离群点就取95%分位数作为阈值threshold np.percentile(min_distances, 95) outlier_mask min_distances threshold print(f阈值: {threshold:.4f}) print(f标记离群点数量: {outlier_mask.sum()})在模拟数据里离群点占比大约是2.4%20/820所以固定95%分位数会多标出一些“边缘正常点”。这时候有两种选择一是调高百分位数到97%、99%减少召回但提升精度二是配合业务实际比如业务方说“每天最多人工复核100条”那就取距离最大的前100个样本不纠结百分位数。除了百分位数我还会同时看一眼箱线图法则q1 np.percentile(min_distances, 25) q3 np.percentile(min_distances, 75) iqr q3 - q1 tukey_threshold q3 1.5 * iqr这个阈值对应的是“统计学意义上的离群点”不依赖我们主观设定的百分位。在数据分布比较健康时Tukey阈值和95%分位数通常比较接近。我的习惯是同时算出多个阈值画在直方图上看一眼再决定用哪个。2.4 结果可视化与效果评估只有数字没有图总觉得少了点说服力。我习惯画两张图一张是散点图展示聚类结果和离群点位置另一张是距离分布直方图画出阈值线。fig, axes plt.subplots(1, 2, figsize(13, 5)) # 左图聚类结果和离群点 axes[0].scatter( X_scaled[~outlier_mask, 0], X_scaled[~outlier_mask, 1], ckmeans.labels_[~outlier_mask], cmapviridis, s20, alpha0.6 ) axes[0].scatter( X_scaled[outlier_mask, 0], X_scaled[outlier_mask, 1], cred, s40, markerx, labeloutlier ) axes[0].scatter( kmeans.cluster_centers_[:, 0], kmeans.cluster_centers_[:, 1], cblack, marker*, s200, labelcenter ) axes[0].set_title(K-Means Outlier Detection) axes[0].legend() # 右图距离分布直方图 axes[1].hist(min_distances, bins50, edgecolorwhite) axes[1].axvline(threshold, colorred, linestyle--, labelfthreshold{threshold:.3f}) axes[1].axvline(tukey_threshold, colorblue, linestyle--, labelftukey{tukey_threshold:.3f}) axes[1].set_title(Distance Distribution) axes[1].legend() plt.tight_layout() plt.show()因为我构造了真实标签y_true所以可以顺便算一下准确率、召回率、F1from sklearn.metrics import classification_report print(classification_report(y_true, outlier_mask, digits3))注意这里的评估带了一个前提我们手上有真实标签。真实业务场景里大部分数据是没有“真异常”这个标签的这时候更现实的做法是抽样检查被标记的样本建立一个人工复核闭环而不是强行找个指标。3. 参数调优与踩坑经验3.1 K值怎么选肘部法与轮廓系数的局限做K-Means绕不开K值的选择。离群点检测场景下K的意义和纯聚类不太一样K代表的是“正常数据有几种典型簇形态”。我第一个建议是先用肘部法看趋势inertias [] K_range range(2, 11) for k in K_range: km KMeans(n_clustersk, n_init10, random_state42) km.fit(X_scaled) inertias.append(km.inertia_) plt.plot(list(K_range), inertias, markero) plt.xlabel(K) plt.ylabel(Inertia / SSE) plt.title(Elbow Method) plt.show()还有一种常用的轮廓系数法from sklearn.metrics import silhouette_score silhouette_scores [] for k in K_range: km KMeans(n_clustersk, n_init10, random_state42) labels km.fit_predict(X_scaled) silhouette_scores.append(silhouette_score(X_scaled, labels)) plt.plot(list(K_range), silhouette_scores, markero) plt.xlabel(K) plt.ylabel(Silhouette Score) plt.show()但我在实际项目里发现这两个指标在离群点场景下都可能给出误导。因为离群点本身会让SSE变大也会拉低轮廓系数。如果数据里混了明显离群点肘部法看到的“肘”可能比真实簇数偏大或者偏小。所以我的核心建议是K值优先从业务含义出发。比如你在做客户分群业务上知道客户大概分为“高价值、中价值、低价值”三类那K3就是合理起点离群检测只是附加输出没必要为了追求“最优轮廓系数”去强行调K。如果业务上没有任何先验我一般从K2开始试看到聚类结果能覆盖主要的正常样本形态就够了不要贪多。3.2 离群点对聚类中心的“带偏”效应与对策K-Means做离群检测有一个天然的“先有鸡还是先有蛋”的矛盾我们希望找到离群点但K-Means的质心计算又会被离群点带偏从而影响后续的离群判定。用一个极端的例子说明假设正常数据坐标集中在(0, 0)附近有一个离群点坐标是(100, 100)。如果直接用K-Means聚类K1时质心会被拉到大概(1.x, 1.x)的位置倒是还好但如果离群点有5个都堆在(100, 100)附近质心就会被明显拉过去。结果是正常样本到质心的距离普遍增大距离阈值被抬高原本的离群点反而可能被“洗白”成正常样本。我在项目中验证过几种对策效果从好到差排列如下迭代清洗法第一次先聚类标出距离较大的“疑似离群点”把它们从训练集里剔除用剩下的干净数据重新训练K-Means再对全量数据计算距离。这一步通常能把误报率降低不少。用更稳健的质心估计把KMeans换成KMedians基于L1距离的中位数中心或者KMedoids基于实际样本点作为中心。它们对离群点的敏感度低于均值质心。不过这两个算法在scikit-learn里没有直接封装需要自己实现或借助其他库代码成本稍微高一点。多次运行加固定随机种子离群点对初始质心敏感如果初始质心跳到离群点附近后续会放大带偏效应。设置n_init10以上固定random_state至少保证结果可复现。我的常规操作是先用完整数据跑一次K-Means画出距离直方图看看长尾有多长。如果存在明显的长尾先粗筛掉最远的0.5%样本再用干净数据重新聚类。这样做之后离群点的定位通常会更准。3.3 高维数据与特征量纲的影响高维数据是K-Means离群检测的重灾区。原因很简单随着维度增加所有样本之间的欧氏距离都会趋向于接近这就是所谓的“维数灾难”。当维度上升到一个程度最近距离和最远距离几乎没有差别“离群点离簇中心远”这个直觉就失效了。我在一个维度大约50的用户行为数据集上试过直接用标准化后的原始特征跑K-Means发现几乎所有样本的离群分数都挤在一起直方图没有明显的长尾阈值怎么定都别扭。后来做了三件事就改善了很多降维用PCA先压到2到5个主成分把解释方差累计到85%以上。K-Means在低维空间里对球形簇的拟合能力更强离群信号也会重新显现。去冗余特征删除方差近乎为0的特征删除相关系数超过0.95的重复特征。冗余特征等于给某些维度重复加权会扭曲距离计算。特征标准化这个前面强调过但必须反复提醒。如果没有标准化量纲大的特征会主导距离K-Means的簇形会被“拉扁”离群点检测也会随之失效。还有一个实战技巧在高维场景里不要只用距离把“所属簇的样本量”也作为一个离群信号。一个只有两三个样本的簇即使它们的点到质心距离算不上特别远也要优先标记出来因为正常数据不应该出现这么孤立的簇。4. 常见问题与排查技巧实录4.1 问题速查表我把实际调试中遇到的高频问题整理成了一张速查表方便你对照排查症状可能原因排查与解决离群点数量异常少阈值设太高、K太小画距离直方图调低百分位数误杀大量正常点特征未标准化、K偏大检查是否做StandardScaler减小K每次运行结果差异大初始质心随机性增大n_init设置random_state距离分布没有长尾高维数据维数灾难用PCA降维结合簇大小判断正常簇被切成碎片K值过大、分布非球形参考业务先验重选K或换DBSCAN极端点把质心拉偏离群点参与质心更新迭代清洗法先剔除再重聚类这张表看起来简单但每一条都是我实际踩过的坑。尤其是“距离分布没有长尾”这条很多人一开始根本没想到是高维问题只会反复调阈值调了半天发现怎么调都没用。4.2 一个完整的业务案例从数据到结论前段时间我帮一个模拟项目X做交易异常识别数据大概是3万条订单记录每条订单有十几个维度包括下单金额、用户历史订单数、登录次数、设备切换次数、距离上一次下单的时间间隔等。业务方最初给的诉求是“找出可疑交易”。我拿到数据后没有直接上模型而是先做了三件事清洗缺失值、标准化、PCA降维。因为原始特征里有“金额”这种量纲很大的列也有“间隔天数”这种很小的列直接聚类必然被金额主导。降维到4个主成分后跑KMeans聚类。K值我没有用肘部法死磕而是根据业务方对用户行为的理解选了K4高频小额、中频中额、低频大额、异常尝试四类。聚类完成后每个样本取最近质心距离画直方图发现尾部明显分叉以99%分位数作为阈值最后标记出约1.2%的交易为“高风险”样本。后续让人工团队抽检了被标记的样本发现其中确实有大量“短时间内高频注册、多设备切换、金额异常”的疑似羊毛党行为明显高于随机抽检的命中率。不过这里我要强调一句算法标记只能起到“圈定范围”的作用真正干掉一个账号、拒绝一笔订单必须配合业务规则和人工复核不能只靠一个聚类模型拍板。整个流程跑下来从数据处理到模型输出大概花了半天时间。性能方面3万条数据在普通笔记本上也就是一两秒的事情。如果数据量更大还可以用MiniBatchKMeans加速效果基本接近。4.3 我自己常用的两条落实建议最后说两个比较零碎但是能明显提升体验的小建议。第一离群检测从来不应该是“一次性的”。数据的分布会随着时间变化正常簇的形态也会漂移。我习惯把K-Means模型训练脚本做成定时任务每天或者每周重跑一次并把当天的阈值、离群比例记录下来。一旦发现离群比例突然从1%跳到10%这本身就是信号说明业务环境可能出现了大变化。第二算法输出和业务定义之间一定要有“翻译层”。比如模型说“这个样本到簇中心距离排第98百分位”业务方其实听不明白。但如果你告诉他“这个用户的行为模式和正常用户差异很大尤其是登录频次和下单金额两个指标显著偏离”他就知道下一步该怎么做了。所以我在项目中通常会给每个离群样本附带上“主要偏离特征”列表方法是比较该样本与所属簇中心在各个维度上的差异取差异最大的几个特征作为解释。我个人在实际操作中的体会是K-Means离群检测最舒服的位置永远是一个“粗筛器”而不是“最终判决器”。它帮我在海量数据里快速圈出可疑范围然后再交给规则引擎和人工去做精细判断。只要摆正这个定位它的价值其实非常大。如果你现在手里的数据正好是有簇状分布特征的正常样本加少量异常样本不妨照着上面的流程试一次大概率会比直接套孤立森林更容易理解也更容易向同事解释清楚。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑