1. 从问卷原始数据到可读热力图:为什么这一步值得单独拿出来讲
问卷数据大概是所有数据里最"脏"的一类。你回收上来的是几百上千行、每行几十列的原始记录,选项是"非常满意/满意/一般/不满意"这种文本,还夹杂着跳答、多选、空值。很多人拿到手第一反应是直接df.corr()然后sns.heatmap(),结果出来一张糊成一团的图,自己都看不懂,更别说给同事或导师看了。
这篇要聊的就是把问卷结果用 matplotlib 生态里的 heatmap 做可视化这件事。核心工具是seaborn.heatmap(它底层就是 matplotlib,所以标题里写 matplotlib 完全说得通),但真正花时间的不是画图那一行代码,而是画图之前的数据整形和画图之后的解读设计。适合已经会画基础折线图、柱状图,但一碰到"问卷""矩阵""相关性"就不知道从哪下手的人。我会把从原始问卷到一张能直接放进报告的热力图,整个链路拆开讲,包括我踩过的几个坑。
先说清楚 heatmap 在问卷场景里到底解决什么问题。问卷分析常见的诉求有三类:一是看单个题目各选项的分布,这个用柱状图或堆叠条形图更合适;二是看多个题目之间的相关性,比如"满意度"和"复购意愿"是不是同向变化,这时候相关性热力图最直观;三是看交叉分布,比如不同年龄段对某个题目的选择差异,这时候用交叉表热力图。这三种诉求对应的数据形态完全不同,很多人画出来难看,根本原因是没分清自己要的是哪一种,拿相关性矩阵的代码去画交叉表,颜色映射全乱了。
我个人的经验是,动手写代码之前先在纸上画一下:行是什么、列是什么、每个格子的数值代表什么、颜色深浅代表什么。这四个问题答不上来,代码写出来也是白写。下面几节就按这个思路,从数据准备一路讲到成品调优。
2. 问卷数据进 heatmap 之前必须做的三件事
2.1 把文本选项转成可计算的数值
问卷回收下来,单选题的答案通常是字符串。热力图要的是数值矩阵,所以第一步是编码。这里有个关键选择:用序号编码还是用独热编码。
序号编码就是把"非常不满意=1、不满意=2、一般=3、满意=4、非常满意=5",适合有序类别(李克特量表)。这种编码保留了"程度"信息,算相关性、画热力图都有意义。独热编码是把每个选项拆成一列 0/1,适合无序类别(比如"你常用的出行方式"),但独热之后画相关性热力图基本没意义,因为 0/1 之间的相关系数解释起来很别扭。
import pandas as pd import numpy as np # 假设原始问卷长这样 raw = pd.DataFrame({ "满意度": ["满意", "非常满意", "一般", "不满意", "满意"], "推荐意愿": ["会推荐", "会推荐", "不确定", "不会推荐", "会推荐"], "使用频率": ["每天", "每周几次", "每月几次", "每天", "每周几次"], }) # 有序类别:手动指定映射,保证顺序正确 scale_map = {"非常不满意": 1, "不满意": 2, "一般": 3, "满意": 4, "非常满意": 5} raw["满意度_编码"] = raw["满意度"].map(scale_map) # 无序类别:用 category 类型固定顺序,再取 codes raw["使用频率"] = pd.Categorical( raw["使用频率"], categories=["每天", "每周几次", "每月几次", "很少"], ordered=True ) raw["使用频率_编码"] = raw["使用频率"].cat.codes注意:
cat.codes遇到不在 categories 里的值会给 -1,画热力图时这个 -1 会变成一个突兀的深色格子。务必先value_counts()检查有没有意外选项,比如用户手填的"其他"。
我见过太多人直接用LabelEncoder一把梭,结果"每天"被编成 2、"每月几次"被编成 0,顺序全乱,热力图上颜色深浅和实际程度对不上,解读直接翻车。有序变量一定要手动 map,这是铁律。
2.2 处理缺失值和跳答逻辑
问卷里跳答很常见:没选"使用过"的人,后面关于使用体验的题就是空的。这些空值在热力图里会显示成白色或灰色块,如果空得太多,整张图会像打了马赛克。
处理方式取决于分析目的。如果只是想看已答题人群的题目间关系,用dropna()按行删掉有缺失的样本最干净,但要记录删了多少、占比多少,写进报告。如果缺失比例很高(比如超过 30%),删样本会让结论严重偏倚,这时候考虑用均值/中位数填充,或者干脆把缺失单独作为一个类别。
# 先看缺失情况 missing_ratio = raw.isnull().mean().sort_values(ascending=False) print(missing_ratio[missing_ratio > 0]) # 缺失少:按行删除 df_clean = raw.dropna(subset=["满意度_编码", "推荐意愿_编码"]) # 缺失多:填充中位数(仅对有序数值变量合理) df_filled = raw.copy() df_filled["满意度_编码"] = df_filled["满意度_编码"].fillna( df_filled["满意度_编码"].median() )2.3 多选题的展开方式
多选题是问卷热力图里最容易做错的地方。一道"你使用过以下哪些功能"的题,回收上来是"功能A;功能C"这种字符串。直接编码没意义,正确做法是拆成多个 0/1 列,每一列代表一个功能是否被选中,然后这些 0/1 列之间算相关性,或者做题目与人群的交叉热力图。
# 多选题展开 multi = pd.Series(["功能A;功能C", "功能B", "功能A;功能B;功能C", "功能C"]) all_options = ["功能A", "功能B", "功能C"] for opt in all_options: raw[f"功能_{opt}"] = multi.str.contains(opt).astype(int)展开之后,功能_A、功能_B、功能_C三列就可以进相关性矩阵了。这里算出来的是phi 相关系数(两个 0/1 变量的皮尔逊相关),解释时要说清楚是"共同被选中的倾向",而不是因果。
3. 相关性热力图:问卷题目间关系的标准画法
3.1 相关系数矩阵的生成与选择
问卷题目大多是李克特量表,属于有序变量。严格来说,有序变量之间应该用Spearman 秩相关而不是 Pearson。Pearson 假设变量是等距的,而"满意=4、非常满意=5"之间的差距未必等于"一般=3、满意=4"之间的差距。Spearman 只看排名,对有序数据更稳健。
# 选出要分析的数值列 cols = ["满意度_编码", "推荐意愿_编码", "使用频率_编码"] corr_spearman = df_clean[cols].corr(method="spearman") print(corr_spearman.round(2))实测下来,如果样本量不大(比如几十份),Pearson 和 Spearman 的差异可能不明显;但样本上百之后,两者在强相关区域会有可见差别。我一般默认用 Spearman,报告里注明方法即可。
3.2 用 seaborn 画出第一版热力图
import matplotlib.pyplot as plt import seaborn as sns plt.rcParams["font.sans-serif"] = ["SimHei"] # 中文显示 plt.rcParams["axes.unicode_minus"] = False fig, ax = plt.subplots(figsize=(8, 6)) sns.heatmap( corr_spearman, annot=True, # 格子里显示数值 fmt=".2f", # 保留两位小数 cmap="RdBu_r", # 红蓝发散色,0 居中 center=0, # 让 0 对应白色 vmin=-1, vmax=1, # 固定范围,避免误导 square=True, # 格子正方形 linewidths=0.5, # 格子间白线 cbar_kws={"shrink": 0.8, "label": "Spearman 相关系数"}, ax=ax ) ax.set_title("问卷题目相关性热力图", fontsize=14, pad=12) plt.tight_layout() plt.savefig("corr_heatmap.png", dpi=150, bbox_inches="tight")这段代码有几个参数是必须显式设置的,不设就会出问题。center=0和vmin/vmax一起用,保证颜色映射以 0 为中性点,正相关偏红、负相关偏蓝,否则 seaborn 会按数据实际范围自动缩放,两张图之间没法对比。square=True让格子变正方形,视觉上更规整。fmt=".2f"控制小数位,问卷相关性一般两位小数足够。
3.3 上三角遮罩:让图更干净
相关性矩阵是对称的,左下和右上重复。把上三角遮掉,只留一半,图会清爽很多,也方便把注意力放在关键区域。
mask = np.triu(np.ones_like(corr_spearman, dtype=bool), k=1) fig, ax = plt.subplots(figsize=(8, 6)) sns.heatmap( corr_spearman, mask=mask, annot=True, fmt=".2f", cmap="RdBu_r", center=0, vmin=-1, vmax=1, square=True, linewidths=0.5, ax=ax )np.triu(..., k=1)生成上三角为 True 的布尔矩阵,mask参数会把 True 的位置隐藏。注意k=1是保留对角线,如果写k=0连对角线也遮掉,那就看不到每个题目和自己的完全相关(1.0)了,一般没必要遮对角线。
4. 交叉表热力图:看不同人群对同一题目的选择差异
4.1 交叉表的构建逻辑
相关性热力图看的是"题目和题目"的关系,交叉表热力图看的是"人群和题目"的关系。比如按年龄段分组,看每个年龄段对满意度的分布。这时候数据形态是一个二维表:行是年龄段,列是满意度等级,格子里是人数或占比。
# 构造交叉表 cross = pd.crosstab( df_clean["年龄段"], df_clean["满意度"], normalize="index" # 按行归一化,看每个年龄段内部的占比 ) print(cross.round(3))normalize="index"是关键。问卷里各年龄段人数往往不均衡,如果直接画人数,人数多的组颜色深,会掩盖分布形状的差异。按行归一化之后,每行加起来是 1,颜色深浅反映的是组内占比,这样不同组之间才能公平对比。如果关心的是"某个满意度等级里各年龄段占多少",那就用normalize="columns"。
4.2 交叉表热力图的配色与标注
交叉表的值是 0 到 1 的占比,配色应该用单向渐变(比如从浅到深的蓝色),而不是相关性那种以 0 为中心的发散色。用发散色会让 0.5 附近的格子颜色很浅,反而看不清。
fig, ax = plt.subplots(figsize=(9, 5)) sns.heatmap( cross, annot=True, fmt=".1%", # 百分比格式 cmap="Blues", # 单向渐变 vmin=0, vmax=cross.values.max(), linewidths=0.5, cbar_kws={"label": "组内占比"}, ax=ax ) ax.set_xlabel("满意度等级") ax.set_ylabel("年龄段") ax.set_title("各年龄段满意度分布", fontsize=14, pad=12) plt.tight_layout()fmt=".1%"让格子显示成"23.5%"这种,比 0.235 直观得多。vmin=0固定下界,避免最小值不为 0 时颜色被拉伸。这里vmax用实际最大值,让颜色充分利用整个色带。
4.3 样本量太小的格子怎么处理
交叉表里经常有些格子只有一两个人,占比算出来是 100% 或 0%,颜色极端,但统计上毫无意义。我的做法是在格子里同时标注人数,或者干脆设一个阈值,样本量低于 5 的格子用灰色覆盖。
# 同时标注占比和人数 counts = pd.crosstab(df_clean["年龄段"], df_clean["满意度"]) annot = cross.copy().astype(str) for i in range(cross.shape[0]): for j in range(cross.shape[1]): annot.iloc[i, j] = f"{cross.iloc[i, j]:.0%}\n(n={counts.iloc[i, j]})" sns.heatmap(cross, annot=annot, fmt="", cmap="Blues", ...)这样读者一眼就能看出哪些格子是"小样本噪声",不会过度解读。这个细节在正式报告里特别加分,因为审阅的人一定会问"这个 100% 是多少人算出来的"。
5. 让热力图真正能读:配色、排序、注释的实战调优
5.1 配色方案的选择依据
配色不是审美问题,是信息传达问题。三条原则:
- 有正负之分用发散色(RdBu_r、coolwarm、vlag),中性点设在 0 或理论中值。
- 只有大小之分用单向色(Blues、Greens、viridis),从浅到深。
- 避免彩虹色(jet、rainbow),它在数值上不单调,人眼会把中间某段误判成"高值"。
问卷相关性用发散色,交叉表占比用单向色,这是基本盘。如果要做给色觉障碍人群看的版本,用viridis或cividis这类感知均匀的色带更稳妥。
5.2 行列排序:把有关系的题目聚在一起
默认情况下,热力图的行列顺序就是 DataFrame 的列顺序,往往没什么逻辑。把相关性高的题目排在一起,图会呈现出明显的"块状结构",一眼就能看出哪几个题目是一组的。
# 用层次聚类重排顺序 from scipy.cluster.hierarchy import linkage, leaves_list corr = df_clean[cols].corr(method="spearman") linkage_matrix = linkage(corr, method="average") order = leaves_list(linkage_matrix) corr_ordered = corr.iloc[order, order] sns.heatmap(corr_ordered, annot=True, fmt=".2f", cmap="RdBu_r", center=0, vmin=-1, vmax=1, square=True)leaves_list返回聚类树的叶子顺序,按这个顺序重排行列,相关性强的题目会挨在一起。问卷题目多的时候(比如 20 题以上),这一步的效果非常明显,能直接看出问卷的"因子结构",对后续做因子分析也有启发。
5.3 注释文字的可读性
格子里的数字看不清,整张图就废了。两个常见问题:一是格子太小、字太大,数字挤在一起;二是深色背景上放黑字,完全看不见。
# 根据背景深浅自动切换文字颜色 def annot_color(val, cmap_name="RdBu_r"): # 简化处理:绝对值大的用白字 return "white" if abs(val) > 0.5 else "black"更省事的办法是调大figsize,让每个格子有足够空间。我一般按"格子数 × 0.6 英寸"估算图宽,比如 10 列的图至少 6 英寸宽。另外annot_kws={"size": 9}可以单独控制注释字号,不用动全局字体。
6. 那些让我返工的坑:问卷热力图常见问题排查
6.1 中文显示成方块
这是 matplotlib 最经典的坑。plt.rcParams["font.sans-serif"]设了中文字体还是方块,通常是字体名写错或者系统里没这个字体。稳妥的做法是先查系统可用字体:
from matplotlib import font_manager fonts = [f.name for f in font_manager.fontManager.ttflist] print([f for f in fonts if "Hei" in f or "Song" in f or "YaHei" in f])找到实际存在的字体名再设。另外axes.unicode_minus=False要一起设,否则负号也会变方块。如果是在服务器上跑图没有中文字体,最省事的办法是把标签改成英文,别硬折腾字体安装。
6.2 相关系数算出来全是 NaN
某两列之间全是 NaN,八成是其中一列方差为 0(所有人选了同一个选项)。方差为 0 的变量和任何变量的相关系数都是 NaN,因为分母是 0。这种情况先df[col].nunique()检查,如果某题所有人都选同一个答案,这题本身就没有分析价值,直接从矩阵里去掉。
# 剔除零方差列 valid_cols = [c for c in cols if df_clean[c].nunique() > 1] corr = df_clean[valid_cols].corr(method="spearman")6.3 颜色范围被单个异常值带偏
如果数据里有个别极端值,seaborn 默认按 min-max 映射颜色,会导致大部分格子颜色挤在一起,区分度极低。解决办法是手动设vmin/vmax,或者用分位数截断。
vmin, vmax = np.percentile(corr.values, [5, 95]) sns.heatmap(corr, vmin=vmin, vmax=vmax, ...)相关性矩阵一般直接固定vmin=-1, vmax=1就行,因为相关系数的理论范围就是 [-1, 1],固定之后不同图之间还能横向对比。
6.4 保存的图被裁掉标签
plt.savefig默认按 figure 的原始尺寸保存,如果标签超出边界就被裁掉。加bbox_inches="tight"能自动扩展边界,把标签包进去。但要注意,tight有时会把图例或标题裁得只剩一半,配合plt.tight_layout()一起用更稳。
排查这类问题的通用思路是:先看数据,再看参数,最后看渲染。数据有问题(NaN、零方差)先修数据;数据没问题但图不对,检查vmin/vmax/center这些映射参数;图和数据都对但显示异常,那就是字体、尺寸、保存方式的问题。按这个顺序查,基本不会绕弯路。
7. 把热力图嵌进完整问卷报告的几个经验
单张热力图再漂亮,也只是报告的一部分。我一般会把相关性热力图和交叉表热力图放在一起,前者回答"哪些题目在测同一个东西",后者回答"不同人群的差异在哪"。两张图配合,结论就立得住。
导出时统一用dpi=150以上,bbox_inches="tight",文件名带上分析维度(比如corr_spearman_v1.png),方便版本管理。如果报告要放进文档,建议同时导出 PNG 和 PDF,PDF 是矢量图,放大不糊。
还有一个容易被忽略的点:热力图的解读要写进正文。图本身不会说话,你得告诉读者"满意度和推荐意愿的相关系数是 0.72,说明这两个题目测的可能是同一个潜在维度,后续做因子分析时可以考虑合并"。把图和分析结论绑在一起,这张热力图才算真正完成了它的使命。
我自己做问卷分析这些年,最大的体会是:画图代码就那么几行,真正决定质量的是画图之前对数据的理解和对分析目标的清晰定义。先把"行是什么、列是什么、颜色代表什么"想明白,剩下的交给 seaborn 就行。