资讯动态

层次聚类实战指南:从树状图到Python实现,告别K-means选k难题

发布时间:2026/9/15 17:04:28 来源:尧图企业网站定制
刚开始接触聚类模型的读者十有八九是先学的K-means。我当年也一样给定一个k值随机初始化质心迭代到收敛最后把样本画成一堆彩色点。这类算法确实快但每次项目评审总有人问同一个问题“你到底凭什么说用户该分成三类”这个问题在K-means体系里一直没有一个特别让人服气的答案因为你得先拍一个k出来才能开始聚类而肘部法则、轮廓系数这些指标算出来的结果还经常互相打架。后来我在用户分群和商品分层项目里换用了层次聚类Hierarchical Clustering第一次看到树状图时确实有种打开另一扇门的感觉原来聚类不一定要一开始就把簇数定死而是先看数据自然的聚合结构再从某个层级切一刀。这篇文章就把我在实际项目里使用层次聚类的经验、Python实现代码和踩坑记录完整整理一遍尤其适合正在用scikit-learn和SciPy做数据分析的朋友参考。1. 从“分几类”的拍脑袋难题聊起层次聚类到底解决了什么1.1 K-means的两处死穴先说K-means最让人头疼的两个问题。第一k值需要预先指定但大部分业务场景里我们并不知道数据里到底藏着几个群体。你说用户分三类那为什么不是两类或者五类用肘部法则看拐点手一抖就选歪了用轮廓系数打分不同距离度量下结果又不一样。第二K-means默认假设簇是凸的、近似等方差的因为它依赖质心和欧氏距离。一旦数据出现环形、哑铃形、长条状分布K-means画出来的圆圈式簇边界就会把相邻簇硬生生切碎误分率非常高。这两个问题的共同根源在于我们希望在一开始就给出一个全局最优的扁平划分但实际上我们连数据里有没有分层结构都不清楚。层次聚类把问题换了一种问法——“这些样本之间谁跟谁更像”它不急着告诉你分几类而是先把样本之间一层一层的相似关系全部算出来让你既能看整体也能抠细节。1.2 树状图给你的不是几个簇是一整棵“族谱”层次聚类最独特的产出物是树状图Dendrogram。它长得像一棵倒过来的树底部是每一个单独的样本越往上越接近根相当于把样本一层层聚成了越来越大的群体。你在任意高度横切一刀就会被切成对应数量的簇切得越低簇数越多切得越高簇数越少。这个特性非常贴合业务分析场景。举个例子。做零售客户分群时业务方既想知道“整体分三大类够不够”又想进一步看“某一个大类底下是不是还能分成几个小类”这两个需求在K-means里需要跑两次模型而在层次聚类里就是看同一棵树的不同高度而已。以前我在门店选址项目里用层次聚类甚至能把“商圈层—街道层—门店层”这种天然的行政和地理递进关系直接映射到树状图的父节点和子节点上汇报给业务时根本不需要解释什么叫距离度量直接把图一摆人家就问“这个高度切下去是不是就把城市群分开了”——对就是这个意思。2. 凝聚式与分裂式两种建树路线的核心差异2.1 凝聚式AGNES自底向上的合并逻辑层次聚类有两条相反的建树路线绝大多数落地场景用的都是自底向上的凝聚式也叫AGNESAgglomerative Nesting。思路可以概括成一句话一开始每个样本自己单独占一个簇然后不断找到距离最近的两个簇合并直到所有样本合成一个大簇为止。如果把合并过程记录下来就是一个完整的树状结构。用五个点举个例子。设有A、B、C三个点凑在一堆D、E两个点凑在另一堆。第一轮计算任意两个样本之间的距离发现A和B距离最近于是合并成新簇{A,B}。第二轮再算所有簇之间的距离发现{C}跟{A,B}最近合并得到{A,B,C}另一边{D,E}也已经合并完成。第三轮只剩下两个簇最终再合并成一整簇。整个过程从n个单点簇收敛到1个大簇一共会发生n-1次合并这个“合并日志”就是构建树状图的数据来源。这个过程最吸引我的地方是它的可解释性每一步都有具体的距离值你可以清清楚楚看到“那两个簇到底是凭什么被合并的”不像K-means那样给你一团只可意会的质心落点。2.2 分裂式DIANA为什么现实中几乎没人用另一条路线是自顶向下的分裂式DIANADivisive Analysis逻辑完全反过来一开始所有样本属于一个大簇然后递归地把最不像同一类的样本拆出去直到每个样本单独成簇。原理听起来也没毛病但实际用的人极少原因主要有三点。第一计算代价更高。凝聚式每一步只需要在现有簇之间找最小距离而分裂式每一步需要考虑“如何把一个簇分成两半”这个切分本身又要处理大量两两组合复杂度比凝聚式高一截。第二分裂结果很难修正。顶层一旦切歪了下面是全盘皆错而且你只能一条路走到黑不像凝聚式还可以用一个合适的linkage去减少后期颗粒度损失。第三工程实现和解释习惯也偏向凝聚式。连SciPy提供的层次聚类函数都是基于凝聚式实现的绝大多数教科书和论文默认的也是AGNES。所以我的建议很直接做项目时不用纠结DIANA能跑通、可解释、社区资料多的凝聚式才是首选。2.3 距离度量决定“相似”的含义这一步不能省无论是凝聚式还是分裂式都要回到同一个基础问题两样本之间或两簇之间的“距离”怎么定义这一步经常被初学者跳过但它恰恰是层次聚类里最能体现业务理解的地方。常用的度量包括欧氏距离、曼哈顿距离和余弦距离。欧氏距离就是最直观的直线距离适合连续数值型特征比如消费金额、活跃天数、点击次数等。曼哈顿距离算的是每个维度差的绝对值之和适合特征之间彼此独立、坐标轴方向有明显业务含义的场景比如按品类划分的采购数量。余弦距离则更多用于文本向量、用户兴趣向量这类方向比模长更重要的数据。选度量之前还要先问自己一句你的特征尺度统一吗如果把年龄和年收入放进同一个欧氏距离公式年收入动辄几万几十万年龄才几十那收入几乎单方面决定了距离年龄参数变成摆设。所以我在任何聚类项目开始前都会先对数值特征做标准化最常用的是StandardScaler让每个特征的均值为0、方差为1这一步做完再谈距离才有意义。3. 定义“最近”是个技术活距离度量与linkage选型3.1 先有距离矩阵才有簇间距离单样本之间的距离好算直接套用距离公式即可。但层次聚类的合并对象经常是“一个簇”和“另一个簇”比如{A,B,C}与{D,E}之间到底算多远这就引入了linkage连接方式的概念。linkage定义的是簇与簇之间的聚合距离规则它建立在样本间距离的基础上与距离度量是两码事。通常我们先用距离度量算出样本间的两两距离矩阵再在每一轮合并时用linkage来决定哪个簇跟哪个簇更近。我在刚开始做层次聚类时也混淆过这两个概念。简单来说距离度量管的是“样本像不像”linkage管的则是“两群样本像不像”。前者是基础后者是上层策略两者共同决定了最终树状图的形状。3.2 四种主流linkage的合并规则与性格在Python里最常打交道的linkage方法有四种单链接single、全链接complete、平均链接average和Ward法。它们各自对“簇间距离”的理解完全不同。linkage合并规则优点主要风险single单链接取两个簇之间所有样本对的最小距离能发现细长形状的簇极其容易产生链式效应把远处点一个个串进去complete全链接取两个簇之间所有样本对的最大距离倾向于生成紧凑、接近圆形的簇对离群点敏感一个远点就能把整个合并带偏average平均链接取两个簇之间所有样本对距离的平均值介于single和complete之间较稳健在大量数据下计算量略高ward离差平方和法合并后使簇内总离差平方和增量最小生成大小更均衡、解释性强的簇仅适用于欧氏距离且对样本量较大的数据内存消耗明显这里要特别提醒的一点是single链接的链式效应。它的合并规则是取最小距离因此只要有一个点和某个簇的距离足够近就会导致两个大簇被一根细线连起来最终聚出来的簇形状像一串糖葫芦内部长距离样本之间其实一点都不像。遇到那种长条形、流线型的数据single有时候确实能派上用场但如果数据是几个没有明显长条形结构的球状簇single基本必翻车。3.3 到底选ward还是average我实际做项目时对于连续数值型特征、样本量又不大的场景最常用的两个选项就是ward和average。Ward法之所以流行是因为它每一步都在追求“合并后簇内方差增加得最少”得到的结果往往比较紧致、大小也相对均衡视觉上和业务直觉都很舒服。Sklearn的AgglomerativeClustering默认linkage就是ward也侧面说明这是多数场景下的稳妥起点。average链接则更适合你对“少数派小簇”有明确保护需求的时候。它不会像ward那样强行让簇的尺寸均衡而是老老实实反映两簇之间的平均相似度因此对分布不那么均匀的数据更宽容。我自己的经验是先用ward画树状图做初判如果发现某个簇被明显切得过大或者过碎再换成average复跑一遍对比两个树状图的合并高度能很快判断数据里到底是紧凑型结构还是长尾型结构。4. 用SciPy把层次聚类的手工过程拆开看4.1 造一份模拟数据先做标准化现在进入代码实操。我推荐先用SciPy的scipy.cluster.hierarchy模块而不是直接上sklearn。原因很简单SciPy把每一步合并的信息都原样暴露给你你能看到linkage矩阵长什么样、每个合并发生在哪个高度sklearn则像一个贴心的黑盒直接给你一份标签。学原理阶段Scipy这一层更解渴。import numpy as np import matplotlib.pyplot as plt from scipy.cluster.hierarchy import linkage, dendrogram, fcluster from sklearn.datasets import make_blobs from sklearn.preprocessing import StandardScaler # 生成3个较为明显的簇 X, y_true make_blobs(n_samples60, centers3, cluster_std1.2, random_state42) # 标准化让每个特征均值为0、方差为1 X_scaled StandardScaler().fit_transform(X) print(X_scaled[:5])标准化这步真的不能省。我最初做层次聚类时直接把原始数据喂进去结果前两个特征的尺度差异把聚类结果彻底带偏树状图上几乎看不出三个簇的分离感标准化之后同样的距离度量、同样的linkage效果立刻清爽很多。4.2 linkage()返回矩阵的解读Z linkage(X_scaled, methodward) print(Z.shape) print(Z[:5])整个链路执行完Z的形状是(59, 4)因为60个样本一共会发生59次合并。每一行是一轮合并的记录含义如下第一列和第二列被合并的两个簇的ID。第三列本轮合并时的距离也就是树状图上对应节点的高度。第四列合并后新簇里的样本数量。这里有个小坑SciPy里前n个ID对应原始样本也就是0到59每发生一次合并就会产生一个新簇新簇的ID从60开始编号一路排到118。所以你在Z里如果看到某个ID大于等于60说明它已经是一个由多次合并形成的中间簇不是原始样本。初学者最容易在这里被绕晕但理解了之后再看树状图其实一目了然。4.3 fcluster()切树拿标签光看Z还不够我们通常还需要把样本划分成指定数量的簇。SciPy提供了fcluster函数最常用的切法是指定最大簇数# 想最终分成3个簇 labels fcluster(Z, t3, criterionmaxclust) print(labels[:20])输出结果是一串从1开始的整数标签比如[2 2 1 3 2 1 ...]。注意这里的标签编号只是一个代号并不是某种排序1号簇和2号簇之间没有数值意义上的大小关系。fcluster还支持按阈值高度来切只需要把criterion换成distance并传入一个具体的高度t系统会把所有在该高度以下合并的样本归入同一簇。这种切法在业务上很实用比如你想让“距离小于某阈值”的人都归为一类。4.4 画出树状图一眼看懂合并过程plt.figure(figsize(10, 6)) dendrogram(Z, truncate_modelevel, p5) plt.axhline(y5, colorred, linestyle--, linewidth1.5) plt.show()dendrogram会把所有合并过程画成一棵倒树。truncate_modelevel是一种实用的小设置当样本量很大时叶子节点密密麻麻根本看不清它能让你只显示上层的合并结构。图中横线处如果存在一个明显的“长杆”说明这个高度之下和之上的簇结构差别很大通常就是选k的最佳参考位置。我第一次跑通这段代码时最大的感受是聚类结果变得可以“看见”了不用再靠指标猜。5. sklearn版本的Fast路径与三个细节差异5.1 AgglomerativeClustering快速上手如果你已经通过SciPy理解了原理日常建模时完全可以直接使用sklearn的AgglomerativeClustering接口更简洁能直接输出标签from sklearn.cluster import AgglomerativeClustering agg AgglomerativeClustering(n_clusters3, linkageward, metriceuclidean) labels_sk agg.fit_predict(X_scaled) print(labels_sk[:20])fit_predict一步到位返回标签从0开始编号这一点跟fcluster从1开始编号的习惯不同注意区分。你还会发现sklearn版本的输出和SciPy版本可能在某些样本上编号不同但聚类结构是一致的因为两边的合并策略本质上是一回事。5.2 新样本没有predict()方法这是很多从K-means转过来的朋友最容易踩的坑。K-means模型训练完以后可以用predict()给新样本分到一个最近的质心但AgglomerativeClustering没有predict()方法因为层次聚类不保存一个固定的簇中心它保存的是一整棵树的合并结构。新样本来了以后理论上要重新放进原来的样本集里重跑一遍聚类才能确定它的位置。因此我在项目里遇到“需要频繁给新用户打标签”的场景时会优先考虑K-means或近邻方法而把层次聚类用于数据分析、探索性分群这种一次性任务。如果一定要用层次聚类上线常见的方案是先用层次聚类定好簇数再用这个簇数训练一个K-means模型用来做新样本的落地预测。5.3 用非欧氏距离时ward会直接报错SciPy里的ward方法要求距离度量必须是欧氏距离sklearn同样如此。如果你想把距离度量换成曼哈顿距离同时linkage还保留wardsklearn会抛异常因为ward法的核心是计算离差平方和增量这套逻辑默认构建在欧氏空间的几何性质上。正确做法是换用average或completeagg AgglomerativeClustering( n_clusters3, linkageaverage, metricmanhattan ).fit(X_scaled)实际经验告诉我不要觉得“ward更高级就一直用”。当你的特征不是连续数值型、或者你业务上更关心曼哈顿式的绝对差之和时average链接加对应距离度量往往能得到更符合业务直觉的结果。5.4 distance_threshold参数的使用场景AgglomerativeClustering还有一个被低估的参数distance_threshold。当你设置n_clustersNone并给distance_threshold传入一个具体高度值时模型会按照这个高度动态切割不固定簇数。这跟fcluster的distance切法原理相同适合在业务上明确知道“合并距离不能超过多少”的场景。agg AgglomerativeClustering( n_clustersNone, distance_threshold8, linkageward, metriceuclidean ).fit(X_scaled) print(agg.n_clusters_)注意使用distance_threshold时sklearn要求compute_full_treeTrue因为它要计算出完整的合并树才能切出任意高度这会进一步增加内存和时间开销。小数据上没问题大的数据量就得掂量着用。6. 树状图选k的实操经验不会切横线等于白做6.1 树状图的y轴高度到底是什么树状图很容易看但很多人只看形状不看坐标轴。它的y轴高度不是样本的距离本身而是代表“两个簇在什么距离/成本下被合并了”。这个距离的内核取决于你选的linkage。比如ward法里这个高度是合并前后簇内离差平方和的增量average法里则是平均距离值。理解这一点之后你会发现树状图的横切选k其实是在做一件事找一个合适的合并距离作为阈值把所有在该阈值以下已经合并的样本看成同一个簇尚未合并的则分成不同簇。阈值切得越低越是“精致地”分组切得越高分组越粗犷。6.2 “最大间隙”法选k的具体操作实际选k时我常用可视化的“最大间隙法”。观察树状图中每个合并点的高度如果某一段高度区间内几乎没有合并发生而在这个区间前后出现了很大的高度跳跃这个跳跃的位置就是天然的分群边界。怎么找看树枝的“长竖线”——竖线越长说明这两个簇被合并时花了很大代价也就说明它们原本离得很远。操作时我会用dendrogram画图后手动尝试多个横线高度。如果某条横线下方切出的簇数量对应的合并高度落差大于周围其他位置那这个k就是统计意义上比较合理的簇数。比如刚才数据里在高度大约5的位置切一刀如果切出3个簇再往下降到高度3切出6个簇但其中3组都挤在一起那说明数据的自然结构确实就是3个簇。6.3 业务目标优先别被“数学最优”困住数学上的“最优k”不一定等于业务上的“合理k”。做用户分群时有时候统计指标建议4类但业务部门说他们只有3套运营策略那强行分4类只会让其中两个策略无人可用。反过来有些时候统计上只有2个明显的簇但业务想精细化运营需要分出5个细分人群这时候也可以往下多切几层每一层都去看业务解释性而不是一味追求最大间隙。我自己的流程是先用最大间隙法圈出一个候选区间比如统计上看起来2到5类都合理再把每个k值下的聚类结果分别做业务画像看哪些类别能形成清晰的人群解释哪些类别混杂着完全不同的用户行为。解释不通的簇哪怕统计再漂亮最后也要放弃。7. 实战里翻过的车从尺度灾难到内存爆炸7.1 不加标准化的后果有多严重这里有必要再强调一次标准化。曾经我在做商品维度的聚类时没对原始特征做标准化结果那几个数值范围高达上千的特征直接把所有距离都霸占了。树状图画出来样本被切得奇奇怪怪怎么看都不像有业务意义的结构。后来我逐个特征做分布检查发现不少特征本身单位就不同有的是金额、有的是次数、有的是时间间隔放在同一个距离公式里完全是灾难。解决方案很简单from sklearn.preprocessing import StandardScaler, MinMaxScaler X_std StandardScaler().fit_transform(X) X_mm MinMaxScaler().fit_transform(X)到底用StandardScaler还是MinMaxScaler取决于后续用的距离定义。MinMaxScaler会把所有数值压到0到1之间适合数据分布比较均匀的情况StandardScaler对长尾分布更稳健。如果数据里存在明显的极端大值先用分位数截断或者对数变换再标准化效果通常更好。7.2 大样本量把运行内存直接吃爆层次聚类最致命的短板是计算复杂度瓶颈主要来自距离矩阵。n个样本的距离矩阵如果全量存储要占n(n-1)/2个浮点数。看着好像不大但n等于10000时大约是5000万个浮点数光这个矩阵就要占几百MB内存SciPy内部中间计算还会再翻几倍。我在处理几万条用户记录时就出现过内存直接把会话卡死的情况。解决办法有三个。第一种是采样先做一次随机抽样或者分层抽样在几千个样本上跑层次聚类得到树状图和合理的k范围再用这个k去做大样本上的K-means或者Mini-Batch K-means。第二种是先跑K-means得到几十个簇中心然后对这些中心做层次聚类把层次结构映射回原始样本。第三种是换用BIRCH、HDBSCAN这类为大规模设计的方法它们也保留了一部分层次聚类的血统但底层计算策略完全不同。7.3 单链接链式效应长什么样这个翻车场景发生在一次探索性分析里。我用默认的ward效果不错但某次为了找细长型异常簇把linkage换成了single。结果数据里只要存在一条稀疏的点带single就会把两个本应分离的大簇用这条点带连接起来导致树状图上方看起来像是被强行拧成了一个簇最终聚类结果完全无法解释。后来我在图上叠加原始散点才意识到single天然会长出“触手”它会把局部最近的点一个个粘连起来形成长链。如果数据里没有明确的细长结构single慎用。7.4 把预计算距离矩阵传给ward直接报错SciPy里还有一个很常见的操作先手工算好距离矩阵再传给linkage。但如果你预先用了非欧氏距离比如余弦距离然后linkage还选ward程序会报错或者给出无意义的解。因为ward基于欧氏空间方差分解它不认非欧氏距离。我踩过这个坑之后统一改成先确定距离度量再选择与之匹配的linkage。如果只是需要紧凑均衡的簇就欧氏距离加ward如果必须用余弦距离处理方向型数据就想办法用average或complete。7.5 贪心合并不可逆数据清洗要跟上最后说一个容易被忽略但影响很大的点凝聚式层次聚类是贪心算法每一步合并都是局部最优决策而且不可逆。一旦两个簇在低位合并了后面的所有步骤都无法把这对组合拆开重新选择。因此输入数据里有异常噪声点时它会先污染最底层的合并进而影响上头整棵树的形状。这比K-means对初始质心的敏感性更难察觉因为K-means跑坏了你还能换随机种子重跑层次聚类的树一旦长歪你必须回头修数据。所以我在做层次聚类前会先花时间做离群点筛查。轻度的用Z-score或IQR规则过滤严重的结合业务判断决定是否删除。清理完之后再标准化再画树状图得到的合并高度往往比直接跑要稳定得多。最后分享一个我目前比较固定的操作流程拿到一批连续数值特征的数据先做缺失值和离群点处理再做标准化然后用SciPy的ward画一遍树状图观察最大间隙确定候选k接着用average链接复跑一遍做交叉验证如果两种linkage给出的簇数结论明显不同就先回看数据分布和距离度量是否选对最后才用sklearn的AgglomerativeClustering输出标签做业务画像。整个过程看起来比直接调一个K-means慢不少但它给你的可解释性和层级信息回报是非常值的。如果你也经常面对“到底分几类”的灵魂拷问我建议你下次先让层次聚类的树状图替你回答。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价