
1. 项目概述为什么你的模型需要一张混淆矩阵训练完一个Pytorch分类模型大多数人习惯性看一眼测试集准确率就觉得万事大吉。但准确率这个指标在不少场景下是会骗人的——比如类别不均衡的数据集哪怕模型把少数类全部判错只要多数类猜得足够准整体准确率依然能保持在90%以上。你根本看不出模型到底在哪些类别上犯了迷糊。而混淆矩阵Confusion Matrix做的就是这件事把模型预测结果和真实标签做成一张二维交叉表每一行代表真实类别每一列代表预测类别。矩阵对角线上的数字是预测正确的样本数非对角线位置就是具体怎么错的、错到了哪个类别。有了这张表你能直接回答三个核心问题模型在哪些类之间最容易混淆每个类别的精确率和召回率表现如何错误是偏向某一边还是均匀分布这个项目我前后做了很多次踩过的坑包括但不仅限于类别顺序对不上导致图画出来全是乱的、放在GPU上的张量忘记转回CPU、归一化方式选错导致热力图深浅完全失真、第一版代码在batch推理时忘了收集全部预测结果。这篇文章把整个过程完整梳理一遍从最基础的依赖安装到最终的热力图可视化每一步都会给出可以直接抄走的代码同时解释为什么这么写。2. 准备工作与核心思路拆解2.1 项目结构与本方案选型逻辑先说整体设计。这个任务本质上是三件事串联得到预测结果、计算混淆矩阵、可视化展示。听起来简单但每件事都有几个看似相似、实际千差万别的实现方式。得到预测结果这一环常见的坑是用验证集上的最好一次批次的预测结果而不是整个测试集从头到尾的完整预测。你用验证集loss挑出来的模型权重最终要看的还是它在测试集上的泛化表现所以做混淆矩阵一定要用测试集、完整跑完forward把所有预测和标签收集起来。另一个是注意要用torch.no_grad()包裹推理过程不然显存会被多余的计算图占满尤其是输入图片分辨率高或batch size大的时候爆显存不是开玩笑的。计算混淆矩阵这一环选sklearn.metrics.confusion_matrix还是自己写计数逻辑我的建议是用scikit-learn的现成函数除非你有特殊需求。自己写循环也可以但处理多分类、类别顺序、预测值和真实值格式不一致这些细节时手写代码很容易出边界问题。sklearn版本已经经过大规模验证返回的矩阵形状是(n_classes, n_classes)行是真实类别列是预测类别注意这个顺序不要搞反可视化的时候如果发现图和预期对不上先检查这里。可视化这一环纯粹用Matplotlib还是用Seaborn取决于你想花多少时间调样式。Seaborn的heatmap函数天然适配这类需求有可选的数值标注、配色方案、行列标签控制几行代码就出效果。不过如果你在自定义模型或需要集成到自己的训练脚本里直接用Matplotlib也不麻烦。我会在后面的可视化章节给出两种写法和各自适用场景。2.2 环境依赖与工具选型最低配置如下Python 3.8以上PyTorch 1.10以上scikit-learn 0.24以上matplotlib 3.5以上numpy 1.21以上seaborn可选仅热力图画得更快我实测过的组合是Python 3.10 PyTorch 2.0 scikit-learn 1.2 matplotlib 3.7没有遇到兼容性问题。如果你还在用老版本的PyTorch比如1.6或者更早一些API细节会不同比如torch.no_grad()的用法在早期版本也支持但要留意数据类型转换时tensor.cpu().numpy()在需要梯度时可能报RuntimeError后面故障排查章节会专门讲。提示如果环境里没安装scikit-learn单纯为了画混淆矩阵装一个库不要觉得这是多余。自己实现多分类混淆矩阵时要处理的边界情况比你想象得多与其在这上面浪费时间不如引入已经打磨好的依赖。2.3 数据格式与类别标签对齐一个非常隐蔽但极其常见的问题训练代码里的类别索引顺序和可视化时显示的类别名称顺序不一致。比如在Pytorch的torchvision.datasets.ImageFolder里类别顺序是按文件夹名称排序的Lexicographic order。你的文件夹可能叫apple, banana, grape那么索引0就是apple索引1就是banana。如果你在别处比如某个手写的txt映射表定义了另一套顺序画出来的混淆矩阵行列标签就会错位整个图看起来还是有模有样的但分析结论全部是错的。所以我强烈建议固定一套标签列表并在代码开头显式声明class_names [apple, banana, grape] # 或者从训练数据集中直接提取 class_names train_dataset.classes用train_dataset.classes是最稳妥的因为你训练时模型输出的索引就是这么来的不会出现人工手抄错误。后续无论计算矩阵还是画图都从同一个列表取标签名。3. 核心代码实现从模型推理到矩阵生成3.1 完整推导用测试集跑出预测结果先说这一段的目标把测试集或者验证集里所有样本的真实标签和模型预测结果收集成两个一维数组长度都应该等于测试集样本总数。一个典型的完整流程如下import torch import torch.nn.functional as F from torch.utils.data import DataLoader from tqdm import tqdm def collect_predictions(model, test_loader, device): model.eval() all_preds [] all_labels [] with torch.no_grad(): for inputs, labels in tqdm(test_loader, descEvaluating): inputs inputs.to(device) labels labels.to(device) outputs model(inputs) # outputs shape: (batch_size, num_classes) # 需要的是每个样本的最大分数对应的索引 _, preds torch.max(outputs, dim1) # 关键转回CPU并转成numpy all_preds.append(preds.cpu().numpy()) all_labels.append(labels.cpu().numpy()) all_preds np.concatenate(all_preds) all_labels np.concatenate(all_labels) return all_preds, all_labels几个细节解释一下为什么使用torch.max(outputs, dim1)而不是torch.argmax两者做的事情一样但torch.max顺带把最大值的数值也返回了如果你后面想做置信度阈值筛选不需要再跑一次前向传播。为什么model.eval()必须在循环前调用因为模型里的Dropout和BatchNorm在训练模式和推理模式行为差别很大。BatchNorm在train()模式下用的是当前batch的统计量在eval()模式下用的是训练阶段累积的全局统计量。忘记这一步预测结果可能是错的且错误模式非常隐蔽——你不要指望它报错只是数字不准确。为什么收集预测和标签要用numpy而不是继续保留tensor因为后面交给sklearn.metrics.confusion_matrix时numpy数组最方便。你当然可以传tensor进去sklearn底层会尝试转numpy但遇到需要detach的场景就会报错这里直接cpu().numpy()一步到位省心。3.2 核心逻辑构建并理解混淆矩阵拿到all_preds和all_labels之后一行代码就能生成矩阵from sklearn.metrics import confusion_matrix cm confusion_matrix(all_labels, all_preds) print(cm)这里有个非常值得注意的细节sklearn的confusion_matrix(y_true, y_pred)第一个参数传真实标签第二个参数传预测标签。我见过不止一次把顺序写反的如果你发现自己矩阵对角线上的数字莫名其妙地大但精确率和召回率分析却对不上先检查是不是这一步参数传反了。输出的cm是一numpy二维数组形状为(n_classes, n_classes)。cm[i][j]代表真实类别为i、预测类别为j的样本数。沿行方向看每一行的总和等于该真实类别在测试集中的总样本数沿列方向看每一列的总和等于模型预测为该类别的总数。举个具体例子假设3分类的矩阵是[[50, 5, 3], [ 2, 45, 8], [ 1, 4, 60]]现在看第一行真实类别050个预测正确5个被错判成了类别13个被错判成了类别2。第一行总样本数58。看第一列预测为类别050个确实来自类别02个来自类别11个来自类别2总预测数为53。你还能顺手算出每个类别的精确率和召回率类别0精确率 50 / 53类别0召回率 50 / 58类别2精确率 60 / 65类别2召回率 60 / 64不从混淆矩阵出发光用sklearn.metrics里的precision_score、recall_score你确实也能拿到这组数字但一个是黑盒求解一个是看表手算显然后者对模型行为的理解更清晰。3.3 归一化问题原始数值与比例图的取舍做可视化之前通常会纠结一个事情热力图颜色深浅到底表示原始数量还是百分比例我实际用下来的建议是两者都做根据需求选择展示。原始数值适合看绝对样本量比如你的测试集每类大约500张一眼就能看出模型在某个类上错了43张这个绝对值是否可接受取决于业务场景。但如果你想分析的是错误率的相对大小尤其是在类别样本量差异很大的情况下用归一化比例图才不会被绝对数量误导。例如测试集中类别0有1000个样本类别1只有100个样本。类别0错了50个比例是5%类别1错了10个比例是10%。看原始数值图50比10大很多你很可能误以为类别0的问题更严重。但归一化之后才能看清类别1的错误率其实翻倍了。sklearn提供normalize参数cm_normalized confusion_matrix(all_labels, all_preds, normalizetrue)normalizetrue的意思是按行归一化即每行真实类别的总和为1。这样矩阵里每个单元格的值表示真实类别为i的样本中被预测为类别j的比例。对角线上的值就是每个类别的召回率。这是最常用的一种归一化方式因为解释起来直观。还有一种normalizepred按列归一化得到的是精确率视角。看你分析需求选择。提示汇报结果时图里标注清楚是绝对数量还是归一化比例。最好在副标题里直接写明normalized confusion matrix (row-wise)免得看图的同事误读颜色深浅代表的意义。4. 可视化实战从朴素表格到生产级热力图4.1 使用Matplotlib实现基础热力图最朴素的方式一个for循环也能画出格子但效率太低且代码冗长。实际项目中我推荐直接使用plt.imshow或者Matplotlib的matshow。给一个我常用的基础版本import matplotlib.pyplot as plt import numpy as np def plot_confusion_matrix(cm, class_names, titleConfusion Matrix, cmapBlues, normalizeFalse): if normalize: cm cm.astype(float) / cm.sum(axis1, keepdimsTrue) fig, ax plt.subplots(figsize(8, 6)) im ax.imshow(cm, interpolationnearest, cmapcmap) ax.figure.colorbar(im, axax) # 设置刻度 ax.set(xticksnp.arange(cm.shape[1]), yticksnp.arange(cm.shape[0]), xticklabelsclass_names, yticklabelsclass_names, ylabelTrue Label, xlabelPredicted Label, titletitle) # 让x轴的标签在底部显示而不是顶部默认imshow是顶部的 ax.xaxis.set_label_position(bottom) ax.xaxis.tick_bottom() # 在每个格子里标注数值 thresh cm.max() / 2. for i in range(cm.shape[0]): for j in range(cm.shape[1]): ax.text(j, i, f{cm[i, j]:.2f} if normalize else f{cm[i, j]:d}, hacenter, vacenter, colorwhite if cm[i, j] thresh else black) fig.tight_layout() plt.savefig(confusion_matrix.png, dpi150, bbox_inchestight) plt.show()这段代码有几个细节值得注意为什么用cm.astype(float)原始混淆矩阵是整数类型整数除以整数在Python 3里得到的结果是浮点数但如果遇到某行和为0除法结果会是NaNastype(float)可以让后续操作更顺畅。另外如果直接把int类型矩阵传给imshow颜色的映射不会有问题但在某些情况下annotate字符串格式化会有坑转成float更安全。为什么用thresh cm.max() / 2来决定字体颜色如果格子的数值大于最大数值的一半说明格子颜色较深用白色字更清楚反之用黑色字。这个阈值设定简单粗暴但效果很好实测各种颜色映射下都清晰。如果你用的是plt.text手动标注而不是后面要讲的Seaborn内置annot功能这段逻辑就得自己写。关于显示方向Matplotlib默认的imshow坐标是从左上角开始y轴向下。这是图像坐标系的常规做法。如果你希望y轴从下往上数学坐标系习惯可以加上ax.invert_yaxis()。但这个决定纯属个人偏好我用默认方向没遇到过问题只要x轴标签在底部、y轴标签在左侧读者就能自然理解。4.2 使用Seaborn一行画出更美观的热力图如果你不排斥引入seaborn热力图能画得更快且默认样式在现代论文和报告里更顺眼import seaborn as sns plt.figure(figsize(10, 8)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelsclass_names, yticklabelsclass_names, cbarTrue, linewidths0.5, linecolorwhite) plt.xlabel(Predicted Label) plt.ylabel(True Label) plt.title(Confusion Matrix) plt.savefig(cm_seaborn.png, dpi150, bbox_inchestight) plt.show()参数解释annotTrue在格子中显示数值。fmtd因为数值是整数用十进制格式。如果归一化了改成fmt.2f。linewidths0.5给每个格子加白色边框线。这个细节能让大矩阵看起来不呆板。cbarTrue右侧显示颜色条。如果矩阵维度小于10Seaborn的标注基本不会重叠如果类别数量多到20以上格子里放数字会拥挤这种情况建议只放颜色块或者把字体调小sns.heatmap(cm, annotTrue, fmtd, annot_kws{size: 8}, ...)4.3 进阶可视化归一化图、错误分布图与多子图对比单一热力图只能说明模型整体行为。做更深入分析时我经常把三个图放在一起对比原始数值图、归一化比例图、错误强调图。错误强调图的思路是对角线位置全部置为0或单独画非对角线位置用另一种颜色映射让错误分布一眼可见。# 创建一个错误分布矩阵对角线保持白非对角线保留原值 error_cm cm.copy() np.fill_diagonal(error_cm, 0) plt.figure(figsize(8, 6)) sns.heatmap(error_cm, annotTrue, fmtd, cmapReds, xticklabelsclass_names, yticklabelsclass_names) plt.title(Error Distribution (Diagonal hidden)) plt.show()这张图在类别数量较多时极其好用。比如你有一个10分类的模型正常混淆矩阵里几十个格子全是数字人类视觉很难快速找到关键信息。把对角线隐藏、只凸显非对角线错误再加上红色系颜色映射哪两类最容易弄混一目了然。多子图对比适合需要同时看训练集表现和测试集表现或者对比两个不同模型权重的场景fig, axes plt.subplots(1, 2, figsize(16, 6)) sns.heatmap(cm_model_a, annotTrue, fmtd, cmapBlues, axaxes[0]) axes[0].set_title(Model A) sns.heatmap(cm_model_b, annotTrue, fmtd, cmapBlues, axaxes[1]) axes[1].set_title(Model B) plt.tight_layout() plt.savefig(compare_models.png, dpi150) plt.show()5. 实操记录数据增强策略的直观验证前面讲了不少理论这一节分享一个实际跑过的案例借此说明混淆矩阵可视化如何真正帮到模型调优。某次做细粒度图像分类任务类别非常相似比如不同品种的鸟。初始模型在测试集上准确率82%看起来还行。但把混淆矩阵画出来后发现问题很大一部分错误集中在“暗色羽毛的鸟类”之间比如A品种和B品种完全色调接近互相错判的比例明显偏高。针对这个情况我在数据增强里增加了颜色抖动ColorJitter、随机灰度化RandomGrayscale同时针对易混类对的样本做了简单的人工检查——发现部分训练样本的标注本身就模糊两张看起来几乎一样的图片标成了不同类别。模型学到的就是这种模糊边界再好的网络也没办法凭空学会区分人类都无法分辨的差异。改了数据增强策略并清理了部分噪声标签后重新训练。对比两次的混淆矩阵归一化图A类和B类之间的错判比例从13.8%降到7.2%整体准确率提升到89%。这个提升要是只看准确率数字你只知道“好了”但不知道为什么好、好在哪些类别上。而两张混淆矩阵图并排放在一起解释性就强得多到底是普遍提升还是特定混淆缓解一眼就能分辨。这个案例想说明的核心思路很简单混淆矩阵是模型错误诊断的入口不是终点。光会画图、会看比例还不行要能根据矩阵里的错误模式反推数据或模型的症结所在然后针对性调整。6. 常见问题与排查技巧实录6.1 矩阵对角线特别大但精确率低问题出在哪场景你打印出的混淆矩阵对角线数值看起来很高即大多数样本分类正确。但用sklearn.metrics.precision_score算出来的精确率低得离谱。这个矛盾通常是三个原因之一第一类别样本量极度不均衡矩阵可视化时小类别被“吃掉”了。比如1000个A类样本、50个B类样本。A类几乎全对B类错一半。混淆矩阵上A类的格子大数字多B类的格子数字小视觉上容易被忽略但精确率计算把B类的糟糕表现放大了。排查方法不要只看矩阵同时打印每一行的总和和每一列的总和计算类别的样本量分布。第二你用的是宏平均macro average还是微平均micro average。宏平均每个类别的权重相同小类别的表现会严重拉低整体分数。微平均等价于整体准确率你会觉得“还行”。第三标签顺序错位。预测时用的类别索引顺序和计算指标时的标签顺序不一致后面对不上导致全部指标都是乱的。排查方法很简单随机抽几个样本手动看预测结果和实际输出对比。6.2 报错RuntimeError: Cant call numpy() on Tensor that requires grad这个错误只会在一个场景出现你在torch.no_grad()外面执行了模型推理或者你在loss.backward()之后、但在同一个tensor上尝试转numpy。排查方法确认推理阶段是否用了with torch.no_grad():。如果确认用了还报错检查你是不是在拿到logits之后又做了某个操作导致新的tensor重新开启梯度追踪。# 错误写法 outputs model(inputs) # requires_gradTrue _, preds torch.max(outputs, dim1) preds.numpy() # RuntimeError # 正确写法 with torch.no_grad(): outputs model(inputs) _, preds torch.max(outputs, dim1) preds.numpy() # OK6.3 可视化时中文标签乱码Matplotlib默认字体不支持中文如果项目里的类别名称包含中文画图的标签会变成一个个方框。解决方案是在绘图前设置中文字体import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei, Noto Sans CJK SC] plt.rcParams[axes.unicode_minus] False这几行代码在Windows和Linux的中文字体选择上略有不同实测在Windows用SimHei没问题Linux服务器上如果没装Noto Sans CJK就先apt install fonts-noto-cjk。如果不想依赖系统字体还可以用font_manager指定字体文件路径稳妥但代码量多一点。建议本地调试用系统字体生成报告时统一用服务器上固定字体避免图片字体不一致的尴尬。6.4 矩阵很大时可视化的排版混乱假设你的类别数量是50甚至100。把所有类别标签都画在同一张图上横向标签互相重叠格子里的数字糊成一片这种图没什么用。应对策略有三种只展示一部分比如按错误数量排序只展示错误最多的前10对类别。降采样把混淆矩阵按更大的粒度合并比如把相近大类合并后再画。交互式展示用plotly生成可缩放的网页图鼠标悬停能看具体数值。虽然无法把图片嵌入静态文档但适合需要交付给业务方做探索的场景。6.5 矩阵里的数值比样本总数还多多出来的数据从哪来出现这个问题几乎可以肯定是你在推理时没有跳过数据集的最后一个小batch或者你重复跑了一次预测并把结果append了两次。排查方法打印len(all_preds)和len(test_loader.dataset)两者必须相等。如果all_preds多了看看是不是你的循环里没有判断边界或者之前某次调试时没有清空列表。# 确保列表是空的才开始 all_preds [] all_labels []6.6 GPU推理时显存溢出如果测试集图片分辨率高即便batch size适中也可能爆显存。主要元凶是检测模型Detection里的多尺度输出或者超大特征图。分类模型通常还好但如果你用了大卷积核或者输入是2000x2000的图也要小心。应对方法减小batch size。用torch.no_grad()。如果推理流程复杂在循环里显式调用torch.cuda.empty_cache()。顺带提一个真实遇到的坑batch size减小后最后一批样本数少了归一化时如果某一行对应的类别一个样本都没有矩阵的一行之和会是0除以0会得到NaN。解决方法是归一化时加上一个极小量epsilonrow_sum cm.sum(axis1, keepdimsTrue) cm_normalized cm / (row_sum 1e-12)7. 从混淆矩阵延伸到更多评估维度7.1 从矩阵一键导出精确率、召回率与F1混淆矩阵本身包含计算所有基础分类指标所需的信息。你可以算def metrics_from_cm(cm): n_classes cm.shape[0] precision np.zeros(n_classes) recall np.zeros(n_classes) f1 np.zeros(n_classes) for i in range(n_classes): # 精确率对角线 / 列和 precision[i] cm[i, i] / cm[:, i].sum() if cm[:, i].sum() 0 else 0 # 召回率对角线 / 行和 recall[i] cm[i, i] / cm[i, :].sum() if cm[i, :].sum() 0 else 0 # F1 if precision[i] recall[i] 0: f1[i] 2 * precision[i] * recall[i] / (precision[i] recall[i]) return precision, recall, f1或者直接依赖sklearnfrom sklearn.metrics import precision_score, recall_score, f1_score, classification_report print(classification_report(all_labels, all_preds, target_namesclass_names))classification_report输出的表格文本在控制台看很方便我每次训练完都会打印一份。虽然它不画图但和混淆矩阵图配合使用一图一表把模型表现讲得很清楚。7.2 多分类任务的宏平均与加权平均在做多分类评估时经常会遇到宏平均macro、微平均micro和加权平均weighted的选择。这里做个简单说明宏平均每个类别的指标先算出来然后取平均值。类别权重相同小类别的表现会被放大适合你关心所有类别是否都被照顾到的场景。微平均所有样本的TP、FP、FN加起来算总体指标等价于整体准确率对多分类而言适合样本量均衡的场景。加权平均按每个类别的样本占比加权计算能反映数据分布情况但大类影响大可能掩盖小类别问题。从模型优化的角度看我通常看宏平均F1因为如果某个小类别效果差宏平均会直接暴露出来。但从业务指标汇报的角度可能更需要加权平均因为业务看的是整体效果。混淆矩阵图解决的正是这个问题不管哪种平均指标它提供的只是总结数字矩阵图能告诉你具体哪些类表现差。宏平均F1低了打开混淆矩阵图一眼就能看到是哪个小类别被虐得最惨。7.3 用混淆矩阵辅助判断阈值调整对于二分类任务混淆矩阵的四个基本项TP、FP、FN、TN直接决定了ROC曲线和PR曲线上每个点的位置。通过调整决策阈值通常默认是0.5你可以让模型更偏向“宁可错杀也不放过”或者“宁可放过不可错杀”。比如医疗筛查场景假阴性比假阳性更致命。你可以降低正类的判定阈值让模型输出概率大于0.3就判为正类。这会让TP增多、FN减少但同时FP也会增加。此时画出不同阈值下的混淆矩阵对比TP、FP的变化幅度比只盯着AUC曲线要直观得多。多分类阈值调整稍微复杂但可以借助每个类别的概率分布直方图配合混淆矩阵来做。核心是先明确业务诉求是精确率优先还是召回率优先然后基于混淆矩阵在不同阈值下的变化来选。8. 经验沉淀与后续扩展方向整个流程实现下来我个人最大的感受是画混淆矩阵本身很简单难的是把它放到模型迭代的闭环里真正用起来。我现在的工作习惯是每次训练完不管结果好还是坏都固定输出三样东西——测试集loss曲线、classification_report文本、混淆矩阵三合一图原始数值、归一化比例、错误分布。这三样东西保存到实验目录下隔一段时间再回头对比不同实验版本时不用重新加载模型跑推理只翻图和报表就能快速定位问题。后续如果你想让这套可视化更自动化可以尝试两个扩展方向第一个方向是把混淆矩阵集成到训练脚本中每个epoch结束在验证集上自动画一张图。这样训练过程中就能动态观察是不是随着epoch增大某些混淆点一直在改善另一些一直停滞。在线监控的价值在于及早发现过拟合或学习率设置不当的问题。第二个方向是结合模型的可解释性工具。当你在混淆矩阵中发现某两类一直混淆别急着一味加数据、调模型先拿错误样本出来看看——用Grad-CAM或SHAP分析模型重点关注图像的哪些区域。很多时候你会发现模型其实在用一个非预期的特征做判断比如区分两种鸟时它关注的是背景草地颜色而非鸟本身的羽毛纹理。这个诊断路径配合混淆矩阵能帮你做出更有针对性的优化决策而不是盲目调参。