K-Means 聚类
监督学习有标签——每一份数据都告诉你"答案是什么"。但现实中,绝大多数数据是没有标注的:用户的浏览记录、传感器的时序信号、社交网络中的行为日志。面对一堆没有标签的点,我们能做什么?
聚类(Clustering)的回答是:物以类聚。如果某些数据点在特征空间中彼此靠近,它们很可能属于同一类。我们不需要标签,只需要一个"距离"的概念。
K-Means 是最经典、最直觉的聚类算法。它的核心思想只有一句话:把数据分成 K 组,让每组内的点尽可能靠近它们的中心。
K-Means 的优雅之处在于,它把一个 NP-hard 的联合优化问题,拆成了两个极其简单的交替步骤:
| 步骤 | 名称 | 操作 |
|---|---|---|
| Step 1 | 分配(Assignment) | 将每个点分配到距离最近的中心所在的簇 |
| Step 2 | 更新(Update) | 将每个簇的中心更新为该簇所有点的均值 |
用流程图来直观展示整个迭代过程:
graph TD
A["随机初始化 K 个中心"] --> B["分配:每个点归入最近中心"]
B --> C["更新:每个中心取簇内均值"]
C --> D{"中心是否变化?"}
D -->|"是"| B
D -->|"否"| E["输出最终聚类结果"]
style A fill:#3498db,color:#fff
style B fill:#e74c3c,color:#fff
style C fill:#27ae60,color:#fff
style E fill:#f39c12,color:#fff
形式化地,设第 $t$ 轮迭代时,簇中心为 $\mu_1^{(t)}, \ldots, \mu_K^{(t)}$:
分配步骤:对每个数据点 $x_i$,找到最近的中心:
更新步骤:重新计算每个簇的均值:
其中 $C_j^{(t)} = \{i : c_i^{(t)} = j\}$ 是第 $j$ 个簇的数据点集合。
import numpy as np
def kmeans(X, K, max_iters=100):
n, d = X.shape
# 随机初始化:从数据中随机选 K 个点作为初始中心
idx = np.random.choice(n, K, replace=False)
centroids = X[idx].copy()
for _ in range(max_iters):
# Step 1: 分配 — 每个点归入最近的中心
# distances[i][j] = ||x_i - mu_j||^2
distances = np.sum((X[:, None, :] - centroids[None, :, :]) ** 2, axis=2)
labels = np.argmin(distances, axis=1)
# Step 2: 更新 — 每个中心取簇内均值
new_centroids = np.zeros_like(centroids)
for j in range(K):
members = X[labels == j]
if len(members) > 0:
new_centroids[j] = members.mean(axis=0)
else:
new_centroids[j] = centroids[j] # 空簇保留原中心
# 收敛判断
if np.allclose(centroids, new_centroids):
break
centroids = new_centroids
return labels, centroids
空簇处理
当某个簇在分配步骤后没有收到任何数据点时,就产生了空簇。常见处理策略:(1) 保留原中心;(2) 从最大簇中分裂一个点过来;(3) 随机重新选择一个中心。实际应用中空簇并不常见,但代码中必须处理。
K-Means 的收敛性可以从一个优雅的不等式链得出。定义失真函数(Distortion Function):
也就是所有数据点到其所属簇中心的距离平方和。K-Means 的目标就是最小化 $J$。
每一轮迭代中,两个步骤分别做了什么?
| 步骤 | 固定什么 | 优化什么 | 对 $J$ 的影响 |
|---|---|---|---|
| 分配 | 中心 $\mu$ | 分配 $c_i$ | $J$ 不增(每个点选最近中心) |
| 更新 | 分配 $c_i$ | 中心 $\mu_j$ | $J$ 不增(均值最小化平方和) |
更新步骤为什么能降低 $J$?这是一个经典的结论:对于固定的簇 $C_j$,使得 $\sum_{i \in C_j} \|x_i - \mu\|^2$ 最小的 $\mu$ 恰好就是算术平均值。可以通过对 $\mu$ 求导令其为零直接验证。
由于每步都不增,且 $J$ 有下界($\geq 0$),所以 $J$ 必然收敛。又因为可能的分配方案只有有限种($K^n$ 种),所以经过有限步后分配不再变化,算法终止。
既然 K-Means 对初始中心敏感,一个自然的想法是:能不能让初始中心"均匀铺开",而不是随机扎堆?
K-Means++(Arthur & Vassilvitskii, 2007)用一种贪心策略实现了这一点:
| 步骤 | 操作 |
|---|---|
| 1 | 从数据中随机选择第一个中心 $\mu_1$ |
| 2 | 对每个数据点 $x$,计算它到已选中心的最短距离 $D(x) = \min_j \|x - \mu_j\|^2$ |
| 3 | 以概率 $p(x) \propto D(x)^2$ 选择下一个中心(距离越远,概率越大) |
| 4 | 重复步骤 2-3 直到选出 $K$ 个中心 |
直觉上,这就像撒种子——第一颗随机撒,后续的种子倾向于落在离已有种子最远的地方,确保初始中心"覆盖"整个数据分布。
def kmeans_plus_plus_init(X, K):
n, d = X.shape
centroids = np.zeros((K, d))
# 第一个中心随机选
centroids[0] = X[np.random.randint(n)]
for k in range(1, K):
# 计算每个点到已有中心的最短距离平方
dists = np.min(
np.sum((X[:, None, :] - centroids[:k][None, :, :]) ** 2, axis=2),
axis=1
)
# 按距离平方的概率分布采样
probs = dists / dists.sum()
centroids[k] = X[np.random.choice(n, p=probs)]
return centroids
理论保证
K-Means++ 有一个漂亮的理论结果:它找到的初始中心对应的失真值 $J$,期望不超过最优解的 $O(\log K)$ 倍。具体来说,$\mathbb{E}[J] \leq 8(\ln K + 2) \cdot J^*$,其中 $J^*$ 是全局最优失真值。虽然这不是常数因子近似,但在实践中 K-Means++ 的表现远优于随机初始化。
K-Means 有一个无法回避的超参数:簇数 $K$。如何选择合适的 $K$?
肘部法则(Elbow Method)
最直觉的方法:对不同的 $K$ 值分别运行 K-Means,画出失真值 $J$ 随 $K$ 变化的曲线。
随着 $K$ 增大,$J$ 必然单调递减(极端情况 $K = n$ 时 $J = 0$)。但在某个点之后,$J$ 的下降速度会明显变缓,形成一个"肘部"。这个拐点对应的 $K$ 就是较好的选择。
轮廓系数(Silhouette Coefficient)
轮廓系数为每个数据点 $x_i$ 定义了一个质量分数,衡量它的"归属感"有多强:
其中:
· $a(i)$:$x_i$ 到同簇其他点的平均距离(内聚度,越小越好)
· $b(i)$:$x_i$ 到最近其他簇的平均距离(分离度,越大越好)
轮廓系数的取值范围 $[-1, 1]$:
| 范围 | 含义 |
|---|---|
| 接近 1 | 点与同簇非常紧密,与其他簇很远——完美分配 |
| 接近 0 | 点处于两个簇的边界——模棱两可 |
| 接近 -1 | 点可能被分配到了错误的簇 |
对不同的 $K$ 计算所有点的平均轮廓系数,选择使均值最大的 $K$。
from sklearn.metrics import silhouette_score
# 对不同的 K 计算轮廓系数
silhouette_scores = []
K_range = range(2, 11) # K 至少为 2 才有意义
for K in K_range:
labels, _ = kmeans(X, K)
score = silhouette_score(X, labels)
silhouette_scores.append(score)
# 选择轮廓系数最高的 K
best_K = K_range[np.argmax(silhouette_scores)]
其他评估指标
| 指标 | 类型 | 适用场景 |
|---|---|---|
| Calinski-Harabasz Index | 内部指标 | 簇间方差 / 簇内方差,越大越好 |
| Davies-Bouldin Index | 内部指标 | 簇内散度 / 簇间距离,越小越好 |
| Gap Statistic | 内部指标 | 对比均匀分布的期望 $J$,找差距最大的 $K$ |
| Adjusted Rand Index | 外部指标 | 有真实标签时才能用 |
K-Means 的简洁背后隐藏了几个强假设,了解它们才能避免误用:
| 假设 | 含义 | 失效场景 |
|---|---|---|
| 球形簇 | 簇在欧氏空间中呈凸形 | 环形、月牙形、不规则形状 |
| 等大小 | 各簇大小大致相同 | 一个巨大簇 + 几个微小簇 |
| 等密度 | 各簇内部密度相近 | 高密簇和稀疏簇并存 |
| 固定 K | 簇数需要预先指定 | 完全未知数据结构 |
| 欧氏距离 | 用 $L_2$ 距离衡量相似度 | 高维数据(维度灾难) |
高维灾难
在高维空间中,所有点对之间的距离趋于相同(距离的相对差异消失),K-Means 基于距离的分配失去意义。常见的应对方案是先降维(PCA、t-SNE、UMAP),再在低维空间中聚类。
一个经典的失效案例:两个同心圆环。K-Means 无论如何也无法正确分离它们,因为它只能切出"饼状"的区域。
针对 K-Means 的各种局限,研究者提出了多种变体:
| 变体 | 改进点 | 核心思想 |
|---|---|---|
| K-Means++ | 初始化 | 概率采样,距离越远越容易被选中 |
| Mini-Batch K-Means | 大规模数据 | 每轮只用一小批数据更新中心,牺牲精度换速度 |
| K-Medoids (PAM) | 鲁棒性 | 中心必须是真实数据点(medoid),对异常值更鲁棒 |
| Kernel K-Means | 非线性 | 在核空间中聚类,可以分离非凸簇 |
| Fuzzy C-Means | 软分配 | 每个点以不同"隶属度"属于多个簇 |
Mini-Batch K-Means
当数据量达到百万甚至亿级时,每轮遍历所有数据太慢。Mini-Batch K-Means 每次随机抽取一小批数据(如 100~1000 个点)来更新中心:
def mini_batch_kmeans(X, K, batch_size=100, max_iters=100):
n, d = X.shape
centroids = kmeans_plus_plus_init(X, K)
counts = np.zeros(K) # 每个簇累计分配的点数
for _ in range(max_iters):
# 随机采样一个小批
batch_idx = np.random.choice(n, batch_size, replace=False)
batch = X[batch_idx]
# 分配:小批内每个点归入最近中心
dists = np.sum((batch[:, None, :] - centroids[None, :, :]) ** 2, axis=2)
labels = np.argmin(dists, axis=1)
# 增量更新中心
for i, label in enumerate(labels):
counts[label] += 1
eta = 1.0 / counts[label] # 学习率递减
centroids[label] = (1 - eta) * centroids[label] + eta * batch[i]
return centroids
Mini-Batch 的关键技巧是增量更新:每来一个点,中心按学习率 $\eta = 1/n_j$($n_j$ 是该簇累计处理的点数)做一步梯度步。这使得算法只需 $O(b)$ 而非 $O(n)$ 的每轮开销,且在实践中效果损失很小。
K-Means 看起来和概率模型没什么关系,但它实际上是高斯混合模型(GMM)的一个特例,对应 EM 算法的一种极端退化。
在高斯混合模型中,假设数据由 $K$ 个高斯分布混合生成:
EM 算法同样有两步:
| K-Means | EM (GMM) | |
|---|---|---|
| E 步 | 硬分配:$c_i = \arg\min_j \|x_i - \mu_j\|^2$ | 软分配:$\gamma_{ij} = P(z_i = j \mid x_i)$(后验概率) |
| M 步 | 更新均值 $\mu_j$ | 更新 $\mu_j, \Sigma_j, \pi_j$(所有参数) |
| 协方差 | 隐含 $\Sigma_j = \sigma^2 I$(各向同性,等方差) | |
| 混合权重 | 隐含 $\pi_j = 1/K$(等权重) |
当 GMM 的协方差矩阵趋于 $\sigma^2 I$ 且 $\sigma^2 \to 0$ 时,软分配退化为硬分配——每个点以概率 1 属于最近的簇。这就是 K-Means。
graph TD GMM["高斯混合模型
(GMM)"] -->|"Σ→σ²I, σ→0"| KM["K-Means
(硬分配)"] GMM -->|"一般情况"| EM["EM 算法
(软分配)"] KM --> INIT["K-Means++
(智能初始化)"] KM --> MB["Mini-Batch
(大规模)"] KM --> MED["K-Medoids
(鲁棒)"] EM --> FB["Fuzzy C-Means
(模糊聚类)"] EM --> VAR["变分推断
(变分 EM)"] style GMM fill:#3498db,color:#fff style KM fill:#e74c3c,color:#fff style EM fill:#27ae60,color:#fff
时间复杂度
| 步骤 | 时间 | 说明 |
|---|---|---|
| 每轮迭代 | $O(nKd)$ | $n$ 个点 × $K$ 个中心 × $d$ 维距离 |
| 总时间 | $O(nKd \cdot T)$ | $T$ 为迭代轮数,通常 $T \ll n$ |
| K-Means++ 初始化 | $O(nKd)$ | 逐个选中心,每轮算距离 |
| Mini-Batch 每轮 | $O(bKd)$ | $b \ll n$,每轮极快 |
关键观察:K-Means 的复杂度与数据量 $n$ 成线性关系,这使得它在大规模数据上非常实用。相比之下,层次聚类的复杂度是 $O(n^2)$ 或 $O(n^3)$。
sklearn 实战模板
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score
import numpy as np
# 1. 标准化(K-Means 对量纲敏感!)
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
# 2. 用肘部法则 + 轮廓系数选 K
for K in range(2, 11):
km = KMeans(n_clusters=K, init='k-means++', n_init=10, random_state=42)
labels = km.fit_predict(X_scaled)
print(f"K={K}: inertia={km.inertia_:.2f}, "
f"silhouette={silhouette_score(X_scaled, labels):.4f}")
# 3. 用选定的 K 训练最终模型
best_K = 4
km = KMeans(n_clusters=best_K, init='k-means++', n_init=10)
labels = km.fit_predict(X_scaled)
centroids = scaler.inverse_transform(km.cluster_centers_)
# 4. 大规模数据用 Mini-Batch
from sklearn.cluster import MiniBatchKMeans
mbkm = MiniBatchKMeans(n_clusters=best_K, batch_size=1000, n_init=3)
labels = mbkm.fit_predict(X_scaled)
工程实践要点
1. 一定要标准化:K-Means 基于欧氏距离,特征量纲不同会导致结果偏向方差大的维度。
2. n_init 设为 10+:sklearn 默认用 K-Means++ 运行 n_init 次,取最优结果。这是应对局部最优的实用策略。
3. 高维先降维:超过 50 维时,先用 PCA 将维度降至 10~20 再聚类。
4. 类别特征需编码:K-Means 只能处理数值特征,类别特征需要 one-hot 或 embedding。
graph LR CORE["K-Means
核心算法"] --> A["两步迭代
分配 + 更新"] CORE --> B["失真函数 J
收敛到局部最优"] CORE --> C["K-Means++
O(log K) 近似"] CORE --> EVAL["评估"] EVAL --> E1["肘部法则"] EVAL --> E2["轮廓系数"] EVAL --> E3["CH / DB 指标"] CORE --> VAR["变体"] VAR --> V1["Mini-Batch
O(b) 每轮"] VAR --> V2["K-Medoids
鲁棒"] VAR --> V3["Kernel K-Means
非线性"] CORE --> CONN["深层联系"] CONN --> G1["GMM / EM
软分配推广"] CONN --> G2["向量量化
信号压缩"] style CORE fill:#e74c3c,color:#fff style EVAL fill:#27ae60,color:#fff style VAR fill:#3498db,color:#fff style CONN fill:#f39c12,color:#fff
参考来源
- Arthur, D. & Vassilvitskii, S. (2007). K-Means++: The Advantages of Careful Seeding. SODA 2007.
- Bishop, C. M. Pattern Recognition and Machine Learning, Chapter 9: Mixture Models and EM
- Sculley, D. (2010). Web-Scale K-Means Clustering. WWW 2010. (Mini-Batch K-Means)
- scikit-learn: Clustering — K-Means