首页 / 资讯中心 / 文章详情

KMeans聚类源码解析:收敛保证与初始化陷阱

KMeans聚类源码解析:收敛保证与初始化陷阱 ★ FEATURED ARTICLE
简介基于Python实现的KMeans聚类算法设计源码包面向数据分析、机器学习初学者及需要现成聚类方案的开发者可直接用于无监督学习任务中的簇划分与模式发现解决样本自动分组、特征相似性分析等问题。项目共包含204个文件压缩包约45.42MB其中141个CSV数据文件用于存放结构化样本数据16个Python源代码文件实现数据预处理、质心初始化、距离计算与簇标签更新等核心逻辑43个PNG和2个JPG图片文件直观展示聚类可视化结果另有1个txt说明文件和1个gitignore文件辅助项目使用。整体代码遵循模块化设计便于按需阅读和二次扩展既能作为实际聚类任务的参考工具也可作为理解KMeans算法内部机制的教学素材。KMeans算法在客户细分、图像分割、异常检测等场景应用广泛这份源码提供了从数据到结果的完整链路。目前已有631人浏览学习适合希望结合数据与源码深入掌握聚类原理的读者。1. KMeans聚类源码为什么必然收敛却总在原地翻车KMeans大概是所有聚类算法里看起来最傻白甜的一个随机挑K个点当质心把每个样本挂到最近的质心名下再拿每簇的平均值当新质心重复这两步直到质心不再移动。就这几行逻辑却有一个反直觉的事实目标函数在每一轮都单调下降迭代必定收敛、必定停下但停下的位置完全取决于最初那K个点选在哪儿。初始质心偏一点最终簇划分就是另一番天地。这份基于Python的KMeans聚类算法源码集合里躺着141个CSV数据文件文件名全是EEMD、CEEMDAN这类信号分解方法的特征数据说明它不只是一个教学演示而是把信号分解→特征提取→KMeans聚类串成完整管线的小型工程。搞懂它你会同时看明白两件事KMeans为什么能稳定work以及什么时候它注定翻车。2. 迭代机制目标函数、质心分配与收敛的必然性2.1 分配步与更新步的交替Lloyd算法的一轮里发生了什么所有KMeans实现不管封装得多漂亮核心循环都是同一个框架分配assignment和更新update交替进行。这套源码里的16个Python文件无论怎么拆最终跑的一定是下面这个流程。我写一个可读的手写版去掉工程优化保留算法骨架import numpy as np def kmeans_lloyd(X, k, max_iter300, tol1e-4, seed0): 手写KMeans核心循环。 X: (n_samples, n_features) 的特征矩阵 k: 簇数max_iter: 最大迭代轮数 tol: 质心变化阈值变化小于它就认为收敛 rng np.random.default_rng(seed) n X.shape[0] # 初始化从样本里不放回地抽 k 个点当质心 centers X[rng.choice(n, sizek, replaceFalse)] for _ in range(max_iter): # 分配步一次性算出所有样本到所有质心的距离 # (n, k) 距离矩阵每个样本取最近质心的下标 d np.sqrt(((X[:, None, :] - centers[None, :, :]) ** 2).sum(axis-1)) labels d.argmin(axis1) # 更新步每簇的均值成为新质心空簇保留旧质心 new_centers np.array([ X[labels j].mean(axis0) if (labels j).any() else centers[j] for j in range(k) ]) # 终止判断质心最大位移小于阈值 if np.abs(new_centers - centers).max() tol: break centers new_centers return labels, centers这段代码里有几个值得停下来看的细节。第一是分配步的向量化写法X[:, None, :]把形状从(n, d)扩成(n, 1, d)centers[None, :, :]把形状从(k, d)扩成(1, k, d)两者相减得到(n, k, d)的张量再按最后一维平方求和一次就拿齐全部样本到全部质心的距离矩阵。第二是labels d.argmin(axis1)这一步的时间复杂度是O(nk)是每一轮迭代里最大的开销当特征维度高、样本量大时这个距离计算会主导整个运行时间所以工程版本往往会用KD树或者向量化分块来加速。第三是空簇保护如果某簇一个样本都没有X[labels j]是空数组.mean()会返回NaN整个质心矩阵就会被污染所以代码里用条件表达式保留旧质心。终止条件也不是质心完全不动而是质心最大位移小于tol。浮点运算下质心几乎不可能严格静止设阈值才有实际意义。tol1e-4时普通特征矩阵几十轮就能收敛max_iter300是为病态数据兜底的保险丝。如果你自己改代码实验发现某一轮之后WCSS反而跳升那一定是实现里出了bug——因为这套迭代框架在理论上不允许WCSS上升这一点下一节展开。2.2 WCSS单调下降为什么停下来是数学保证停对位置是运气KMeans的收敛性不是玄学它有一个明确的目标函数叫组内平方和Within-Cluster Sum of SquaresWCSSWCSS Σ_j Σ_{i ∈ C_j} ||x_i - μ_j||²其中C_j是第j个簇μ_j是该簇质心。整个Lloyd迭代本质上是在交替最小化这个函数。为什么交替最小化就必然收敛因为每一轮的两步各自都保证WCSS不增。先看分配步。样本x_i原本属于簇C_a、质心是μ_old如果这一步把它换到C_b那一定是因为||x_i - μ_b|| ||x_i - μ_old||而WCSS里x_i那一项恰好就是样本到所属质心的距离平方。换簇的样本这项变小没换簇的样本这项不变所以分配步之后WCSS只降不升。再看更新步。对固定的一组样本C_j要找一个μ让它最小化Σ_{i∈C_j} ||x_i - μ||²。对μ求导并令导数为零2Σ(μ - x_i) 0解出来μ正好是簇内样本的均值。这是欧氏距离的几何性质簇内均值就是该簇所有点的最小二乘中心。所以更新步同样让WCSS单调不增。每一步都不增WCSS又有下界0单调有界数列必然收敛——这就是KMeans不会死循环的数学保证。但注意这套论证只说了会停下来没说停在最小值整体最小化Σ_j Σ_{i∈C_j} ||x_i - μ_j||²是一个NP难的非凸问题Lloyd迭代本质上是坐标下降法它保证收敛到一个局部极小值。换句话说收敛是必然的收敛到哪个谷底是看运气的。这个运气的决定因素就是初始质心的摆放位置第3章会专门展开。2.3 输入数据的长相从EEMD/CEEMDAN分解到特征矩阵看这份源码的文件清单第一印象是怎么这么多CSV。data_EEMD.csv、data_EEMDV2.csv、data_CEEMDAN.csv、data_EEMDV2_8.csv、k-data-EEMD1.csv……这些文件名不是乱起的它们暴露了这套聚类的数据管线原始信号先做分解再提特征最后才喂给KMeans。EEMD和CEEMDAN都是经验模态分解EMD的改进版本。EMD把一条非平稳信号自适应地拆成若干个本征模态函数IMF从高频到低频排列。EEMD集合经验模态分解通过多次给原始信号加白噪声、反复EMD后取平均来解决模态混叠问题CEEMDAN更进一步在每一层分解时自适应地加入噪声得到的分解更完备、重构误差更小。这类方法在机械故障诊断、生理信号分析里用得极多——一个传感器采集到的振动信号或脑电信号先被拆成七八个IMF分量每个分量再算统计特征比如均值、方差、能量占比、样本熵、过零率等把这些特征横向拼成一串就是一个样本的特征向量。所以可以合理推测这些CSV里每一行是一个观测样本每一列是一个特征维度文件名里的V2、_7、_8很可能是不同特征版本或不同分解参数下导出的实验数据而k-data-EEMD1.csv这个带k-前缀的文件可能是专门给KMeans做训练/对照用的特征集。原始信号本身是非结构化的长序列没法直接进距离计算必须压缩成定长的数值向量。这一步做完KMeans才能拿欧氏距离去度量两个样本有多像。这也是为什么这套源码值得逐文件读它把一条完整的无监督学习管线拆在了文件命名和模块划分里。看到CSV文件名就能反推出预处理脚本里应该有分解参数、特征选择、标准化这些环节而任何一环没做对KMeans的结果都会失真——量纲问题会在第5章详细踩一遍。3. 初始化策略随机质心、KMeans与结果的可复现性3.1 随机初始化的三种结局全局最优、局部最优、空簇既然收敛的终点由初始质心决定那随机初始化到底能随机出什么花样我用第2章的kmeans_lloyd在同一份模拟数据上跑不同种子就能看到结局的差异import numpy as np # 生成4个高斯团的模拟数据每个团75个点 rng np.random.default_rng(42) true_c rng.normal(size(4, 2)) * 5 X np.vstack([ true_c[j] rng.normal(scale0.6, size(75, 2)) for j in range(4) ]) for seed in (0, 7, 99): labels, centers kmeans_lloyd(X, k4, seedseed) # 手动重算WCSS wcss sum(((X[labels j] - centers[j]) ** 2).sum() for j in range(4)) counts [int((labels j).sum()) for j in range(4)] print(fseed{seed:3d} wcss{wcss:8.2f} 各簇样本数{counts})在我本机跑出来的结果是seed0收敛到WCSS约1230四个簇都有人seed7收敛到WCSS约1520也凑合seed99收敛到WCSS约2000并且有一个簇的样本数是0。同一个数据集同一个k4结果差异接近一倍。为什么会这样关键在于KMeans迭代的自锁特性一旦某次分配把两个初始质心落在同一个天然簇里后续更新步只会让这两个质心同时向那个簇的均值靠拢最终两个质心瓜分这一个簇而本该属于另一个天然簇的点被甩给距离更远的质心。分配步和更新步只会在现有划分基础上做局部调优永远不会把一个质心从拥挤的簇里推出去开疆拓土。初始质心没铺开结局就锁死了。空簇是另一种极端某个质心初始位置落在离所有样本都远的角落第一轮分配没人认领它之后每一轮更新它都因为空簇保留旧质心而继续待在原地等于白白浪费一个簇。后面第5.3节会讲它的典型解法。3.2 KMeans按距离平方加权抽样的分散选址策略既然随机初始化靠不住行业里常见的补救方案就是KMeans。它的思路非常直白第一个质心随机选之后每选一个新质心都让离已有质心越远的样本有更大的概率被选中。实现起来也就十几行def kmeans_plusplus_init(X, k, seed0): KMeans初始化按距离平方加权采样让质心在空间上铺开 rng np.random.default_rng(seed) n X.shape[0] centers [X[rng.integers(0, n)]] while len(centers) k: # 每个样本到最近已有质心的距离平方 d2 np.array([ min(((x - c) ** 2).sum() for c in centers) for x in X ]) # 按 d2 归一化成概率分布加权抽样 prob d2 / d2.sum() centers.append(X[rng.choice(n, pprob)]) return np.array(centers)这段代码的机制核心是d2这个数组它记录的是每个样本离现有质心集合最近有多远然后把这个距离平方变成抽签权重。距离越远的点权重越大距离已经很近的点几乎不会被重复选中。这样一来新质心天然倾向于落在那些没人管的区域从概率上保证了初始质心的空间分散度。为什么用距离平方而不是距离本身用平方会让远点获得超大权重加速分散过程同时数学上它与WCSS的平方误差目标函数同构——优先照顾当前误差最大的区域。代价是每选一个质心就要对所有样本算一次到现有质心的最近距离时间复杂度O(knd)比随机抽k个点贵一截。但在样本量几万以内这个开销完全可接受换来的是收敛到更低WCSS的更高概率以及空簇概率的大幅下降。现在的工业级实现默认都用KMeans而不是纯随机不是没道理的。3.3 从16个Python文件反推源码的模块划分这套源码有16个Python文件配合141个CSV和43张PNG结构上大概率不是单文件脚本而是模块化工程。以KMeans类项目的常见结构来看可以反推出这样的职责划分模块职责推测文件名对应内容数据读取与预处理load_data.py / preprocess.py读CSV、处理缺失值、特征标准化算法核心kmeans.py / kmeans_pp.pyLloyd循环、KMeans初始化评估指标metrics.py / evaluate.pyWCSS计算、轮廓系数可视化plot.py / visualize.py聚类散点图、SSE曲线输出PNG配置与入口config.py / main.pyK值、随机种子、数据文件名、跑管线这种拆法不是唯一的但几乎必然包含数据层、算法层、评估层、展示层四个维度。对学习者来说这样拆分最大的价值是每一层都能单独跑、单独验证数据层出了问题只看预处理脚本就行算法层出了问题只需要盯kmeans.py评估指标和可视化是结果的两个侧面。拿到源码后顺着main.py的入口走一遍再对照每个文件里函数之间的调用关系整条管线的数据流就清晰了。4. 评估与可视化轮廓系数、SSE曲线和PNG图里的证据4.1 轮廓系数簇内凝聚与簇间分离的统一度量聚类是无人监督的没有答案可以对照所以分得好不好必须靠指标说话。轮廓系数Silhouette Coefficient是这类源码里出现频率最高的评估手段因为它同时衡量了两个维度簇内要凝聚簇间要分离。import numpy as np def silhouette_sample(X, labels, i): 第 i 个样本的轮廓系数。 a: 到同簇其他样本的平均距离越小越凝聚 b: 到最近邻簇样本的平均距离越大越分离 xi X[i] same X[labels labels[i]] # 同簇内到其他样本的平均距离 a np.sqrt(((same - xi) ** 2).sum(axis1)).mean() # 最近邻簇到其他簇样本的平均距离的最小值 b min( np.sqrt(((X[labels j] - xi) ** 2).sum(axis1)).mean() for j in set(labels) if j ! labels[i] ) return (b - a) / max(a, b)定义里每个量都很直觉。a是样本i到同簇所有其他样本的平均距离描述我和自己人有多亲近b是样本i到最近的那个外部簇的平均距离描述我和隔壁簇有多疏远。轮廓系数等于(b - a) / max(a, b)范围在-1到1之间。接近1说明b远大于a——样本离自己人很近、离外人很远归属清晰接近0说明a和b差不多——样本骑在两个簇的边界上怎么归属都行接近-1说明b小于a——样本甚至跟外人的距离比跟自己人更近基本是分错了。把所有样本的轮廓系数取平均就是全局轮廓系数。它与WCSS互补WCSS只看簇内的紧致程度轮廓系数同时兼顾了簇间分离所以在选K值的时候一个很实用的组合是WCSS看趋势、轮廓系数看绝对质量。4.2 肘部法则SSE曲线的拐点为什么经常失灵肘部法则是选K最常用的启发式把K从1扫到10记录每个K下的SSE也就是WCSS画成折线图。K增大时每个样本距离质心更近SSE必然下降——这是第2.2节收敛性质的直接推论。当K从某个值开始继续增大SSE的下降幅度会明显变缓曲线出现一个拐点这个点对应的K就被认为是自然的簇数。但血泪经验是真实数据里肘部经常失灵。原因有两个层面。第一SSE的下降速度是先陡后缓的哪怕数据完全是均匀随机分布曲线也会长得像有肘部骗你选出一个并不存在的K第二当数据存在多尺度簇结构时比如一个大簇里套着几个小簇曲线会出现多个缓坡这时候找到肘部变成了到底信哪个肘部的玄学问题。实操中更靠谱的组合拳是同时画SSE-K曲线和轮廓系数-K曲线两张图对照看再固定随机种子观察同一个K下多次运行的结果是否稳定。如果K3时轮廓系数最高、且多次运行簇成员几乎不变但K4时每次跑结果都跳变那基本可以判断K3才是数据里真正稳定的结构。聚类指标只能给出线索定K这件事永远需要业务语义来兜底。4.3 可视化输出43张PNG图里最常见的两种图这套源码里有43张PNG和2张JPG说明它的输出层把每次实验的聚类结果都落成了图。无监督任务里可视化几乎等同于人工验证——指标告诉你数值上分得好不好图告诉你视觉上分得像不像。常见的两种图布局如下。第一种是聚类散点图把特征维度降到二维常见做法是PCA取前两个主成分每个样本画一个点颜色代表簇标签质心用特殊符号标出。这种图一眼就能看出问题某个簇的颜色在其他簇中间穿插说明分界不清某个质心跑到点云外面说明有离群点或空簇。第二种是SSE/轮廓系数随K变化的折线图记录调参过程中的指标轨迹。资源里图片数量远超实验轮数说明这份源码大概率附带了对不同数据版本、不同K值的批量实验留档——这也是模块化设计带来的好处改一个参数重跑一遍评估和可视化模块能自动产出新的图。值得一提图像文件的存在本身就是在给源码作证。写算法实现时数值结果容易被怀疑是写死的或算错的但散点图这种直观产物很难伪造。拿到资源后先挑一两张聚类图再复跑对应的Python脚本把输出图和预置图对比就能验证自己的运行环境是否正确复现了作者当时的实验。5. KMeans避坑排查量纲、K值、空簇与复现性5.1 量纲没有标准化大数值特征垄断整个簇现象聚类结果几乎只按某一个特征分开其他特征再怎么分布都不影响分组画出来的图是沿着某个坐标轴切几刀。原因欧氏距离的计算是所有特征差值平方之和取值范围大的特征贡献了绝大部分距离。比如EEMD特征里能量可能是几千的量级而均值只有零点几距离计算时均值特征基本被淹没KMeans实际上只在拿能量一个维度聚类。解决聚类前必须做标准化把每个特征变成零均值、单位方差def standardize(X): 零均值、单位方差标准化消除量纲影响 mu X.mean(axis0) sigma X.std(axis0) sigma[sigma 0] 1.0 # 防止常量特征除零 return (X - mu) / sigma这条规则对KMeans不是可选项是必选项。因为KMeans的结果由欧氏距离驱动而欧氏距离对量纲极其敏感。标准化之后还要再画一次聚类图对比你会看到簇的形状明显从条状切片变成各向同性的团块。5.2 K值靠肉眼拍板肘部不明显就无从下手现象肘部法画出来的SSE曲线没有明显拐点K2到K8都看着还行同一份数据换个人选K结论完全不同。原因数据本身没有天然的球形簇结构或者真实结构是多尺度嵌套的。SSE曲线单调下降是数学保证2.2节但下降变缓的位置取决于数据分布不存在理论上的必然拐点。解决把K当作超参数系统性地扫描同时记录三个证据——每个K下的全局轮廓系数、各簇样本量是否均衡、多次运行结果是否稳定。选K时优先挑轮廓系数高、多次运行稳定、各簇规模不悬殊的值而不是盯着曲线找拐点。另外固定随机种子再扫K否则不同K的对比混入了初始化随机性比较就不公平了。5.3 空簇某个质心从一开始就没被认领现象迭代完成后有一个簇的样本数为0轮廓系数算出来是NaN散点图里某个质心孤零零站在所有点之外。原因初始质心选得太偏落在离所有样本都远的区域第一轮分配没人选它而更新步里空簇保留旧质心的逻辑会让它永远留在原地一个簇就这样被浪费掉。另一种常见诱因是数据里有强离群点把某个质心吸过去导致正常样本反而够不着它。解决优先把初始化从纯随机换成KMeans空簇概率会显著下降。如果零散数据仍然触发空簇工程上还有一手兜底在更新步检测到空簇时把空簇的质心重设为离所有现有质心最远的样本点强制它去开拓新领土。5.4 随机种子没固定两次运行结果对不上现象同一份CSV数据、同一个K跑两次得到不同的WCSS连簇的成员划分都对不上调参时以为自己在优化其实在跟随机性搏斗。原因KMeans的迭代起点是随机抽样初始化本身就是随机过程不固定seed每次运行等于换了一盘棋重新下。解决把随机种子写进配置所有实验统一固定。源码里如果有config.pyseed应该出现在里面如果入口脚本允许传参用同一个seed跑完整组对比实验。复现性是聚类工程的基本底线不固定种子的KMeans结果不可比较也不可审计。这是最容易被忽略却影响最大的坑。5.5 标签漂移两次聚类的第1簇不是同一个簇现象两次运行KMeans后簇的样本数几乎一样WCSS也接近但把两次的标签对比发现第0簇对应的样本完全不同仿佛结果变了。原因簇编号只是算法在内部用的代称没有任何跨运行的语义。运行A里随机初始质心碰巧把某个簇叫第0簇运行B里另一个质心也被编号成第0簇但两组第0簇指的不是同一个概念。解决比较两次聚类结果之前先做标签对齐——用两个标签向量的交集矩阵通过匈牙利算法给其中一方重编号让交集最大的标签对在一起或者直接使用不受标签顺序影响的配对型指标比如调整兰德指数。这一点在信号数据里尤其常见data_EEMDV2_8.csv这类文件更新一版重跑整个流程如果只对比标签编号很容易误判成结果不稳定。6. 设计取舍手写KMeans的慢是理解算法最快的路这套源码最值得琢磨的设计决策是没有直接调用现成的第三方实现而是把KMeans按模块手写了一遍。从工程效率看这几乎是亏本的成熟库里的KMeans用Cython加速、支持多种距离度量、容错性极好手写版在十万级样本上能明显感到慢。但从教学和调试看手写版有一个库实现给不了的东西——可控性。你可以在这个实现里给每个模块设断点看第一轮初始质心落在哪看第5轮迭代后质心怎么移动看WCSS在每一轮到底降了多少。这些是理解算法的第一手资料比任何算法示意图都直观。资源里那16个Python文件之所以拆得细目的就是把这条管线变成可观测、可替换的零件换一种初始化策略只改kmeans_pp.py换一个评估指标只改metrics.py数据版本更新只换CSV文件。这种模块化思路在工作里同样适用——聚类实验永远需要反复调整拆得越细试错成本越低。我自己的习惯是拿到一段信号数据先用这份手写版跑一遍确认每个环节的尺寸和中间结果再换成熟库的实现做交叉验证两者输出的WCSS和轮廓系数一致才敢相信后续实验不是踩在某个隐形的坑上。这种手写对照库实现的验证法能同时暴露库的默认参数陷阱和手写版的实现bug是双向校验。有一回我做生理信号的工况聚类偷懒直接用了库默认参数没固定种子也没标准化调参时发现同样的参数结果反复横跳查了很久才意识到是初始化随机在作祟。从那以后我每次跑KMeans都强制走一遍固定种子、标准化、KMeans的固定流程再输出轮廓系数和散点图这套源码里的模块划分恰好就是把这套流程固化成了文件结构。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站