做机器学习的人大概都有过这种体验:拿到一批没有标签的数据,第一反应就是先分分组、看看结构。分组这件事,最简单的方案是K-Means,但遇到簇形状复杂、不知道应该分几类时,层次聚类往往是那个更稳妥的起点。
层次聚类不是某个高深莫测的模型,它更像一套“自底向上合并或自顶向下切分”的规则。你不需要预先指定最终分成几个簇,算法会先把每个样本当成一个独立的簇,然后按照距离不断合并相邻的簇,直到最后只剩一个簇。整个过程可以画成一棵倒置的树,树的高度就是被合并集团之间的距离。这个特性让它在探索性数据分析里特别有用:它不但给你最终的分组结果,还顺手把数据内部的“家族谱系”展示出来。
这篇记录不打算讲教科书式的理论推导,而是结合我自己实际跑过的数据和踩过的坑,把层次聚类的原理、Python实现、参数选择和大数据场景下的替代思路都捋一遍。适合刚接触机器学习、想用聚类做数据探索的人,也适合那些已经跑过K-Means、但对层次聚类只停留在一知半解的读者。
1. 先弄明白层次聚类到底解决了什么问题
1.1 无标签数据分组的本质矛盾
聚类问题的本质,是把没有标签的数据按某种“相似度”组织起来。这里的相似度并不是唯一的:有的数据适合用欧氏距离,有的适合用余弦距离,有的适合用Jaccard系数。这意味着聚类结果从来不是唯一的标准答案,而是与你选的度量标准强相关。很多新手容易忽略这一点,以为随便调个包就行,实际上你选的“相似”定义会直接决定后面的每一轮合并行为。
更麻烦的是,大多数现实场景里我们连“最终要分几类”都不知道。客户画像要分成5类还是8类?文本主题要分成10个还是20个?这些都不是提前能拍脑袋定下来的。K-Means需要你先给定K值,等于把最不确定的问题放在了最前面。而层次聚类把这个问题推迟到了最后,它先把所有可能的合并顺序暴露给你,再由你来决定“在哪一层停下来”。这种延迟决策的特性,对前期探索特别友好。
1.2 与K-Means相比,它的不可替代性在哪
K-Means的经典假设是簇近似球形、大小相近。如果数据里存在一个很长的条状簇,或者一个簇被另一个簇包围,K-Means经常会把它们硬切成几块。层次聚类因为是基于“邻居关系”逐层合并的,对付这类形状更灵活。尤其是使用单链接准则时,它可以捕捉到任意形状的连通结构,这是K-Means很难做到的。
还有一个差异容易被忽略:K-Means用簇中心来代表一个簇,层次聚类没有中心,它记录的是样本之间的合并关系。这会造成两种结果:层次聚类结果更透明,可以解释为什么这两个样本会出现在同一个簇里;但新样本进来以后,没有一个现成的“中心”可以直接判断它属于哪个簇。后者决定了层次聚类更偏向离线分析任务,不太适合在线实时打分场景。
1.3 什么场景下优先考虑层次聚类
我个人常见的优先采用层次聚类的场景包括:用户行为路径的群体归纳、文本主题结构初探、基因表达矩阵的样本聚类,以及任何“我想看看这批数据到底是什么形状”的探索任务。这类场景的共同特征是:不知道分成几类、需要可解释的合并过程、数据规模可控。
如果你只是要给几千万条样本快速打上编号,且簇的形状近似球形,那K-Means或者Mini-Batch K-Means会更合适。层次聚类在这种规模下会非常痛苦,后面我会专门展开原因。所以现在的做法通常是先用层次聚类在小样本上搞清楚结构,再拿结论指导大规模数据上的高效算法。
2. 核心机制拆解:从样本到树状图
2.1 自底向上聚合与自顶向下分裂
层次聚类的主流实现是聚合式,也叫自底向上。初始时每个样本都是一个独立的簇,然后不断找到距离最近的两个簇,把它们合并成新簇,重复直到只剩一个簇。另外还有分裂式,反过来从整个数据出发,每次把最分散的簇拆成两个,直到每个样本单独成簇。分裂式的计算量更大,实际工具里用得少,大多数库实现的都是聚合式。
聚合式的每一步都会产生一次合并信息,包括“哪两个簇合并了”以及“它们之间的距离”。这些信息被存成一个矩阵,也就是后面画树状图要用的Z矩阵。这个矩阵是层次聚类的重要产物,它保留了所有样本合并的全过程,和那些只输出最终标签的聚类算法很不一样。你不仅要会看最终结果,还要学会从这个矩阵里读取合并逻辑。
2.2 树状图到底怎么读
树状图可以理解成聚类过程的族谱。底部每个叶子代表一个原始样本,越往上,分支越聚合;纵轴表示合并时两个簇之间的距离。如果你在某个高度上横着切一刀,所有被这条线切断的地方,就是被划分成的簇。同一棵树,切的位置不同,簇数就不同。
我最常用的方法是先整体看一眼树形,哪里有一段很长的“垂直空白”,说明这一段距离内没有合并发生,旁边就是比较自然的切分点。这种空白越长,说明跨越不同集团的间隔越大、切出来的簇越稳定。多试几个高度,再结合业务含义选一个。树状图不是一个只能定K值的工具,它还能帮你发现离群样本、异常小簇和潜在的层级嵌套关系。
2.3 链接准则决定簇的形态
链接准则本质上回答一个问题:当两个簇各含很多样本时,它们之间的“距离”到底怎么算。最常见的四种:单链接、全链接、平均链接、沃德法。它们的差异直接决定最终簇的形态,我放一张对比表。
| 链接准则 | 合并依据 | 结果倾向 | 注意点 |
|---|---|---|---|
| 单链接(single) | 两个簇之间最近样本的距离 | 擅长找长条、任意形状 | 容易链状效应 |
| 全链接(complete) | 两个簇之间最远样本的距离 | 偏向紧致球形簇 | 对异常值敏感 |
| 平均链接(average) | 所有样本对距离的平均值 | 中庸,应用最广 | 计算量稍大 |
| 沃德法(ward) | 合并后组内平方和的增量 | 球形且大小接近的簇 | 只适合欧氏距离 |
表格之外还有一点值得说:沃德法在统计上其实是最小化每次合并造成的“离差平方和增量”,可以理解成让每次合并后的簇内部尽量紧凑。它通常能得到结果很好看、大小相对均衡的簇,但因为它基于方差概念,默认要求使用欧氏距离,不能随便换余弦距离。你在做文本数据时如果选了沃德法,就要特别注意这一点。
3. 用Python跑通一次完整层次聚类
3.1 造一份模拟数据并做标准化
我在本地用一份模拟数据跑了一遍完整的层次聚类流程,数据由三个不同位置、不同离散程度的高斯簇组成。先用NumPy生成数据,再调用StandardScaler做标准化。
import numpy as np from sklearn.preprocessing import StandardScaler np.random.seed(42) n = 120 cluster1 = np.random.normal(loc=[0, 0], scale=0.6, size=(n, 2)) cluster2 = np.random.normal(loc=[5, 3], scale=0.5, size=(n, 2)) cluster3 = np.random.normal(loc=[2, -4], scale=0.7, size=(n, 2)) X = np.vstack([cluster1, cluster2, cluster3]) X_scaled = StandardScaler().fit_transform(X)标准化的目的不是走流程,而是防止个别特征的量纲覆盖其他特征。比如一份数据里“消费金额”动辄几千,“点击次数”只有个位数,如果不标准化,距离计算基本会被消费金额支配,最后分出来的簇可能就等于按消费金额划分的区间。这个问题在层次聚类里尤其严重,因为它用的是样本之间的距离矩阵,而不是像决策树那样对每个特征独立做切分。
3.2 用SciPy计算距离并完成层次聚类
层次聚类在SciPy里的核心函数是linkage,它直接接收标准化后的特征矩阵,内部先计算样本间距离,再按指定方法做合并。我习惯用沃德法,因为大多数探索场景下它结果最稳定。
from scipy.cluster.hierarchy import linkage, dendrogram import matplotlib.pyplot as plt Z = linkage(X_scaled, method='ward')这里的Z是一个形状为(n-1, 4)的矩阵,每一行记录一次合并:前两个数是合并的两个簇编号,第三个数是合并时的距离,第四个数是新簇里包含的样本数。这个矩阵就是整棵树的完整结构,后续的树状图、簇划分全都依赖它。
拿到Z之后,直接画树状图。
plt.figure(figsize=(10, 6)) dendrogram(Z) plt.title("Hierarchical Clustering Dendrogram") plt.xlabel("Sample index") plt.ylabel("Distance") plt.show()如果样本量比较大,叶子标签会挤成一团,可以用truncate_mode和p参数对树做裁剪,只显示顶层若干个分支。实际工作中几乎不会有人把几百个原始样本的标签都画出来,大部分时候只关心顶层结构。
3.3 用fcluster划分最终簇
树状图画出来后,需要从Z矩阵里提取每个样本的簇标签。SciPy提供了fcluster函数,支持两种常用的划分方式:按最大簇数划分,或者按距离阈值划分。
from scipy.cluster.hierarchy import fcluster # 方式一:指定最终簇数 labels_3 = fcluster(Z, t=3, criterion='maxclust') # 方式二:指定距离阈值 labels_cut = fcluster(Z, t=1.5, criterion='distance')方式一适合你已经通过树状图确定了K值的情况;方式二更贴近“在某个高度切一刀”的直觉,t取多少取决于树状图纵轴的刻度。我通常先用方式一快速得到3到5类的标签,然后计算轮廓系数看质量,再回到树状图上验证是否合理。
3.4 sklearn版本对比
如果不想自己处理Z矩阵,可以直接用sklearn的AgglomerativeClustering,代码如下:
from sklearn.cluster import AgglomerativeClustering model = AgglomerativeClustering(n_clusters=3, linkage='ward') labels_sklearn = model.fit_predict(X_scaled)sklearn版本的优点是接口统一,和fit_predict、silhouette_score这些工具能无缝衔接;缺点是拿不到树状图需要的Z矩阵。所以我的习惯是:画图用SciPy,批量实验用sklearn。两个版本在相同参数下结果一致,不用担心对不上。
4. 簇数怎么定:四种判断方法
4.1 树状图最大间隙法
层次聚类最直观的定K方式就是看树状图。画完之后,找到纵轴上最长的纵向空白段,在那里画一条水平线,打断的分支数就是候选簇数。
这个方法不需要额外计算,看一眼就能得出一个区间。它的缺点是有主观性,不同人看同一棵树可能选不同的高度。我通常用树状图定一个候选区间,比如“看起来在3到5类之间”,然后再用下面几种定量方法去缩小范围。
4.2 轮廓系数快速筛选
轮廓系数是常用的聚类效果评价指标,取值范围在负一到一之间,越接近一说明簇内越紧凑、簇间越分离。对层次聚类来说,可以循环尝试不同K值,然后比较轮廓系数。
from sklearn.metrics import silhouette_score for k in range(2, 9): labels = AgglomerativeClustering(n_clusters=k, linkage='ward').fit_predict(X_scaled) score = silhouette_score(X_scaled, labels) print(f"k={k}, silhouette={score:.4f}")需要注意的是,轮廓系数是基于欧氏距离和样本点计算的,如果你用了余弦距离或者Jaccard系数,直接在scaled数据上算轮廓系数可能不匹配。最好也用同样的距离定义去算轮廓,或者干脆把它当成参考指标,不要当成唯一标准。
4.3 肘部法和组内平方和
还有一种思路是计算不同K值下的组内平方和WCSS,原理和K-Means的肘部法一样。先拿到簇标签,计算每个簇内部样本到簇中心的距离平方和,再全部加起来。随着K增大,WCSS总体会下降,下降速度在某处出现拐点,这个拐点就是备选簇数。
from scipy.spatial.distance import cdist def compute_wcss(X, labels): wcss = 0 for cluster_id in np.unique(labels): cluster_points = X[labels == cluster_id] center = cluster_points.mean(axis=0) wcss += np.sum(cdist([center], cluster_points) ** 2) return wcss这个方法比轮廓系数快,因为它不涉及样本两两距离的平方和。但实际数据里肘部的拐点经常不明显,需要配合树状图一起看。我的经验是:轮廓系数和肘部法不一致时,优先相信业务可解释性,其次再看统计指标。
4.4 业务约束才是最硬的K值
统计指标给出的是“数学上比较合理的范围”,但最终K值往往由业务目标决定。客户分群如果最终是为了配置三档运营策略,分成七类可能根本没有对应资源;文本主题如果每一类都要人工审阅,类别数量就得控制在人力可覆盖的范围内。
所以我在项目里通常把定K分成两步:先用树状图加轮廓系数确定一个候选区间,再和业务方确认边界情况。比如某份客户数据数学上分成五类最好,但业务上最小的类只有销售额的百分之二,那我会考虑把它合并到相邻簇里,或者重新切到四类。离群小簇不一定是噪声,也可能是高价值小众群体,这时候硬套指标反而会误导决策。
5. 决定结果好坏的两个旋钮
5.1 距离度量怎么选
层次聚类的第一步是算样本间距离,这一步的选择比后面所有参数都重要。我常用的一套判断标准如下。
| 数据类型 | 推荐距离 | 原因 |
|---|---|---|
| 连续数值特征 | 欧氏距离 | 直观,配合沃德法效果好 |
| 高维稀疏特征 | 余弦距离 | 关注方向而非幅值 |
| 类别或集合特征 | Jaccard系数 | 关注重叠程度 |
| 有序数值但量纲差异大 | 曼哈顿距离 | 对极端值没那么敏感 |
距离度量没有绝对的好坏,关键要和数据性质匹配。比如做文本聚类时,如果用欧氏距离,向量长度会影响距离,而余弦距离只关心向量方向,更适合词频向量。做用户行为序列时,Jaccard更适合判断两个用户关注的行为交集。
5.2 单链接的链状效应,我踩过的一次
有一回我给一组用户行为数据做分群,随手选了单链接,结果树状图画出来后,有一个簇从底部一直延伸到顶部,像一条蜿蜒的长链,把所有中间状态的样本都串在一起。这就是典型的链状效应:单链接只看两个簇之间最近的两个点,只要存在一条逐级过渡的链,它就会把所有样本一个个串进同一个簇。
那次结果在业务上几乎没法解释,因为分出来的“长簇”里有完全不同的行为模式,只是它们之间有渐进过渡的样本。后来我把链接准则换成平均链接,簇的形态才恢复正常。所以我现在的经验是:除非明确知道数据存在流形结构,否则不要默认用单链接。
5.3 全链接和沃德法怎么取舍
全链接看两个簇之间“最远”的样本对距离,它倾向生成紧凑的球形簇,但样本里只要有一个离群点,就可能成为两个大簇合并的障碍,导致许多正常样本被划成一个个小簇。平均链接综合了所有样本对,相对最中庸,但计算成本略高。
沃德法则是我在连续数值数据上的默认选项,因为它的目标是最小化合并后簇内方差,分出来的簇相对均衡。它的限制也很明确:只适合欧氏距离。如果你因为数据原因使用了余弦距离,就不要再选沃德法,改成平均链接或者其他距离兼容的方法。工具会提醒吗?不会,它会照样输出结果,但统计含义已经不对了。
5.4 标准化和异常值不只是预处理
很多教程会把标准化放在数据预处理部分一笔带过,但层次聚类里它几乎是决定成败的细节。除了量纲问题,标准化还能弱化异常值对距离矩阵的冲击。当然,标准化不能代替异常值处理,如果数据里存在极端离群点,最好先通过箱线图或聚类树状图单独识别出来。
我个人的流程是:先做缺失值和异常值清洗,再做标准化,然后画一次树状图,观察最底部那些被合并得很晚的样本。如果某个样本在最底层分支孤零零地挂到很晚才和任何簇合并,它大概率是离群点,需要考虑是否保留。这个流程在真实项目里比调链接准则更管用。
6. 层次聚类跑不动的场景:数据量大时怎么办
6.1 计算复杂度到底高在哪
层次聚类的时间复杂度通常在O(n^2)到O(n^3)之间,空间上至少需要存一个n乘n的距离矩阵,这就是它在大数据场景下跑不动的根源。n等于一万时,距离矩阵已经是几千万个浮点数,内存轻松超过几百MB;n等于十万时,光这个矩阵就需要几十GB内存,普通机器根本扛不住。
每一步合并还要重新扫描距离矩阵找最小值,即使有优化,海量数据下也会非常慢。相比之下,K-Means每轮只需要计算簇中心和样本距离,复杂度是O(nk迭代次数),在数据量上要友好得多。所以层次聚类从来不是为海量数据设计的算法,它更适合中小规模数据集上做深度探索。
6.2 三种实际可用的降级方案
我常用的三种方案分别是降采样、粗聚类和子集层次聚类。
降采样最简单:从全量数据里随机抽取一个子集,在这个子集上做层次聚类,然后把得到的簇中心或代表性样本用作“锚点”,再通过最近邻规则把全量样本分配到对应簇。这个方案适合数据分布比较均匀的情况,如果某个小簇在抽样中被漏掉,结果就会失真。
粗聚类是另一个思路:先用Mini-Batch KMeans把全量数据聚成几百个粗簇,得到这些粗簇的中心,再对这些中心做层次聚类。这样层次聚类处理的样本数从几十万大幅降到几百个,跑得飞快,同时还能保留下簇与簇之间的层级关系。我做大规模用户分群时经常用这个方案,效果很稳定。
子集层次聚类则是把整个数据集按业务维度拆成若干子集,比如按渠道、按地区拆开,然后分别做层次聚类。这不是算法层面的优化,而是把一个问题拆成多个可并行的小问题。
6.3 什么时候该放弃层次聚类
如果在线请求必须实时给出聚类结果,层次聚类基本不适合,因为它没有现成中心点,对新样本只能重新跑整个聚类过程。如果要处理的数据量超过几十万,且你只需要粗粒度的分桶编号,K-Means系算法是更靠谱的选择。
层次聚类的价值不在于“能打”,而在于“能看懂”。它是探索阶段的探照灯,不是生产阶段的流水线。我在实际项目里的定位是:离线分析、样本规模可控、需要解释合并逻辑时优先选它;一旦进入大规模定期更新管道,就会换用粗聚类加代表点的方式,既保留层次关系,又不牺牲性能。
最后分享一个我最近的体会:层次聚类用得好不好,一半在参数选择,另一半在前期的数据理解。树状图不是给你一个答案,而是给你一批候选答案。多花点时间去看那些距离跳跃的位置,多和业务方确认每个簇的实际含义,比反复调库调用参数有用得多。机器学习不是一键分群的魔法,层次聚类尤其如此。