做数据分析这么久,我见过太多人一拿到多维数据就开始画散点图、折线图,结果看完还是不知道“哪些样本是一伙的”。老板问“这 100 个用户到底能分成几类”,你总不能拿散点图圈几个圆给他说“我觉得差不多就这样”。聚类图才是这种场景下的正解——它能一次性把“样本怎么分组、组和组之间差多远、组内又是什么结构”全都摆在图上。这篇文章讲的聚类图,就是以树状图(dendrogram)为核心、常配合热力图一起出现的那类可视化图形,在用户分群、商品归类、基因表达、异常检测这些场景里非常实用。我会从底层的读图逻辑讲起,手把手带你用 Python 把它跑出来,再聊到真实项目里怎么把静态聚类图升级成企业级的交互式可视化。适合刚入门 Python 数据分析的读者,也适合已经会画基础图、想要进阶的从业者。
1. 聚类图到底在表达什么:先搞懂这张图里藏的事
1.1 聚类图不是“分类结果图”,而是“分类过程图”
很多人第一次看到聚类图会误以为它只是一张标记了类别的图,比如不同颜色代表不同组,完事了。但严格意义上,层次聚类图最核心的树状图部分,展示的是层次聚类的完整合并过程——也就是数据从每个样本各自独立开始,如何一步一步根据相似度合并成更大的簇,最终聚成一棵完整的树。
这有什么区别?前者只告诉你“最终答案”,后者告诉你“答案是怎么来的”。举个例子:你有一批用户数据,普通分类图告诉你“分成 3 类,第 1 类 40 人,第 2 类 35 人,第 3 类 25 人”。聚类图则可以进一步告诉你:第 2 类和第 3 类是不是在很早的时候(低距离处)就合并了?如果是,说明它们其实比较相似,只是你选了 3 类这个粒度,它们才被拆开;如果第 1 类一直到最高的高度才和其他类合并,说明它是非常独立的一个人群。
这个信息在做业务决策时极其关键。比如做用户分群,如果你发现某两类在高位才合并,说明分群边界清晰,可以放心按这个划分做针对性运营;如果两类在低位就合并,你就要斟酌:或许这两个群体本质上是一类人,强行拆分会导致运营策略重复。
1.2 树状图的读图细节:叶子、分支、高度
树状图的读法并不复杂,但有几个关键元素必须分清:
- 叶子节点:最底层的每个节点对应一个样本或一个变量。如果样本太多,叶子会挤成一团,这也是后文会专门处理的问题。
- 合并节点(分支点):两条线汇合的地方,代表两个簇在这一高度上被合并。分支越深(越靠近底部),说明合并在一起的两个样本/簇相似度越高。
- 高度(距离轴):纵轴或横轴上的刻度代表合并时的距离。两个簇合并时的高度越高,说明它们被合并时差异越大、越“勉强”。
读图时记住一个口诀:**离得越近、合并得越早、分支越靠下,相似度越高。**反过来,两个分支如果一直到树顶才汇合,说明它们在当前数据里属于非常“不合群”的存在。
另外要注意,树状图的左右顺序本身没有太多含义,上下(或左右)翻转不影响聚类结果。换句话说,图里某一支画在左边还是右边,不代表它在数值上“更靠近左边”,只是排版差异。读图时别过度解读。
1.3 什么时候该用聚类图,而不是散点图或折线图
我的经验是,这三类图的使用场景有明确区分:
| 图类型 | 适用情况 | 典型问题 |
|---|---|---|
| 散点图 | 2~3 维数据,想直观观察点的分布和离群点 | 维度一多,点就糊在一起,看不出结构 |
| 折线图 | 有时间/顺序维度,想看趋势变化 | 无法回答“哪些样本是一类的” |
| 聚类图 | 多维数据,想知道样本/特征的层次关系、分组结构和组间距离 | 不能直接看到原始数值分布,需要配热力图 |
所以,当你拿到的是一张 n 行 × m 列的表(n 个样本、m 个特征),而你想回答“这 n 个样本能不能自然地分成几群、群和群的关系怎么样”,第一选择就是聚类图。特别是 m 比较大的时候,比如几十个特征、上百个样本,散点图已经彻底失效,聚类图几乎是唯一能全局展示结构关系的方案。
2. 环境准备与工具选型:为什么我推荐这套组合
2.1 最小依赖环境,别一上来就装全家桶
我知道很多人习惯先装一个 Anaconda,什么库都往里塞。但真要跑聚类图,其实依赖非常少。我的建议是最小化安装,既避免版本冲突,也方便后续部署。
需要的基础库就这几个:
pip install numpy pandas scipy matplotlib seaborn scikit-learnnumpy/pandas:数据处理的基础,谁都要用。scipy:层次聚类的核心算法库,scipy.cluster.hierarchy提供了linkage、dendrogram这些关键函数,是画树状图的事实标准。matplotlib:底层绘图库,树状图的每个元素其实都是它画出来的。seaborn:统计可视化库,它的clustermap函数能一句话同时画出热力图 + 聚类树,是这篇实战的主力工具。scikit-learn:主要用于后续生成演示数据(make_blobs)以及提供一些比scipy更便捷的聚类工具函数。
我个人建议 Python 版本至少用 3.9 以上,别用 2.x,也别用太新的预览版——毕竟很多库的 wheel 包对新版 Python 支持有滞后,装包时容易卡在“找不到匹配版本”这种问题上。
2.2 环境配置里最常见的坑:seaborn 装上了却用不了
说实话,我第一次装 seaborn 也踩过坑:pip install 明明提示成功,import 却一直报错。后来发现是 matplotlib 版本不兼容导致。所以如果你是新环境,最好一次性把版本匹配好。路过的人如果遇到这种问题,直接用这一条命令重新装:
pip install --upgrade numpy pandas matplotlib seaborn scipy scikit-learn升级之后再 import 基本不会报错。另外,如果你在 Jupyter Notebook 里画图却不显示,检查有没有跑过%matplotlib inline这一行魔术命令。这个问题出现频率极高,尤其初学者。
2.3 为什么我用 scipy 而不是直接用 sklearn 画图
进入正题前,先聊一下工具分工。很多人会问:既然sklearn.cluster.AgglomerativeClustering能算层次聚类,为什么我还要用scipy.cluster.hierarchy?原因很简单:
- scipy 提供了完整的层次聚类过程输出,包括
linkage生成的合并矩阵、dendrogram绘制树状图的能力、fcluster按高度/数量截断得到类别标签。这三样是画聚类图的完整链路。 - sklearn 的 AgglomerativeClustering 更适合只想要聚类结果标签的场景,它不直接产出树状图所需的数据结构。虽然少数版本也可以通过指标计算还原距离,但多绕一圈没有意义。
- seaborn.clustermap 内部底层调用的就是 scipy 的 linkage 和 dendrogram,所以直接掌握 scipy 能让你理解 clustermap 的每个参数到底在控制什么。
一句话总结:要么用 scipy 自己一步一步来,要么用 seaborn 的 clustermap 高封装,都行;但不要用 sklearn 去硬凑树状图,那是用错工具。
3. 层次聚类热力图的完整实现:从默认参数到定制化
3.1 第一步:构造一份可以复现的演示数据
为了让你能直接跑通,我这里不引入外部数据集(毕竟很多入门读者连 CSV 路径处理都容易卡壳),直接用sklearn生成一份带有真实分类结构的数据。
import numpy as np import pandas as pd from sklearn.datasets import make_blobs # 生成 40 个样本,每个样本 8 个特征,真实分成 4 个簇 X, y_true = make_blobs( n_samples=40, n_features=8, centers=4, cluster_std=2.0, random_state=42 ) # 转成 DataFrame 方便后续处理 columns = [f"feature_{i}" for i in range(8)] df = pd.DataFrame(X, columns=columns) df["true_label"] = y_true print(df.shape) print(df.head())这里有个关键点:random_state=42固定随机种子,保证你看到的聚类结果和我在文中描述一致。如果不写这个参数,每次运行数据都不同,后续对比就无从谈起了。
3.2 第二步:标准化,不做这一步后面全白搭
聚类基于距离计算,而不同特征的量纲差异会直接扭曲距离。比如 feature_1 的范围是 0~1000,feature_2 的范围是 0~1,那算欧氏距离时 feature_1 几乎完全主导了结果。这不是你想要的聚类,这是“最大量纲特征的一元聚类”。
标准化最简单的做法是用StandardScaler,把每个特征变成均值 0、标准差 1:
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() df_scaled = scaler.fit_transform(df[columns]) df_scaled = pd.DataFrame(df_scaled, columns=columns)注意:标准化要在聚类之前做,而且是在全部数据上做,不是在聚类之后。这个顺序问题我见很多人搞反,导致后续画出来的图怎么都不对劲。
3.3 第三步:用 seaborn.clustermap 一行画出热力图 + 树状图
标准化之后,最简单的方案是直接上seaborn.clustermap。它同时做两件事:画热力图和计算层次聚类树。
import seaborn as sns import matplotlib.pyplot as plt # 设置图像风格 sns.set_theme(style="whitegrid") # 核心一行:聚类热力图 g = sns.clustermap( df_scaled, method="ward", metric="euclidean", cmap="vlag", figsize=(10, 8), dendrogram_ratio=(0.15, 0.15), # 控制行/列树状图占的比例 cbar_pos=(1.0, 0.2, 0.03, 0.6) # 控制颜色条位置 ) plt.show()跑完你会得到一张图:左侧和顶部各有一棵聚类树,中间热力图的每一行是一个样本、每一列是一个特征,颜色越亮值越大、越暗值越小。clustermap默认是同时对行和列都做聚类,这点要注意——如果列是固定含义的指标(比如年龄、收入、消费频次),你不希望它们的顺序被打乱,就要加row_cluster=False或col_cluster=False。
这段代码里几个参数再解释一下:
method="ward":链接规则,Ward 方差最小化法,是我最常用的默认选择。metric="euclidean":距离度量,欧氏距离。cmap="vlag":色板。这里我故意没选默认的,因为默认色板在某些显示器上对比太弱。vlag是从紫色到绿色到黄色的渐变色系,对正负值表达很清晰。
3.4 第四步:从聚类图里提取“到底分了几类”
图是画出来了,但业务方肯定会追问:“所以你告诉我们,这堆样本到底分几类?”这时候需要用scipy的fcluster函数来截断聚类树。
from scipy.cluster.hierarchy import linkage, fcluster # 计算层次聚类的合并矩阵 Z = linkage(df_scaled, method="ward", metric="euclidean") # 截断成 4 类 labels_4 = fcluster(Z, t=4, criterion="maxclust") # 把标签加回到原始表里 df["cluster"] = labels_4 # 看看每个类的大小 print(df.groupby("cluster").size())criterion="maxclust"表示“不管距离阈值,强制分成 t 类”。另一种常见做法是criterion="distance",即给定一个距离阈值,自动决定簇数量。什么时候用哪种?业务上已经确定了“分成 4 类”,用 maxclust;还在探索阶段,想看看不同距离阈值下自然会形成几类,用 distance。
拿到标签后,可以进一步做聚类效果的量化分析,比如计算每个簇的特征均值:
cluster_profile = df.groupby("cluster")[columns].mean() print(cluster_profile.round(2))这张“簇画像”表在实际汇报中比那张聚类图还有用——它能清楚告诉你第 1 类用户是“高收入高消费型”,第 2 类是“低收入低频型”,等等。
3.5 进阶定制:给热力图加真实标签和分组色带
默认情况下,clustermap的行标签只是索引号。但在实际项目中,样本往往有业务含义(比如“用户ID”“商品名称”)。你可以定义一个标签列表传进去:
sample_names = [f"样本{i:02d}" for i in range(df.shape[0])] g = sns.clustermap( df_scaled, method="ward", metric="euclidean", cmap="vlag", row_cluster=True, col_cluster=True, figsize=(12, 10), xticklabels=True, yticklabels=sample_names, # 自定义行标签 ) plt.setp(g.ax_heatmap.get_yticklabels(), fontsize=8) plt.show()还有一个实用技巧:如果你已经有预先的类别信息(比如真实类别、业务分类),可以在热力图的左侧加一根“色带”来对照聚类结果和真实标签。用row_colors参数:
# 给每个真实类别分配一个颜色 true_label_colors = df["true_label"].map({0: "#4C72B0", 1: "#DD8452", 2: "#55A868", 3: "#C44E52"}) g = sns.clustermap( df_scaled, method="ward", metric="euclidean", cmap="vlag", row_colors=true_label_colors, # 左侧色带 figsize=(12, 10) ) plt.show()图上左侧会多出一条与样本顺序同步的色带,方便你直接对比“真实分类”和“聚类结果”之间是否一致。这是我做聚类报告时最常用的单图组件,没有之一。
4. 聚类图背后那些“看不见”的决策:距离度量和链接规则
4.1 距离度量:数据长什么样,决定了你怎么量相似
画聚类图的前置工作,本质上是在算样本之间的距离。默认大家都选欧氏距离,但它绝不是唯一合理的选项,而且在某些场景下是相当差的选项。
| 距离度量 | 适用场景 | 注意点 |
|---|---|---|
| 欧氏距离 | 各特征量纲相近、数据分布接近球形时 | 对量纲敏感,必须先标准化 |
| 曼哈顿距离 | 特征维度较高,想要削弱个别极端值对距离的影响 | 对离群点更鲁棒 |
| 余弦相似度 | 文本向量、用户行为比例型数据 | 关注方向、不看量级 |
| 相关系数(1 - 皮尔逊相关系数) | 关注特征变化趋势是否一致,比如基因表达谱 | 不关心绝对表达量,只关心趋势 |
举一个很典型的例子:做农产品价格数据聚类时,不同农产品的价格绝对值差异很大(猪肉一斤几十块,白菜一斤几块钱),如果直接用欧氏距离,最后聚类结果基本就靠“平均价格水平”这一个维度分开了。这时候如果换成相关系数距离,就能把“价格走势相似但绝对价格不同”的品种聚在一起——类似猪肉和牛肉的价格走势可能高度相关,尽管它们价格整体不是一个量级。这个区别在真实业务里特别要命,选错距离度量会导致整个聚类结论南辕北辙。
4.2 链接规则:簇和簇的“距离”怎么定义
链路规则定义了“两个簇之间的距离”如何计算。同样一双眼睛看到的数据,换一种链接规则,聚类图可能完全变样。
| 链接规则 | 定义 | 特点 |
|---|---|---|
| single(单链接) | 两簇中最近样本的距离 | 容易形成“链条状”簇,对噪声敏感 |
| complete(全链接) | 两簇中最远样本的距离 | 倾向于形成紧凑球状簇 |
| average(平均链接) | 两簇中所有样本对距离的平均值 | 介于 single 和 complete 之间,常用 |
| ward(离差平方和) | 合并后簇内方差增加量最小 | 默认首选,适合高维连续特征 |
我的经验是:**没有特殊需求,默认用 ward + euclidean 组合,大多数场景下结果都比较合理。**但如果你的数据存在明显的离群点,ward 会受到影响,此时可以试一下complete或average。反正出图很快,建议都跑一遍对比。
4.3 怎么评价聚类树选得好不好:Cophenetic 相关系数
这里分享一个很多人不知道的量化指标:cophenet相关系数。它的基本思想是:把原始样本之间的实际距离,和聚类树中两个样本被合并时的高度(即树状图中表达的距离),计算一个相关系数。如果这个值高(比如 > 0.8),说明聚类树忠实地还原了样本之间的真实距离结构;如果很低,说明你的距离度量或链接规则选得不对,树状图在“扭曲事实”。
from scipy.cluster.hierarchy import linkage, cophenet from scipy.spatial.distance import pdist # 计算原始样本两两距离 original_distances = pdist(df_scaled, metric="euclidean") Z = linkage(df_scaled, method="ward", metric="euclidean") # 计算聚类树还原的距离 cophenetic_distances = cophenet(Z) # 计算二者的相关系数 c, coph_dist = cophenet(Z) print(f"Cophenetic 相关系数: {c:.3f}")我习惯在选型时把几种组合的 cophenet 相关系数并列打印出来,哪个高就用哪个。这是一个非常实用的技术决策工具,但网上讲聚类图几乎没人提,强烈建议你用起来。
5. 从静态图到可交互:把聚类图用在项目交付里
5.1 用 Plotly 生成可交互树状图,秒杀静态图
静态图在探索分析阶段很够用,但一旦要发给业务方,对方几乎必然会有这些问题:“这个标签是哪个样本?”“把鼠标放上去能看到数值吗?”“能不能只展开某几个分支?”这些问题,静态图解决不了,交互式树状图可以。
plotly有一个现成的接口:
import plotly.figure_factory as ff # 用 scipy 算好 linkage 矩阵 Z,直接传入 fig = ff.create_dendrogram( df_scaled.values, orientation="left", labels=sample_names, linkagefun=lambda x: Z ) fig.update_layout(width=900, height=700) fig.show()出来的图可以缩放、悬停查看分支信息,标签也不会挤在一起。缺点是不带热力图,所以如果你想看“树 + 数值矩阵”全貌,Plotly 需要手动把热力图拼上去,代码会复杂不少。我的建议是:交互树状图用于业务汇报,静态 clustermap 用于自己的数据探索,两者各司其职。
5.2 Flask + ECharts 输出聚类图的思路,给企业级项目留个口子
如果你的项目最终要放到 Web 页面上给客户或运营人员使用(搜过企业级数据可视化的人应该都懂,最后都会走到 Web 化这一步),那么常见的技术路线是这样的:后端用 Python 算好聚类结果,再通过接口把数据传给前端,前端用 ECharts 画图。
后端逻辑非常直接:
# 伪代码示意 import json from flask import Flask, jsonify app = Flask(__name__) @app.route("/api/cluster") def cluster_api(): # 1. 读取数据 # 2. 标准化 + 计算 linkage Z = linkage(df_scaled, method="ward") # 3. 用 dendrogram 转换成 JSON 友好的结构 from scipy.cluster.hierarchy import dendrogram dendro = dendrogram(Z, labels=sample_names, no_plot=True) # 4. 返回前端绘制树状图所需的数据结构 return jsonify(dendro)前端 ECharts 的tree图可以直接消费类似children/name的嵌套结构。你只需要把 dendrogram 输出转成这种嵌套 JSON。注意:dendrogram(... no_plot=True)返回的icoord/dcoord列表更适合还原成画布坐标,如果转嵌套树结构需要自己递归一遍合并信息。这一步代码不算多,但如果你第一次做,建议事先把树结构画在纸上,捋清楚每个合并事件的父子关系再写递归。
这类 Flask + ECharts 的综合项目,在网约车大数据、农产品价格可视化这些场景里特别常见。核心逻辑跑通之后,你完全可以复用这套“scipy 算聚类 → JSON 出接口 → ECharts 画图”的管线。
5.3 业务侧怎么解读聚类图:从用户分群到商品归类
图做出来不是终点,能讲出业务故事才是终点。分享几个我经手过的高频场景,让你感受一下聚类图的实际价值:
- 电商用户分群:把用户按消费金额、频次、品类偏好、活跃时长做聚类,聚类图能直观显示“高价值用户”和“沉睡用户”的分离程度,如果两棵子树在很高位置才分开,说明你的流失预警边界非常清楚。
- 农产品价格联动分析:不同农产品价格序列做聚类,你会发现叶菜类和根茎类往往先各自成簇、然后才和肉蛋类合并,说明两类价格走势有本质差异。
- 网约车订单时段聚类:把一天 24 小时的订单量做成样本维度,聚类图可以区分“通勤高峰型”工作日、“周末休闲型”周末,以及“深夜活跃型”特殊日期。这种聚类结果直接指导运力调度。
- 品牌竞争格局分析:将多个品牌按用户评论关键词向量做聚类,可以看到哪些品牌常被一起讨论、构成竞品群组。
这些场景里,聚类图的价值不在于“图好看”,而在于把不可言说的结构关系变成了可辩护的证据链。
6. 避坑实录:这些细节我花了一晚上才搞定
6.1 横坐标标签太密集,树状图变成一坨黑色
这是我见过最多人吐槽的现象——“python画图横坐标太密集”这个搜索词的热度一直居高不下,我也被坑过。样本一多,每个叶节点都有标签,水平排列的标签全部叠在一起,结果就是一片黑色。
解决办法有三个层级:
最优先的做法是加旋转角度和缩小字号:
plt.xticks(rotation=90, fontsize=6)但样本到几百个时,旋转也没用。更实用的做法是截断树状图,只显示上层主要分支结构,把细节缩到看不见:
from scipy.cluster.hierarchy import dendrogram # 设置 truncate_mode="level",只显示 3 层 fig, ax = plt.subplots(figsize=(12, 6)) dendrogram( Z, labels=sample_names, leaf_rotation=90, leaf_font_size=8, truncate_mode="level", p=3, # 显示到第 3 层 show_contracted=True, # 被折叠的簇用折线标注数量 ) plt.show()原理解释一下:truncate_mode="level", p=3会只显示最上面三层合并结构,下面那些细碎的叶子节点被折叠成一个“倒 V”符号,并在符号上标注该分支包含多少样本。这样一来,即使你有 2000 个样本,图上也很清爽,同时上层结构的关键信息完全保留。
第三种方案是直接改用交互式显示(Plotly),让用户自己缩放标签区域。这在汇报时最好用,因为不同人关心的粒度不同。
6.2 忘了标准化,聚类结果全被“体重”这种大数特征带跑
前文提过标准化,但实际项目中这个坑出现的频率高到离谱。特别是当数据里混着“用户收入(几万)”和“点击次数(个位数)”这种量纲极端不同的列时,如果不做标准化,聚类结果几乎等于“按收入一个变量排序分组”,其他特征全成摆设。
而且要注意:标准化要在做聚类分析的数据范围上进行。有些场景需要区分“训练集”和“新数据”,那新数据的标准化必须用训练集的均值/标准差,不能自己重新 scaler.fit()。这个细节涉及数据泄露问题,虽说不在这篇文章的标题范围内,但做项目时请务必记住。
6.3 色板选择不只是审美问题,还是信息表达问题
clustermap的cmap参数,很多人直接复制默认值不管了。但色板选错会导致热力图里的数值差异完全看不出来,或者更有甚者——选成红绿渐变色,结果色盲同事完全分不清。
我的推荐组合:
- 数据是连续数值,从负到正:
vlag或coolwarm。 - 数据是连续数值,从 0 到正:
viridis、cividis、mako。 - 数据是离散小整数:
tab20或hls。
vlag这类发散型色板适合标准化后的数据,因为标准化后数值会在 0 附近波动,发散色板能把“高于平均”和“低于平均”的差异一眼区分开。如果你运行了StandardScaler,用发散色板是最合理的。
6.4 性能问题:linkage 是 O(n²),数据一多别硬撑
层次聚类的 linkage 算法复杂度大约是 O(n²)(n 为样本数),内存开销也是 O(n²),因为要维护两两距离矩阵。这意味着:
- 500 个样本:秒级。
- 2000 个样本:可能要按分钟算。
- 5000 个样本:已经相当吃力,内存轻松上几百 MB。
- 10000+ 个样本:直接劝退。
如果项目里必须处理几万行数据,我的建议是换算法:先用 KMeans 或 MiniBatchKMeans 做粗聚类,得到 100~500 个簇中心,再对这些簇中心做层次聚类。这样既能保留层次结构的可解释性,又把计算规模压到可控范围。这是数据量级上升后最务实的折中方案,代价是叶子节点不再是原始样本,而是“簇代表”。业务报告里也完全够用。
6.5 列方向要不要聚类?这个参数默认值经常坑人
clustermap默认对行和列都聚类。但很多业务场景下,列是固定的指标,比如“年龄、收入、消费频次”,你不希望算法在列方向重新排顺序——否则别人会问“为什么收入这一列被插到了年龄和消费之间?”。
解决方案是加参数:
g = sns.clustermap( df_scaled, row_cluster=True, # 样本聚类 col_cluster=False, # 特征不做聚类,保持原始顺序 )如果列也有聚类分析的必要(比如基因表达研究里几十个基因要按表达模式归类),那就不设置col_cluster=False。这个选择没有绝对对错,但一定要清楚自己在干什么,别默认值顺手跑过去完事。
最后再分享一个让我长记性的小技巧:聚类图做完之后,一定要回到原始数据里抽几个样本验证图上分支的合理性,比如看同一簇的样本在第 3、第 5 个特征上数值是否真的相近。图上再漂亮,也得经得起原始数据的“反问”。这个习惯帮我挡掉过至少三次因为数据预处理失误而差点做出的错误分群结论。可视化只是帮你看清工具,最后拍板做判断的,永远还是人。