K-Means大概是机器学习里最容易被低估的算法了。表面上看,它简单到可以用一句话概括:把数据分成K堆,每堆找中心点,反复迭代到稳定。但真到了自己动手从零实现的时候,你才会发现里面塞满了细节——质心怎么初始化、距离怎么度量、空簇怎么处理、K值怎么定、怎么判断收敛。这些点在任何调库教程里都被轻描淡写地带过了,只有把算法一步步抠出来写的时侯,你才对"聚类"这件事有真正的体感。
这篇文章就围绕K-Means从零实现展开,目标是把算法拆到底:不调sklearn,用Python和NumPy从空手开始把K-Means写出来,同时把每个关键环节的原理和取舍讲清楚。适合正在学机器学习基础、准备面试、或者想摆脱"只会调库"状态的读者。别急着抄代码,跟着走一遍,你会发现很多之前被忽略的坑,恰恰是理解这个算法的钥匙。
1. 项目概述与整体设计思路
1.1 K-Means到底在解决什么问题
先说本质。K-Means解决的是无监督学习里的聚类问题:给你一堆没有标签的数据点,希望把它们自动划分成K个群体,使得同一个群体内的点尽量相似,不同群体间的点尽量不同。相似度用距离衡量,K是人为指定的群体数量。
一个很典型的场景是用户分群。假设你有电商平台一万个用户的消费记录,每个用户有两个特征:月均消费金额、月均购买次数。你想把这批用户分成几个有区分度的运营群体,但事先没有任何标签告诉你谁属于哪类。这时候K-Means就能派上用场:把每个用户当成二维平面上的一个点,K-Means自动把它们聚成K堆,每堆就是一个群,运营策略就能针对每个群单独设计。
这个"自动"背后的逻辑很朴素,叫"物以类聚":同一类的点,到该类中心点的距离,应该比到其他类中心点的距离更近。算法做的就是反复调整中心点和点的归属,直到这个朴素逻辑被满足到最好。
1.2 算法的标准流程拆解
从流程上讲,K-Means的迭代过程可以压缩成四步:
- 选定K值,并初始化K个聚类中心(质心)。
- 对每个样本点,计算它到K个质心的距离,把点归属到距离最近的质心所在的簇。
- 对每个簇,重新计算簇内所有点的均值,把这个均值作为新的质心。
- 重复步骤2和3,直到质心位置不再明显变化,或者达到最大迭代次数。
四个步骤看似简单,但每一步都有多种实现选择和隐藏的坑。比如步骤1,直接随机选K个点当质心,在大多数情况下能跑,但偶尔会陷入很差的局部最优;用K-Means++初始化能避免这个问题,但很多人不知道它为什么有效。步骤2里的距离度量,默认用欧氏距离,但数据特征量纲差很大时,直接算欧氏距离会出问题。步骤3的均值计算看起来没有争议,但遇到空簇时程序直接崩掉也不是没可能。
所以从零实现不是把伪代码翻译成Python就完事了,你需要对每一步的边界情况和原理做决策。这也是我认为从零实现最有价值的地方:迫使你真正去理解算法的假设和局限,而不是把sklearn当成黑盒。
1.3 为什么非要自己从零写一遍
有人会说,现在调个库几行代码就搞定聚类,何必自己造轮子?我的看法是:造轮子的过程,是在为"调库"建立可靠的地基。
一方面,面试和工作中经常会被问到K-Means的原理细节,比如"K-Means++比随机初始化好在哪里""K-Means的收敛性如何保证""为什么K-Means不适用于类别型特征"。这些如果不亲手实现,你很难答得深入。另一方面,工程实践中你大概率会遇到标准库覆盖不到的变体需求,比如要加距离权重、要处理稀疏向量、要在分布式环境下做K-Means。这时候你对照标准实现做修改,前提就是你已经吃透了原始算法。
还有一层原因是培养调试直觉。我见过太多人出了聚类结果不对,第一反应是"换个库试试",而不是检查数据是否归一化、K值是否选得合理、初始化是否太糟糕。从零实现一遍,你会养成先检查数据、再检查算法、最后才怀疑代码的习惯——这个顺序在真实项目中非常重要。
2. 核心细节解析:每一步背后的原理与取舍
2.1 距离度量的选择:欧氏距离并不是唯一答案
K-Means里最常用的距离度量是欧氏距离,公式是:
d(x, y) = sqrt(Σ(xi - yi)²)
为什么默认用欧氏距离?因为它和"均值"天然匹配。K-Means的质心更新用的是簇内均值,这个均值在数学上恰好就是让簇内欧氏距离平方和最小的点。换句话说,用欧氏距离 + 均值更新,是一个自洽的组合:目标函数是簇内误差平方和(SSE),均值就是它的最优解。
但这不是說欧氏距离在所有场景下都对。如果特征列的量纲差异很大,比如一个特征是年龄(20到60),另一个特征是收入(5000到50000),欧氏距离会把收入的影响放大几十倍,年龄特征基本失去作用。解决办法是聚类前先做标准化(z-score或min-max归一化),这是新手最常忽略的一步。
也有场景需要换距离度量,比如文本向量常用余弦相似度,但余弦相似度不是严格意义上的距离(不满足三角不等式),K-Means用起来需要额外处理;再比如数据里有明显的离群点,可以考虑曼哈顿距离,它对异常值更稳健。但从零实现的第一版,我建议先把欧氏距离做扎实,再去扩展别的度量。
2.2 质心初始化:K-Means++为什么比随机初始化强
初始化是K-Means里最容易翻车的一环。最朴素的做法是从数据里随机选K个点作为初始质心。问题在于,随机选到的质心可能非常集中,几个质心挤在一团,导致迭代收敛到很差的局部最优解。
K-Means++的思想是让初始质心尽量分散。具体步骤:
- 从数据中随机选第一个质心。
- 对每个样本点,计算它到最近质心的距离D(x),并按D(x)²的概率加权,随机选出下一个质心。
- 重复步骤2,直到选出K个质心。
用距离的平方做概率权重,意味着离已有质心越远的点,越有机会被选成新质心。这样初始质心就能拉开距离,避免一开局就挤在一起。
我在从零实现时直接改用了K-Means++,实测效果和随机初始化相比,收敛速度更快,而且多次运行结果的稳定性明显提高。要注意的是,K-Means++也不是万能的,在特别高维的数据或者噪声密集的数据上,它同样可能选到孤立点当质心,但因为这是初始化的概率问题,通常不会造成灾难性后果。
2.3 收敛判断:质心不动才算完?
标准实现中,收敛判断有两种常见方式:一种是比较前后两轮的质心位移量,如果每个质心的位置变化都小于某个极小阈值(比如1e-4),就判定收敛;另一种是设置最大迭代次数,到了就强制停止。
这里有一个实践细节:光看质心位置可能会错过"震荡"。在个别数据分布下,簇的归属可能在两个质心之间反复横跳,质心位置看起来变动不大,但标签已经来回换了。稳妥的方法是同时跟踪损失函数(SSE)的变化——如果SSE不再下降,再判定收敛。这比只看质心位置更可靠。
到底用哪个阈值?没有统一标准,但我习惯把阈值设为1e-4,最大迭代次数设为300。前者的作用是确保精度,后者的作用是防止死循环。你还需要在代码里加一个提前终止条件:如果连续几轮SSE变化率小于阈值,就提前结束。这能省下不少无聊的迭代。
2.4 K值怎么选:肘部法和轮廓系数的实战用法
K值是K-Means里最让人头大的超参数。它不像学习率那样有经验范围,K值完全取决于数据本身的分布结构。选K有两个最常用的工具:肘部法和轮廓系数。
肘部法:对不同的K值(比如1到10)分别做聚类,计算每个K对应的SSE,然后画折线图。SSE会随着K增大单调下降,但在某个K值之后,下降速度骤减,曲线出现一个像手肘一样的拐点,这个拐点就是推荐的K。
轮廓系数:对每个样本计算轮廓系数,取值范围在-1到1之间,越接近1表示聚类效果越好。一般取多个K值分别计算所有样本的平均轮廓系数,平均值最高的K就是优选方案。
但这两个方法在实际数据上经常给出不同建议。我的经验是:肘部法更直观但拐点有时不明显,轮廓系数比较精确但对噪声敏感。不要迷信任何单一指标,最好结合业务场景判断——如果你是给用户分群做运营,K值要从"我能为每个群设计差异化策略"这个角度来定,而不是完全交给算法。
3. 从零实现:完整代码与逐行讲解
3.1 环境准备与数据结构设定
从零实现不需要任何机器学习库,只需要Python和NumPy,再加一个Matplotlib用于可视化验证。动手前先明确输入输出:
- 输入:一个形状为(n_samples, n_features)的二维数组X,以及类别数K。
- 输出:每个样本的簇标签(长度为n_samples的一维数组),以及K个质心的坐标。
为了后续可视化方便,我会先造一个二维的模拟数据,让点分布在几个簇附近。
import numpy as np import matplotlib.pyplot as plt # 生成模拟数据:3个簇,每个簇200个点 np.random.seed(42) cluster_1 = np.random.normal(loc=[2, 2], scale=0.5, size=(200, 2)) cluster_2 = np.random.normal(loc=[8, 8], scale=0.5, size=(200, 2)) cluster_3 = np.random.normal(loc=[2, 8], scale=0.5, size=(200, 2)) X = np.vstack([cluster_1, cluster_2, cluster_3]) # 可视化 plt.scatter(X[:, 0], X[:, 1], s=10) plt.title("模拟数据分布") plt.show()这份数据有三个清晰的簇中心,分布在(2,2)、(8,8)、(2,8)附近。用这样的数据来验证实现的正确性,因为真实答案已知,任何错误都能直观反映在图像上。
3.2 核心函数实现:距离计算与质心更新
先写两个最基础的函数:计算每个点到质心的距离、更新质心。
def compute_distances(X, centroids): """ 计算每个样本点到每个质心的欧氏距离 X: (n_samples, n_features) centroids: (K, n_features) 返回: (n_samples, K) """ n_samples = X.shape[0] k = centroids.shape[0] # 利用广播计算 (n_samples, 1, n_features) - (1, k, n_features) diff = X[:, np.newaxis, :] - centroids[np.newaxis, :, :] distances = np.sqrt(np.sum(diff ** 2, axis=2)) return distances def update_centroids(X, labels, k): """ 根据簇标签重新计算质心 """ n_features = X.shape[1] new_centroids = np.zeros((k, n_features)) for i in range(k): points_in_cluster = X[labels == i] if len(points_in_cluster) > 0: new_centroids[i] = points_in_cluster.mean(axis=0) else: # 后续会详细讨论空簇怎么处理 new_centroids[i] = X[np.random.choice(X.shape[0])] return new_centroidscompute_distances里用了NumPy的广播机制,一步算出全部点对全部质心的距离矩阵,不需要写双层循环。这是一个性能关键点:如果写for i in range(n): for j in range(k),数据量一上万就会明显变慢。向量化是从零实现里一上来就要养成的好习惯。
update_centroids里对空簇做了临时兜底处理:从原始数据里随机抽一个点当质心。这里先不展开,第三节单独讲空簇的深层处理。
3.3 K-Means++初始化实现
初始化部分我直接实现K-Means++,它比随机初始化多一点点计算量,但效果好很多。代码逻辑严格按照前面讲的概率选择思路来写。
def kmeans_plusplus_init(X, k): """ K-Means++初始化质心 """ n_samples = X.shape[0] centroids = np.zeros((k, X.shape[1])) # 第一步:随机选第一个质心 first_idx = np.random.choice(n_samples) centroids[0] = X[first_idx] for i in range(1, k): # 计算每个点到最近质心的距离 distances = compute_distances(X, centroids[:i]) min_distances = np.min(distances, axis=1) # 按距离平方的概率选下一个质心 probabilities = min_distances ** 2 probabilities = probabilities / probabilities.sum() next_idx = np.random.choice(n_samples, p=probabilities) centroids[i] = X[next_idx] return centroids这段代码里有几个细节值得注意。第一个质心是用np.random.choice均匀随机选的,后面每个质心都用距离平方加权。probabilities / probabilities.sum()这一步是归一化,确保所有概率加起来等于1,np.random.choice的p参数才能正确按概率抽样。另外,min_distances是每个点到已有质心集合的最近距离,这保证了新质心选在"离现有质心尽量远"的位置。
3.4 主循环:完整K-Means封装
把前面各部分组合起来,写一个标准K-Means类。加上了SSE跟踪和收敛判断。
class KMeans: def __init__(self, k=3, max_iter=300, tol=1e-4): self.k = k self.max_iter = max_iter self.tol = tol def fit(self, X): self.X = X n_samples, self.n_features = X.shape # 初始化质心 self.centroids = kmeans_plusplus_init(X, self.k) # 记录历史SSE sse_history = [] for iteration in range(self.max_iter): # 分配簇标签 distances = compute_distances(X, self.centroids) labels = np.argmin(distances, axis=1) # 计算当前SSE sse = 0 for i in range(self.k): points_in_cluster = X[labels == i] if len(points_in_cluster) > 0: sse += np.sum((points_in_cluster - self.centroids[i]) ** 2) sse_history.append(sse) # 更新质心 new_centroids = update_centroids(X, labels, self.k) # 判断收敛:质心位移小于阈值 movement = np.linalg.norm(new_centroids - self.centroids) self.centroids = new_centroids if movement < self.tol: print(f"第{iteration + 1}轮迭代后收敛,SSE={sse:.4f}") break else: print(f"达到最大迭代次数,停止迭代,最终SSE={sse:.4f}") # 最终标签 distances = compute_distances(X, self.centroids) self.labels = np.argmin(distances, axis=1) return self用一个对象来封装K-Means是常见的工程做法,这样后续可以很方便地访问labels、centroids、sse_history。收敛判断这里用了质心位移量,但我在第2.3节提过,还可以结合SSE一起判断。代码里的for...else结构值得注意:如果循环正常走完300次没有break,else块会执行,提示达到最大迭代次数。这是一个很容易被忽视但非常实用的Python细节。
3.5 实验验证:可视化聚类效果与SSE曲线
跑一遍上面的实现,看看结果。
kmeans = KMeans(k=3, max_iter=300, tol=1e-4) kmeans.fit(X) # 可视化聚类结果 plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) plt.scatter(X[:, 0], X[:, 1], c=kmeans.labels, cmap="viridis", s=10) plt.scatter(kmeans.centroids[:, 0], kmeans.centroids[:, 1], c="red", marker="x", s=200, label="质心") plt.title("K-Means聚类结果") plt.legend() plt.subplot(1, 2, 2) plt.plot(range(1, len(kmeans.sse_history) + 1), kmeans.sse_history, marker="o") plt.title("SSE随迭代变化") plt.xlabel("迭代次数") plt.ylabel("SSE") plt.tight_layout() plt.show()我实测时,这个实现会在几轮迭代内收敛,SSE迅速下降然后趋于平缓。聚类结果和原始生成数据的三个簇完全对上,质心坐标也基本落在真实中心(2,2)、(8,8)、(2,8)附近。这说明对于分布清晰、簇间分离度高的数据,标准K-Means非常简单可靠。
3.6 从零实现时需要避开的性能陷阱
第一个陷阱是循环里重复计算距离矩阵。有人会写一个for iter in range(max_iter),然后在循环内又用for i in range(n_samples)逐个计算距离——在小数据集上没事,但数据量到十万级别就会卡到怀疑人生。正确做法永远是向量化。
第二个陷阱是直接用np.linalg.norm去循环算每个点到每个质心的距离,这同样低效。上面代码里利用广播把距离矩阵一次性算出来,不仅快,而且代码更简洁。
第三个陷阱是用Python列表存储所有中间结果而不预分配数组。在迭代过程中不断append是灵活的,但如果你已经知道最终结果大小,最好提前用np.zeros或np.empty分配好。这个习惯做起来很简单,对性能的提升却立竿见影。
4. 常见问题与调试实录
4.1 空簇问题:质心彻底失去了归属样本
K-Means在迭代过程中偶尔会遇到一个簇的样本数为0的情况,也就是空簇。这是我第一次从零实现时就中招的坑。原因通常是初始化时质心选得太偏,或者某个簇的样本在迭代过程中全被其他质心"抢走"了。
处理空簇有几种常用策略,我按推荐的优先级排序:
策略一:重新随机初始化空簇质心。这是最简单粗暴的方式,给空簇一个随机点作为新质心,让它重新参与竞争,代码里我们用的就是这种兜底。缺点是可能再次陷入空簇。
策略二:选择离所有现有质心最远的点作为新质心。这样能让空簇质心跳到数据分布最稀疏的地方,起到探索新区域的作用。比纯随机更聪明,实现也不复杂。
策略三:从SSE最大的簇中分裂出一些样本给空簇。这个策略更激进,但能直接降低全局SSE。
我实际工程里最常用的是策略二。它效果好,代码量也不大:计算出每个点到最近质心的距离,挑距离最大的那个点作为空簇的新质心即可。新手建议先用随机初始化兜底,跑通流程后再升级为这个策略。
4.2 局部最优问题:同一份数据,多次运行结果不同
K-Means的迭代过程是一个贪心式下降,最终收敛到的解取决于初始化落在哪里。你可能会遇到这样的情况:跑三次算法,三次的SSE都不一样,其中一次明显比其他两次高。这是因为那次初始化让算法掉进了局部最优。
解决办法有几个层次。最常用的是多重初始化:同一份数据跑多次(比如10次),每次用不同的随机种子初始化,最终取SSE最小的那次结果。这个技巧简单有效,是工业界最通用的做法。我在实际写代码时,会把整个fit过程包进一个循环,记录所有运行中的最小SSE和对应模型,最后返回最优结果。
另外,K-Means++已经大幅降低了局部最优的概率,但不能完全消除。如果数据太复杂,还可以考虑使用二分K-Means(Bisecting K-Means),它把聚类过程拆成层次化的二分类,稳定性会更好。但那是另一个话题了。
4.3 特征量纲不一致:聚类结果被某些特征主导
我接手过一个真实案例,数据包含"消费频次"和"消费金额"两个特征,金额的数值动辄几千,频次只有个位数。直接用K-Means聚类,结果几乎是按金额单维度切分,频次特征基本没起作用。这不是算法的问题,是数据预处理的问题。
解决办法是在聚类前做特征缩放。最常用的有:
- 标准化(Z-score):减去均值,除以标准差,让每个特征均值为0、方差为1。
- Min-Max归一化:让每个特征缩放到[0, 1]区间。
具体选哪个没有绝对答案。Z-score对离群点更稳健,Min-Max对分布范围有硬性约束。我在大多数聚类场景下偏好Z-score,因为它保留了数据分布的形状信息,而Min-Max容易把长尾分布压扁。不过在文本或图像数据上,Min-Max也很多见。关键是要理解:K-Means对特征的尺度极其敏感,不做归一化的聚类结果基本没有参考价值。
4.4 聚类结果不可复现:随机种子与工程化
从零实现和调库有个非常大的区别:库函数通常帮你管理了随机种子,但自己实现时,每次运行的初始化都是随机的。如果你在写论文、做实验对比或者给老板汇报,结果不可复现会很尴尬。
解决办法是固定随机种子。在NumPy层面用np.random.seed(42)就能让整个流程中的随机过程确定下来。如果代码里有多个随机源(比如Python的random模块也用了),需要一并固定。我在工程实践中还会把随机种子和算法参数一起记录到配置文件中,这样任何一次实验结果都能追溯到生成它时的完整环境。
另外一个工程细节:不要在生产环境里直接使用上面的KMeans类,建议在fit方法里加入数据校验,比如检查输入是否包含NaN、特征维度是否一致、K是否小于样本数。这些校验代码看起来不起眼,但在真实数据上能救你一命。
4.5 常见问题速查表
| 问题表现 | 可能原因 | 解决方案 |
|---|---|---|
| 聚类结果每次运行都不一样 | 随机初始化导致落入不同局部最优 | 固定随机种子,或采用多重初始化 |
| 某个簇始终为空 | 初始化质心过偏或样本被其他簇夺走 | 空簇随机初始化,或选最远点作为新质心 |
| 某个特征主导聚类结果 | 特征量纲差异过大 | 聚类前做Z-score标准化或Min-Max归一化 |
| 迭代很快停止但SSE偏高 | 初始化太差,掉入局部最优 | 改用K-Means++或多次重启 |
| 数据量大时跑得极慢 | 循环计算距离而非向量化 | 使用广播/距离矩阵一次性计算 |
| 轮廓系数很低,聚类边界模糊 | K值选取不合理或数据本身无簇结构 | 用肘部法重新选K,检查数据分布 |
| 质心坐标出现NaN | 空簇且均值计算时除零 | 在均值计算前检查簇内样本数是否大于0 |
这张表是我在实际调试中总结出的高频问题。如果你在从零实现时遇到奇怪的结果,先对照这张表排查一遍,大概率能找到原因。
5. 结果评估与算法局限性
5.1 评估聚类效果:不要用SSE作为唯一指标
很多人在K-Means实现完成后,习惯用SSE看收敛,但SSE本身不适合用来横向对比不同的K值或不同的聚类方案。原因很简单:SSE一定是K越大越低,K=样本数时SSE降到0,但这显然没有任何意义。
我常用的评估指标有两个维度。内部指标不依赖真实标签,包括轮廓系数、DB指数(Davies-Bouldin Index)、Calinski-Harabasz指数;外部指标则需要有真实标签做对照,比如调整兰德指数(ARI)、归一化互信息(NMI)。
从零实现K-Means之后,我强烈建议再手写一个轮廓系数的计算函数。轮廓系数的公式并不复杂:对每个样本,计算它与同簇其他样本的平均距离a,再计算它与最近邻簇样本的平均距离b,然后算(b - a) / max(a, b)。所有样本的平均值就是整体的轮廓系数。手写一遍不仅能加深对评估指标的理解,也能反过来检验你的聚类实现是否正确。
5.2 K-Means的适用边界:什么时候它不work
K-Means不是万能的,它的核心假设是"簇是凸形的"——也就是说,簇在特征空间中应该是近似球形或凸形的分布。这句话对新手来说可能有点抽象,我换个方式说:如果数据分布是月牙形、环形、嵌套的螺旋形,K-Means几乎不可能把它们正确分开。
原因在于K-Means的决策边界是Voronoi图,是基于质心的线性划分。对于非凸形状的簇,质心落在数据分布的"空心"位置,划分出来的簇和真实结构完全对不上。检测这个问题的办法也很简单:聚类完成后画图看一下,如果质心明显落在数据的空洞区域,说明数据和K-Means的假设不匹配。
另外,K-Means对离群点非常敏感。离群点会拉拽质心,导致整个簇的划分被带偏。如果你清洗数据时发现异常值很多,K-Means可能不是最优选,可以考虑DBSCAN这类基于密度的聚类算法。
5.3 K-Means的变体与应用扩展
从零实现一遍标准K-Means之后,你可以很自然地往几个方向扩展:
- 二分K-Means:先不管K,把所有数据当成一个簇,然后反复把SSE最大的簇拆成两个子簇,直到达到K个簇。这种贪心策略能显著减少随机初始化带来的波动。
- Mini-Batch K-Means:每次迭代随机抽一部分数据做质心更新,适合海量数据场景。代价是收敛精度略降,但速度快很多。
- K-Means的核化版本(Kernel K-Means):把数据映射到高维空间后再做聚类,能处理部分非线性可分的情况。
- 加权K-Means:给不同特征赋予不同权重,适合有业务先验知识的场景。
这些扩展大多是在标准实现上做小幅改动,前提是你已经对标准实现烂熟于心。所以说到底,从零实现一遍K-Means,不是终点,而是后续一系类聚类算法的起点。
6. 最终总结与经验心得
从零实现K-Means,整个过程本身并不长,几十行核心代码就能完成。但我一直觉得,这个算法是机器学习入门阶段最适合手写的项目之一:它够简单,能在一天内从零写到可视化;它又够复杂,涉及初始化、距离度量、空簇处理、收敛判断、特征缩放、模型评估等一堆关键决策。把这些决策都做过一遍,你对无监督学习的理解深度,会和只调sklearn的人明显拉开差距。
根据我自己的实操经验,有几点特别想分享给准备动手的朋友:
不要追求代码行数少,先追求逻辑清晰。把你写的每一步都对应回算法流程,能在注释里写出"这一步在干什么、为什么这么干"才算真的理解了。等逻辑完全通了,再考虑用向量化优化性能。
调试时先用简单人造数据验证正确性,再上真实数据。我见过太多人一上来就用真实业务数据跑K-Means,结果出问题了也分不清是代码bug还是数据本身的分布问题。先用像本文开头那种生成数据跑通,你心里对"什么是对的"有数了,再面对真实数据时才能快速定位问题。
最后,别停留在只跑通标准版。试着把K-Means++换成随机初始化,把欧氏距离换成曼哈顿距离,把均值更新改成中位数更新,观察结果变化。这种"改一个变量,看一个结果"的实验方式,是理解算法鲁棒性的最好训练。从零实现K-Means的意义,不在于写出一个可用的聚类器,而在于通过亲手构建,看清楚这个算法每一步的脾气和底牌。