3. K-means聚类原理与实现归纳总结
K-means 应该是空间转录组分析里最常用的无监督方法了。为什么?因为它简单、快,结果也还算直观。我当年刚开始接触 Visium 数据时,第一个试的就是 K-means,跑完一看聚类图——嗯,组织学结构基本能对上,当时就觉得这门算法有点东西。
但别急着上手。你得先搞清楚它到底在算什么,以及为什么有些坑踩进去就爬不出来。
3.1 算法核心——一句话讲明白
K-means 的目标很简单:把高维空间里的点分成 K 堆,每堆内部尽量紧凑,堆与堆之间尽量疏远。说白了就是“物以类聚,人以群分”,只不过是用数学方式算出来的。
它的迭代逻辑只有四步:
- 初始化:随机选 K 个点作为初始聚类中心。
- 分配:每个样本点找离自己最近的中心,归入那个簇。
- 更新:计算每个簇的均值,作为新的中心。
- 重复:跳到第 2 步,直到中心不再变化或达到最大迭代次数。
你想想看,整个过程就是一个“猜中心 → 划分子 → 算平均 → 再猜”的循环。数学上,它是在最小化每个样本到其所在簇中心点距离的平方和——也就是惯性(inertia)。
K-means 的损失函数:
J = Σ (x - μ_k)²
其中 μ_k 是第 k 个簇的中心,x 属于该簇。最小化这个值,就是让簇内点尽可能密集。
3.2 距离度量与数据标准化
默认都用欧氏距离。但我在 Visium 项目中遇到过一个问题:基因表达矩阵里不同基因的方差差了几个数量级,直接用原始值跑 K-means,高方差基因会主导聚类结果。你懂吧,那结果基本就是“谁数值大谁说了算”。
所以建议先做标准化:要么 Z-score 按基因缩放,要么用对数归一化 + 标准化。我个人习惯在降维到前 50 个 PCA 主成分后再跑 K-means,这样既能去噪声又能降维,速度也快不少。
| 场景 | 推荐处理 | 原因 |
|---|---|---|
| 原始表达量 | LogNormalize + Scale | 消除文库大小差异,均衡基因方差 |
| PCA 降维后 | 不需要再标准化 | PCA 本身已经对主成分做了方差归一 |
| 空间坐标 | 保持原始坐标或 min-max 归一 | 避免组织形状被缩放扭曲 |
3.3 手动实现就是最好的理解方式
我建议每个学聚类的人都手写一遍 K-means。别怕麻烦,写一次比看十遍原理都管用。下面这个实现去掉了花哨的加速,保留了最核心的逻辑:
import numpy as np
def k_means(X, K, max_iter=100, tol=1e-4):
# 随机初始化中心
idx = np.random.choice(len(X), K, replace=False)
centers = X[idx]
for i in range(max_iter):
# 分配:计算每个点到所有中心的欧氏距离
distances = np.linalg.norm(X[:, None] - centers, axis=2)
labels = np.argmin(distances, axis=1)
# 更新中心
new_centers = np.array([X[labels == k].mean(axis=0) for k in range(K)])
# 检查收敛
shift = np.linalg.norm(new_centers - centers)
if shift < tol:
break
centers = new_centers
return labels, centers
这段代码你拿去跑小样本数据完全能用。但在 Visium 数据上——通常有几千个 spot、两万个基因——别这么干。建议直接用 sklearn.cluster.KMeans,它优化了初始中心选择(k-means++)和迭代加速,还支持并行。
sklearn 用法:
from sklearn.cluster import KMeans
km = KMeans(n_clusters=8, init='k-means++', n_init=10, random_state=42)
labels = km.fit_predict(X_pca)
3.4 在 Visium 数据上的实战经验
Visium 每个 spot 是一个 55μm 的圈,每个圈里混合了几到十几个细胞。所以 spot 的表达其实是个混合信号。这时候做 K-means,你会发现斑点之间的边界通常对应组织层的分界。嗯,很好理解——不同层域的细胞类型不同,表达谱自然有差异。
但我踩过一个坑:直接用全部基因(~20k)跑 K-means,结果每次运行聚类都不一样。为什么?高维空间下距离度量失效(“维度灾难”),加上初始中心随机,结果极不稳定。
解决办法:
- 先做特征选择:选高变基因(top 2000~5000)。
- 再 PCA 降到 30~50 维。
- 最后跑 K-means,设置
n_init=20以上,选最优解。
有一回我忘了对坐标做对齐,把两个不同切片拼一起跑了 K-means——结果聚出来的簇完全按照切片ID分开,毫无生物学意义。你想想,这多误人子弟。
3.5 K 值怎么选?肘部法和轮廓系数
K-means 最头疼的就是要自己定 K。没有标准答案。我习惯先用肘部法画惯性曲线:
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
inertias = []
K_range = range(2, 15)
for k in K_range:
km = KMeans(n_clusters=k, init='k-means++', n_init=10, random_state=42)
km.fit(X_pca)
inertias.append(km.inertia_)
plt.plot(K_range, inertias, 'bo-')
plt.xlabel('K')
plt.ylabel('Inertia')
plt.show()
找那个"拐点"——惯性下降速度突然变缓的地方。但说实话,很多 Visium 数据并不存在明显拐点,这时候我会配合轮廓系数(silhouette score)一起看。
| 指标 | 适用场景 | 注意 |
|---|---|---|
| 惯性(inertia) | 初筛,看曲线下降趋势 | 越大不一定越差,得看拐点 |
| 轮廓系数 | 评估簇内紧密度和簇间分离度 | 值在[-1,1],越大越好 |
| 生物学先验 | 如果知道组织有几层 | 手动预设 K,再微调 |
千万别迷信某个单一指标选 K。我记得有次肘部法给出的 K=4,轮廓系数却给了 K=7。最后结合病理图像一看,K=7 把白质里几个亚结构分开了,更有意义。所以,多用几个视角,包括空间可视化检查。
3.6 缺点和避坑清单
- 对初始中心敏感:每次运行结果可能不同。用 k-means++ 初始化 + 多次重复取最优。
- 假设簇是球形且大小相近:Visium 中有些细胞类型很少(比如免疫细胞浸润),这些簇会被其他大簇吞并。遇到这种情况,可以考虑用 DBSCAN 或层次聚类。
- 无法处理离群点:个别离群的 spot 会被强行归入某个簇,干扰边界。我一般先做一轮 DBSCAN 过滤掉异常点后再跑 K-means。
- 尺度敏感:一定要对特征进行标准化,否则高数值特征主导结果。
- K 值难以确定:上面已经说了,结合多种方法,并做最终的空间可视化确认。
3.7 本章知识结构
下面这张图帮你把 K-means 在 Visium 上的完整流程串起来,从数据准备到聚类后验证。我画的时候特别强调了“标准化”和“降维”这两步——很多人跳过它们直接跑 K-means,然后来问我为什么结果不好。
K-means 是空间转录组分析工具箱里的“瑞士军刀”——好用、通用,但别指望它处理所有问题。下一把刀是层次聚类,它不需要预设 K,而且能画出好看的树状图。不过那是另一个话题了,先把 K-means 练熟再说。