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[每天, 每周几次, 每月几次, 很少], orderedTrue ) 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(ascendingFalse) 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(methodspearman) 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, annotTrue, # 格子里显示数值 fmt.2f, # 保留两位小数 cmapRdBu_r, # 红蓝发散色0 居中 center0, # 让 0 对应白色 vmin-1, vmax1, # 固定范围避免误导 squareTrue, # 格子正方形 linewidths0.5, # 格子间白线 cbar_kws{shrink: 0.8, label: Spearman 相关系数}, axax ) ax.set_title(问卷题目相关性热力图, fontsize14, pad12) plt.tight_layout() plt.savefig(corr_heatmap.png, dpi150, bbox_inchestight)这段代码有几个参数是必须显式设置的不设就会出问题。center0和vmin/vmax一起用保证颜色映射以 0 为中性点正相关偏红、负相关偏蓝否则 seaborn 会按数据实际范围自动缩放两张图之间没法对比。squareTrue让格子变正方形视觉上更规整。fmt.2f控制小数位问卷相关性一般两位小数足够。3.3 上三角遮罩让图更干净相关性矩阵是对称的左下和右上重复。把上三角遮掉只留一半图会清爽很多也方便把注意力放在关键区域。mask np.triu(np.ones_like(corr_spearman, dtypebool), k1) fig, ax plt.subplots(figsize(8, 6)) sns.heatmap( corr_spearman, maskmask, annotTrue, fmt.2f, cmapRdBu_r, center0, vmin-1, vmax1, squareTrue, linewidths0.5, axax )np.triu(..., k1)生成上三角为 True 的布尔矩阵mask参数会把 True 的位置隐藏。注意k1是保留对角线如果写k0连对角线也遮掉那就看不到每个题目和自己的完全相关1.0了一般没必要遮对角线。4. 交叉表热力图看不同人群对同一题目的选择差异4.1 交叉表的构建逻辑相关性热力图看的是题目和题目的关系交叉表热力图看的是人群和题目的关系。比如按年龄段分组看每个年龄段对满意度的分布。这时候数据形态是一个二维表行是年龄段列是满意度等级格子里是人数或占比。# 构造交叉表 cross pd.crosstab( df_clean[年龄段], df_clean[满意度], normalizeindex # 按行归一化看每个年龄段内部的占比 ) print(cross.round(3))normalizeindex是关键。问卷里各年龄段人数往往不均衡如果直接画人数人数多的组颜色深会掩盖分布形状的差异。按行归一化之后每行加起来是 1颜色深浅反映的是组内占比这样不同组之间才能公平对比。如果关心的是某个满意度等级里各年龄段占多少那就用normalizecolumns。4.2 交叉表热力图的配色与标注交叉表的值是 0 到 1 的占比配色应该用单向渐变比如从浅到深的蓝色而不是相关性那种以 0 为中心的发散色。用发散色会让 0.5 附近的格子颜色很浅反而看不清。fig, ax plt.subplots(figsize(9, 5)) sns.heatmap( cross, annotTrue, fmt.1%, # 百分比格式 cmapBlues, # 单向渐变 vmin0, vmaxcross.values.max(), linewidths0.5, cbar_kws{label: 组内占比}, axax ) ax.set_xlabel(满意度等级) ax.set_ylabel(年龄段) ax.set_title(各年龄段满意度分布, fontsize14, pad12) plt.tight_layout()fmt.1%让格子显示成23.5%这种比 0.235 直观得多。vmin0固定下界避免最小值不为 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, annotannot, fmt, cmapBlues, ...)这样读者一眼就能看出哪些格子是小样本噪声不会过度解读。这个细节在正式报告里特别加分因为审阅的人一定会问这个 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(methodspearman) linkage_matrix linkage(corr, methodaverage) order leaves_list(linkage_matrix) corr_ordered corr.iloc[order, order] sns.heatmap(corr_ordered, annotTrue, fmt.2f, cmapRdBu_r, center0, vmin-1, vmax1, squareTrue)leaves_list返回聚类树的叶子顺序按这个顺序重排行列相关性强的题目会挨在一起。问卷题目多的时候比如 20 题以上这一步的效果非常明显能直接看出问卷的因子结构对后续做因子分析也有启发。5.3 注释文字的可读性格子里的数字看不清整张图就废了。两个常见问题一是格子太小、字太大数字挤在一起二是深色背景上放黑字完全看不见。# 根据背景深浅自动切换文字颜色 def annot_color(val, cmap_nameRdBu_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_minusFalse要一起设否则负号也会变方块。如果是在服务器上跑图没有中文字体最省事的办法是把标签改成英文别硬折腾字体安装。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(methodspearman)6.3 颜色范围被单个异常值带偏如果数据里有个别极端值seaborn 默认按 min-max 映射颜色会导致大部分格子颜色挤在一起区分度极低。解决办法是手动设vmin/vmax或者用分位数截断。vmin, vmax np.percentile(corr.values, [5, 95]) sns.heatmap(corr, vminvmin, vmaxvmax, ...)相关性矩阵一般直接固定vmin-1, vmax1就行因为相关系数的理论范围就是 [-1, 1]固定之后不同图之间还能横向对比。6.4 保存的图被裁掉标签plt.savefig默认按 figure 的原始尺寸保存如果标签超出边界就被裁掉。加bbox_inchestight能自动扩展边界把标签包进去。但要注意tight有时会把图例或标题裁得只剩一半配合plt.tight_layout()一起用更稳。排查这类问题的通用思路是先看数据再看参数最后看渲染。数据有问题NaN、零方差先修数据数据没问题但图不对检查vmin/vmax/center这些映射参数图和数据都对但显示异常那就是字体、尺寸、保存方式的问题。按这个顺序查基本不会绕弯路。7. 把热力图嵌进完整问卷报告的几个经验单张热力图再漂亮也只是报告的一部分。我一般会把相关性热力图和交叉表热力图放在一起前者回答哪些题目在测同一个东西后者回答不同人群的差异在哪。两张图配合结论就立得住。导出时统一用dpi150以上bbox_inchestight文件名带上分析维度比如corr_spearman_v1.png方便版本管理。如果报告要放进文档建议同时导出 PNG 和 PDFPDF 是矢量图放大不糊。还有一个容易被忽略的点热力图的解读要写进正文。图本身不会说话你得告诉读者满意度和推荐意愿的相关系数是 0.72说明这两个题目测的可能是同一个潜在维度后续做因子分析时可以考虑合并。把图和分析结论绑在一起这张热力图才算真正完成了它的使命。我自己做问卷分析这些年最大的体会是画图代码就那么几行真正决定质量的是画图之前对数据的理解和对分析目标的清晰定义。先把行是什么、列是什么、颜色代表什么想明白剩下的交给 seaborn 就行。