☰
K-means聚类实战:从算法直觉到工程落地的完整指南
2026/10/15 13:03:43 网站建设 项目流程

K-means大概是每个接触数据聚类的人绕不开的第一个算法。我见过太多人第一次跑通它,就是在某个教程里复制几行sklearn.cluster.KMeans,看着散点图被染成几块颜色,然后觉得"哦,会了"。但真到了自己手头的数据上——量纲不统一、簇形状诡异、K值不知道取几、跑出来的结果每次都不一样——才发现那几行代码根本不够用。这篇东西就是写给这个阶段的你:已经知道K-means是什么,但想真正把它用明白、用稳、用到能解释结果的程度。我会从算法本身的直觉讲起,一路拆到工程实现里的坑,包括手写一版不依赖现成库的核心逻辑、K值怎么定、初始点怎么选、结果怎么评估,以及那些文档里不会写但实际会咬你一口的细节。全程用Python,代码可以直接抄去改。

1. 先把K-means的直觉钉死,再谈代码

很多人写K-means的时候脑子里是空的,只知道"它能把数据分成K堆"。这种状态下写出来的代码,参数全靠默认,出了问题也不知道从哪查。所以动手之前,得先把算法在脑子里跑一遍。

1.1 它到底在优化什么

K-means的目标非常朴素:让每个点到它所属簇中心的距离平方和最小。这个量有个名字叫簇内平方和(WCSS,Within-Cluster Sum of Squares),也叫惯性(inertia)。公式写出来是这样:

$$J = \sum_{i=1}^{K} \sum_{x \in C_i} |x - \mu_i|^2$$

其中$C_i$是第$i$个簇,$\mu_i$是这个簇的中心(质心),$x$是簇里的样本点。整个算法就是在找一组簇划分,让这个$J$尽量小。

这里有个关键认知:K-means是一个贪心迭代算法,不保证找到全局最优。它做的事情是反复执行两步——把每个点分配给最近的中心,然后重新计算每个簇的中心——直到中心不再移动或者达到迭代上限。这两步各自都能降低$J$,所以整个过程$J$是单调不增的,最终会收敛到一个局部最优。局部最优意味着什么?意味着你换个初始中心,可能得到完全不同的结果。这一点后面讲初始化的章节会重点展开。

1.2 为什么是"距离平方和"而不是"距离和"

这是个容易被忽略但很值得想清楚的问题。用平方而不是绝对值,有两个实际原因。第一,平方函数处处可导,求导之后正好得到"均值"这个解析解——对$J$关于$\mu_i$求导并令其为零,解出来$\mu_i$就是簇内所有点的算术平均。如果用绝对距离,最优中心会变成中位数,迭代公式就没这么干净了。第二,平方会放大远离中心的点的惩罚,让算法更倾向于把离群点单独归到某个簇或者让它影响更小,而不是让一个大簇被几个远点拖偏。

代价也很明显:K-means对离群点敏感。一个极端远的点可能把整个簇的中心拽过去。所以如果你的数据里有明显的异常值,要么先清洗,要么考虑用K-medoids这类对离群点更鲁棒的变体。

1.3 它隐含了哪些假设

用之前必须清楚K-means的"世界观",否则你会拿它去解它根本解不了的问题:

  • 簇是球状的、大小相近的。因为它是用欧氏距离划分的,隐含假设每个簇在各个方向上延展程度差不多。如果真实簇是细长的条状或者大小悬殊,K-means会切得很别扭。
  • 簇的密度差不多。密度差异大时,稀疏的大簇容易被拆开,稠密的小簇容易被合并。
  • 每个簇可以用一个中心代表。如果簇是环形、月牙形这种非凸形状,一个中心根本代表不了,K-means必然失败。

我踩过最典型的一个坑:拿K-means去聚两类同心圆数据,结果它把圆环从中间劈成两半,而不是分成内圈和外圈。这不是代码写错了,是算法假设和数据形状不匹配。这种时候该换DBSCAN或者谱聚类,而不是死磕K-means。

2. 手写一版核心逻辑,比调库更能暴露问题

直接上sklearn当然快,但如果你想真正理解K-means,我强烈建议先用NumPy手写一遍。手写的过程会逼你面对所有细节:距离怎么算、空簇怎么处理、收敛怎么判断。这些细节在调库时都被封装了,出了问题你根本不知道发生了什么。

2.1 用NumPy实现最朴素的版本

先看一个能跑、逻辑完整的最小实现:

import numpy as np def kmeans_numpy(X, k, max_iter=300, tol=1e-4, random_state=None): rng = np.random.default_rng(random_state) n_samples, n_features = X.shape # 随机选k个点作为初始中心 idx = rng.choice(n_samples, size=k, replace=False) centers = X[idx].copy() for i in range(max_iter): # 第一步:计算每个点到每个中心的距离 # 用 (a-b)^2 = a^2 - 2ab + b^2 展开,避免显式循环 dists = np.linalg.norm(X[:, None, :] - centers[None, :, :], axis=2) # 第二步:分配每个点到最近的中心 labels = np.argmin(dists, axis=1) # 第三步:重新计算中心 new_centers = np.array([ X[labels == j].mean(axis=0) if np.any(labels == j) else centers[j] for j in range(k) ]) # 第四步:判断是否收敛 shift = np.linalg.norm(new_centers - centers) centers = new_centers if shift < tol: break return labels, centers

这段代码有几个地方值得停下来看。

第一,距离计算用了广播X[:, None, :] - centers[None, :, :],一次性算出所有点到所有中心的距离矩阵,形状是(n_samples, k)。数据量大时这个矩阵会吃内存,比如10万样本、10个簇、100维特征,这个中间张量就是10万×10×100,约8GB。所以生产环境一般不会这么写,而是用矩阵展开或者分块计算。但作为理解用途,这样写最直观。

第二,空簇的处理。如果某一轮某个中心没有分到任何点,X[labels == j].mean(axis=0)会报错或者产生NaN。上面代码里我做了保护,保留原中心不动。但更好的做法是重新初始化这个空簇的中心,比如选一个离其他中心最远的点。这个细节调库时你根本感知不到,但手写时必须处理。

第三,收敛判断用的是中心移动距离shift < tol。也可以用标签是否变化来判断,但中心移动距离更平滑,不容易因为个别点的抖动导致提前停止。

2.2 向量化改写:把内存和速度都照顾到

上面那版在数据量稍大时就会卡。核心瓶颈是那个三维距离张量。用展开公式改写可以避免它:

def kmeans_fast(X, k, max_iter=300, tol=1e-4, random_state=None): rng = np.random.default_rng(random_state) n_samples, n_features = X.shape idx = rng.choice(n_samples, size=k, replace=False) centers = X[idx].copy() X_sq = np.sum(X ** 2, axis=1) # (n,) for i in range(max_iter): # ||x - c||^2 = ||x||^2 - 2 x·c + ||c||^2 cross = X @ centers.T # (n, k) c_sq = np.sum(centers ** 2, axis=1) # (k,) dists_sq = X_sq[:, None] - 2 * cross + c_sq[None, :] labels = np.argmin(dists_sq, axis=1) new_centers = np.zeros_like(centers) counts = np.zeros(k) np.add.at(new_centers, labels, X) np.add.at(counts, labels, 1) # 处理空簇 empty = counts == 0 new_centers[~empty] /= counts[~empty, None] new_centers[empty] = centers[empty] shift = np.linalg.norm(new_centers - centers) centers = new_centers if shift < tol: break return labels, centers

这里用np.add.at做按标签累加,比循环快很多。np.add.at虽然比np.bincount慢,但能处理多维累加,通用性好。如果只关心速度,可以用np.bincount分别对每个特征维度做加权求和,会更快。

提示:np.add.at在旧版本NumPy上性能较差,如果数据量特别大,建议用scipy.sparse的矩阵乘法或者pandas.groupby来替代累加操作。

2.3 手写版和sklearn对拍,确认逻辑一致

写完自己的版本,一定要和sklearn对拍,确认结果在同一量级。注意不能直接比标签,因为簇的编号是任意的。正确的对拍方式是比inertia(簇内平方和):

from sklearn.cluster import KMeans from sklearn.datasets import make_blobs X, _ = make_blobs(n_samples=1000, centers=4, cluster_std=0.8, random_state=42) labels_mine, centers_mine = kmeans_fast(X, k=4, random_state=0) inertia_mine = sum(np.sum((X[labels_mine == j] - centers_mine[j]) ** 2) for j in range(4)) km = KMeans(n_clusters=4, n_init=10, random_state=0).fit(X) print("mine:", inertia_mine) print("sklearn:", km.inertia_)

如果两者差距在合理范围内(比如5%以内),说明你的实现基本正确。差距过大通常是初始化不同导致的局部最优差异,可以多跑几次取最好的。这个对拍习惯非常重要,我见过有人手写版把距离算错了一个符号,结果聚出来的簇看着"还行",实际完全不对,只有对拍才能发现。

3. K值怎么定:没有标准答案,但有靠谱流程

K-means最让人纠结的就是K取几。这不是一个能靠算法自动解决的问题,因为"多少个簇"本质上取决于你的业务目标。但有一套流程可以帮你把选择范围缩小到合理区间。

3.1 肘部法:看拐点,但别迷信拐点

肘部法的逻辑是:随着K增大,簇内平方和(inertia)必然下降,因为簇越多,每个簇越紧凑。但当K超过真实簇数之后,inertia的下降速度会明显变缓,形成一个"肘部"拐点。

import matplotlib.pyplot as plt from sklearn.cluster import KMeans inertias = [] K_range = range(1, 11) for k in K_range: km = KMeans(n_clusters=k, n_init=10, random_state=0).fit(X) inertias.append(km.inertia_) plt.plot(K_range, inertias, marker='o') plt.xlabel('K') plt.ylabel('Inertia') plt.show()

实际用的时候你会发现,肘部经常不明显,曲线平滑得像一条直线,根本找不到拐点。这时候别硬找,换别的方法。肘部法适合作为第一轮粗筛,把明显不合理的K排除掉。

3.2 轮廓系数:量化簇的分离质量

轮廓系数(Silhouette Score)衡量的是每个点"跟自己簇的相似度"比"跟最近邻簇的相似度"高多少。取值在-1到1之间,越接近1越好。

from sklearn.metrics import silhouette_score scores = [] for k in range(2, 11): km = KMeans(n_clusters=k, n_init=10, random_state=0).fit(X) s = silhouette_score(X, km.labels_) scores.append(s) print(f"K={k}, silhouette={s:.4f}")

轮廓系数的计算复杂度是$O(n^2)$,数据量大时会很慢。这时候可以采样计算,silhouette_score支持sample_size参数。

但轮廓系数也有陷阱:它偏好球状、分离良好的簇,如果你的数据本身是长条状或者密度不均,轮廓系数可能给出误导性的结果。所以它和肘部法一样,是参考不是圣旨。

3.3 业务约束优先于任何指标

这是我最想强调的一点。假设你在做用户分群,业务上明确只能运营3个群体(因为运营团队只有3个人),那K就是3,肘部法说4更好也没用。反过来,如果业务上需要精细分层,K=8虽然轮廓系数略低,但每个群体有明确的业务含义,那就选8。

我一般的流程是:先用肘部法和轮廓系数把候选K缩小到2-3个,然后对每个候选K跑一遍,人工看每个簇的特征均值,判断哪个K的分群结果最有业务解释性。能解释的聚类才是好聚类,指标只是辅助。

方法适用场景主要局限
肘部法快速粗筛,数据簇较分明拐点常不明显
轮廓系数需要量化对比不同K偏好球状簇,计算慢
业务约束有明确分群数量要求依赖领域知识
Gap Statistic需要统计显著性实现复杂,计算量大

4. 初始化:K-means++为什么能救命

前面反复提到,K-means对初始中心敏感。随机初始化可能选到两个挨得很近的点,导致它们最终收敛到同一个簇,另一个真实簇被拆散。K-means++就是为解决这个问题设计的。

4.1 随机初始化的失败长什么样

先看一个能复现失败的例子。用make_blobs生成4个簇,然后用完全随机的初始化跑K-means,多跑几次,观察inertia的波动:

inertias_random = [] for seed in range(20): km = KMeans(n_clusters=4, init='random', n_init=1, random_state=seed).fit(X) inertias_random.append(km.inertia_) print(f"min: {min(inertias_random):.2f}") print(f"max: {max(inertias_random):.2f}") print(f"std: {np.std(inertias_random):.2f}")

你会看到inertia波动很大,最差的一次可能是最好的一次的两倍以上。这意味着随机初始化经常掉进糟糕的局部最优。

4.2 K-means++的选点逻辑

K-means++的核心思想是:让初始中心彼此尽量远离。具体步骤是:

  1. 随机选第一个中心。
  2. 对每个点,计算它到已选中心集合的最近距离$D(x)$。
  3. 以正比于$D(x)^2$的概率选下一个中心。距离越远,被选中的概率越大。
  4. 重复2-3直到选够K个中心。

这个"正比于距离平方"的设计很巧妙:它既保证了远点有更大概率被选中,又保留了随机性,不会每次都选到同一个最远点。理论证明K-means++的期望inertia不超过最优解的$O(\log K)$倍,比纯随机的$O(K)$好得多。

sklearn的KMeans默认就是init='k-means++',所以大多数人其实一直在用,只是不知道。手写版要复现这个逻辑也不难:

def kmeans_plusplus_init(X, k, rng): n_samples = X.shape[0] centers = [X[rng.integers(n_samples)]] for _ in range(1, k): dists = np.min( [np.sum((X - c) ** 2, axis=1) for c in centers], axis=0 ) probs = dists / dists.sum() idx = rng.choice(n_samples, p=probs) centers.append(X[idx]) return np.array(centers)

4.3 n_init:多跑几次取最好

即使有了K-means++,单次运行仍可能不理想。所以sklearn有个n_init参数,表示用不同随机种子跑多少次,取inertia最小的那次。老版本默认是10,新版本(1.4之后)默认改成了'auto',在K-means++下等于1,在random下等于10。

我的建议是:如果数据量不大,n_init设成10到20,别省这点时间。聚类结果不稳定带来的返工成本远高于多跑几次的计算成本。如果数据量很大,至少设成3到5。

注意:n_init和random_state要配合使用。固定random_state能保证结果可复现,但如果你发现固定种子后结果依然不理想,说明这个种子恰好落进了坏局部最优,换个种子或者增大n_init。

5. 数据预处理:K-means的成败一半在这里

我见过太多人拿到数据直接丢进KMeans,然后抱怨结果没意义。十有八九是预处理没做。K-means基于欧氏距离,而欧氏距离对特征的量纲极其敏感。

5.1 标准化不是可选项

假设你有两个特征:年龄(18-80)和年收入(3万-300万)。算欧氏距离时,收入的差异会完全主导距离,年龄几乎不起作用。结果就是聚类只按收入分,年龄白给了。

解决办法是标准化,让每个特征均值为0、方差为1:

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_scaled = scaler.fit_transform(X)

标准化之后,每个特征对距离的贡献就相对均衡了。但要注意:标准化会改变簇的实际含义。如果你需要解释"这个簇的平均收入是多少",记得用scaler.inverse_transform把中心还原回原始量纲。

5.2 什么时候不该用标准化

标准化不是万能的。如果某个特征本身就有明确的物理意义,且你希望它在距离中占更大权重,那就不该标准化。比如做图像聚类时,像素值本身就是可比的,强行标准化反而破坏结构。

还有一种情况:特征已经是同量纲的比例值(比如都是0-1之间的概率),这时候标准化可能没必要,甚至有害。

我的经验法则是:如果各特征的单位不同,标准化;如果单位相同且业务上认为它们同等重要,可以不标准化;拿不准就先标准化跑一遍,再对比不标准化的结果。

5.3 降维:高维数据的预处理

K-means在低维(比如2-10维)表现最好。维度一高,欧氏距离会失效——所有点之间的距离趋于相等,这叫"维度灾难"。这时候可以先降维再聚类:

from sklearn.decomposition import PCA pca = PCA(n_components=0.95) # 保留95%方差 X_reduced = pca.fit_transform(X_scaled)

n_components=0.95表示自动选择能解释95%方差的主成分数量。降维后再跑K-means,通常又快又稳。但降维也有代价:主成分是原始特征的线性组合,解释性会变差。如果业务需要解释每个簇对应哪些原始特征,降维后要额外做映射分析。

6. 结果评估与调优:跑完不是终点

代码跑通、图也画出来了,不代表工作结束。K-means的结果需要评估、解释、迭代。

6.1 除了inertia和轮廓系数,还能看什么

  • Calinski-Harabasz指数:簇间离散度与簇内离散度的比值,越大越好,计算比轮廓系数快。
  • Davies-Bouldin指数:簇内散度与簇间距离的比值,越小越好。
  • 簇的稳定性:用bootstrap重采样,看多次聚类结果的一致性。如果换个样本子集簇结构就大变,说明结果不可靠。
from sklearn.metrics import calinski_harabasz_score, davies_bouldin_score print("CH:", calinski_harabasz_score(X_scaled, labels)) print("DB:", davies_bouldin_score(X_scaled, labels))

6.2 可视化:把高维结果压到二维看

最常用的可视化是先用PCA或t-SNE把数据降到二维,然后按簇标签上色:

from sklearn.manifold import TSNE X_2d = TSNE(n_components=2, random_state=0).fit_transform(X_scaled) plt.scatter(X_2d[:, 0], X_2d[:, 1], c=labels, cmap='viridis', s=10) plt.scatter(centers_2d[:, 0], centers_2d[:, 1], c='red', marker='X', s=200) plt.show()

注意t-SNE的坐标没有实际意义,只能看相对聚集关系。而且t-SNE本身有随机性,不同运行结果可能不同,别过度解读。

6.3 簇的解释:回到业务语言

这是最容易被跳过但最重要的一步。对每个簇,计算它在各个原始特征上的均值,然后翻译成业务语言。比如:

簇平均年龄平均消费频次平均客单价业务标签
0251280高频低客单年轻用户
1453500低频高客单成熟用户
2328200中频中客单主力用户

有了这样的表,聚类结果才能拿去和业务方沟通。如果某个簇的特征均值和其他簇差不多,说明这个簇可能是多余的,考虑减少K。

7. 工程落地时那些文档不写的坑

前面讲的偏方法论,这一节讲实操中真正会咬人的细节。

7.1 大数据量下的内存和速度

sklearn的KMeans在数据量大时会很慢,因为它默认用full batch。这时候有两个选择:

  • MiniBatchKMeans:每次只用一小批数据更新中心,速度快很多,结果略差但通常够用。
  • 自己分块计算距离:避免一次性生成巨大的距离矩阵。
from sklearn.cluster import MiniBatchKMeans mbk = MiniBatchKMeans(n_clusters=4, batch_size=1024, n_init=10, random_state=0) mbk.fit(X_scaled)

MiniBatchKMeans的batch_size需要调,太小结果不稳,太大失去速度优势。一般从1024或2048起步。

7.2 类别特征怎么处理

K-means只能处理数值特征。如果数据里有类别特征(比如性别、城市),直接做one-hot编码再标准化,会让类别特征在距离中占的权重取决于类别数量。类别多的时候,one-hot会主导距离。

更好的做法是用K-prototypes这类专门处理混合类型数据的算法,或者先把类别特征用目标编码、嵌入等方式转成低维数值表示。

7.3 结果不可复现怎么办

K-means结果不可复现通常有三个原因:没固定random_state、n_init太小、数据本身有随机性(比如用了随机采样)。解决办法是固定所有随机源,增大n_init,并且记录下每次运行的参数。

我习惯在代码里把random_state、n_init、max_iter、tol全部显式写出来,哪怕用的是默认值。这样半年后回头看,能准确复现当时的实验。

7.4 增量聚类:新数据来了怎么办

K-means是批处理算法,新数据来了不能直接增量更新。常见做法有两种:一是把新数据加进原数据重新跑(适合数据量不大);二是用已训练的中心对新数据做最近邻分配,然后定期重新训练(适合流式场景)。第二种做法要注意中心会逐渐漂移,需要设置重训练触发条件,比如累计新样本超过原样本的20%。

8. 一个完整的端到端示例

把前面所有东西串起来,走一遍完整流程。假设我们有一份用户行为数据,包含年龄、月消费、月访问次数三个特征。

import numpy as np import pandas as pd from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score from sklearn.decomposition import PCA import matplotlib.pyplot as plt # 1. 构造模拟数据 rng = np.random.default_rng(42) n = 1500 data = pd.DataFrame({ 'age': np.concatenate([ rng.normal(25, 3, 500), rng.normal(45, 5, 500), rng.normal(33, 4, 500) ]), 'monthly_spend': np.concatenate([ rng.normal(80, 20, 500), rng.normal(500, 80, 500), rng.normal(200, 40, 500) ]), 'visits': np.concatenate([ rng.normal(12, 3, 500), rng.normal(3, 1, 500), rng.normal(8, 2, 500) ]) }) # 2. 标准化 scaler = StandardScaler() X_scaled = scaler.fit_transform(data) # 3. 选K:肘部法 + 轮廓系数 inertias, sils = [], [] for k in range(2, 9): km = KMeans(n_clusters=k, n_init=10, random_state=0).fit(X_scaled) inertias.append(km.inertia_) sils.append(silhouette_score(X_scaled, km.labels_)) fig, ax1 = plt.subplots() ax1.plot(range(2, 9), inertias, 'b-o') ax1.set_xlabel('K') ax1.set_ylabel('Inertia', color='b') ax2 = ax1.twinx() ax2.plot(range(2, 9), sils, 'r-s') ax2.set_ylabel('Silhouette', color='r') plt.show() # 4. 最终聚类 km_final = KMeans(n_clusters=3, n_init=20, random_state=0).fit(X_scaled) data['cluster'] = km_final.labels_ # 5. 还原中心到原始量纲并解释 centers_original = scaler.inverse_transform(km_final.cluster_centers_) center_df = pd.DataFrame(centers_original, columns=data.columns[:3]) print(center_df) # 6. 可视化 X_2d = PCA(n_components=2).fit_transform(X_scaled) plt.scatter(X_2d[:, 0], X_2d[:, 1], c=data['cluster'], cmap='viridis', s=8) plt.title('K-means Clustering Result') plt.show()

跑完这个流程,你会得到三个簇的中心在原始量纲下的值,以及一张二维可视化图。如果三个簇在图上分得开,中心值也有明显差异,说明聚类有效。如果簇重叠严重,要么是K选错了,要么是特征不够区分,要么是数据本身就没有簇结构。

9. 几个我反复踩过的坑和对应解法

最后集中说几个具体问题,都是实际项目里遇到的。

坑一:忘了标准化,结果只按数值大的特征分。这个前面讲过,但真的高频。养成习惯:拿到数据先看各特征的量纲,不同就标准化。

坑二:K设得太大,每个簇只有几个点。簇太小没有统计意义,业务上也没法运营。一般每个簇至少要有几十到上百个样本,具体取决于业务。如果某个簇样本量明显偏少,考虑减小K或者把这个簇的点归到最近的簇。

坑三:用轮廓系数选K,但数据是长条状簇。轮廓系数会给出错误的偏好。这时候改用Calinski-Harabasz或者直接看业务解释性。

坑四:每次跑结果都不一样,以为代码有bug。大概率是没固定random_state或者n_init太小。固定种子,增大n_init,问题通常消失。

坑五:把K-means用在非凸形状数据上。前面说的同心圆例子。判断方法:先可视化看看数据形状,如果明显不是球状簇,直接换算法,别浪费时间调K-means。

坑六:忽略空簇。手写版必须处理,调库版sklearn会自动处理但可能给出警告。如果频繁出现空簇,说明K设大了或者初始化不好。

坑七:用inertia比较不同K的结果。inertia随K单调下降,不能直接比较。要比就比轮廓系数、CH指数这些归一化的指标。

这些坑我基本都踩过至少一遍,写出来是希望你能少走点弯路。K-means本身不复杂,但把它用对、用稳、用到能解释,需要的是对这些细节的持续关注。代码可以抄,但对数据的判断和对结果的解释,只能靠一次次实践积累。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询