Softmax输出转分类标签并绘制混淆矩阵:模型评估可视化指南

📅 发布时间:2026/10/20 23:49:50
Softmax输出转分类标签并绘制混淆矩阵:模型评估可视化指南
1. 项目拆解从概率到标签混淆矩阵的最后一公里先把这个标题拆开来看将softmax输出转换为分类标签进行画出混淆矩阵。这半句话里其实藏了两个动作——先把模型的输出做一次“翻译”把向量变成标签再把标签变成一张能看懂分类好坏的矩阵图。两件事单独拎出来都很简单但合在一起做的时候我见过不少人在第一步骤就翻车。Softmax本身做的事情是把模型最后一层输出的logits未归一化的分数压成一个概率分布所有类别的输出值都在0到1之间并且加和为1。比如三分类问题某个样本的softmax输出可能是[0.7, 0.2, 0.1]含义就是模型认为这个样本属于类别0的概率是70%类别1是20%类别2是10%。这一步看着人畜无害但问题在于混淆矩阵需要的不是“70%、20%、10%”这种概率形式而是“这个样本被模型判成了哪个类”。这就需要做一次从概率向量到整数标签的转换。转换的规则极其简单取概率最大的那个下标。上面的例子直接取argmax得到0这个样本的预测标签就是类别0。但在实际操作中这个简单的转换背后还有几个容易被忽略的细节比如argmax在多个类别概率相同的时候选哪一个Keras/TensorFlow/PyTorch的选择还不太一样、验证集的标签如果是one-hot编码要怎么还原、以及二分类跟多分类在使用习惯上的区别。这些细节如果处理不好后面画出来的混淆矩阵可能是错得很有章法但你根本不知道问题出在哪。再说混淆矩阵这一头。混淆矩阵本质上是一张二维表格行通常是真实标签列通常是预测标签每个格子里的数表示“属于某真实类别的样本中被模型预测为某类别的数量”。对角线上的数字越大说明分类越准非对角线上的数字说明模型在哪些类别之间容易混淆。正因为这张表同时容纳了正确信息和错误信息它才成为分类任务里最常用的可视化评估工具之一。整体说来这个项目的链路是模型预测 - 概率转标签 - 构建混淆矩阵 - 绘图 - 分析。每一步都不复杂但每一步都有值得注意的点。这篇我按实操经验来写从环境准备到完整代码再到坑点梳理争取一篇跑通。2. 核心细节解析与实操要点2.1 概率到底怎么转成标签argmax的前前后后先明确一个基本认知模型在推理阶段给出的softmax输出不是一个可直接用于混淆矩阵的格式。混淆矩阵期望的输入是y_true真实标签的整数数组shape为[样本数]和y_pred预测标签的整数数组shape同样为[样本数]而不是二维的[样本数, 类别数]概率矩阵。所以第一步必然是把概率矩阵压成一个一维标签数组。最标准的操作就是np.argmax或者TensorFlow里的tf.argmax。以numpy为例import numpy as np # 假设 probs 的 shape 是 [num_samples, num_classes] probs np.array([[0.7, 0.2, 0.1], [0.1, 0.6, 0.3], [0.2, 0.2, 0.6]]) y_pred np.argmax(probs, axis1) print(y_pred) # [0, 1, 2]axis1的含义是沿类别维度取最大值所在的下标这是多分类的标准姿势。如果你不小心写了axis0得到的是每个类别维度上概率最大的样本下标整个语义就变了出来的结果没法看。有三个容易出问题的细节其一np.argmax在最大值不唯一的时候返回的是第一个出现的下标。比如probs [0.5, 0.5, 0.0]np.argmax返回0。绝大多数情况下模型不会出现完全相等的概率值但如果你用了离散化处理或者某些量化模型这种情况下默认取前一个类通常也不需要特别处理。其二如果你的框架是PyTorchtorch.argmax的行为大体相同但返回的是张量后接.cpu().numpy()才能转为numpy数组。对初学者来说这里常见的坑是直接在GPU张量上调用sklearn.metrics.confusion_matrix结果报类型错误。其三Keras/TensorFlow的老版本里有个model.predict_classes()方法专门做这件事但后来被移除了。现在统一是先model.predict()拿到概率再自己写argmax。自己写的优点是比较灵活因为很多场景下你还需要同时拿到概率值来做置信度过滤只拿个标签反而丢掉信息。2.2 真实标签的来源处理one-hot还原是重灾区训练模型的时候很多人的标签处理方式不一样。有的人用整数编码比如手写数字识别里的y_train本身就是0到9的整数数组有的人用to_categorical转成了one-hot编码比如[0, 0, 1, 0, ...]。如果真实标签是one-hot直接拿去跟y_pred算混淆矩阵会直接报错因为shape对不上y_true是二维y_pred是一维sklearn不会帮你自动广播。解决办法就是反向转换用np.argmax把one-hot也还原成整数标签。注意这里必须沿类别维度操作y_true_1d np.argmax(y_true_onehot, axis1)这个操作的本质是“撤销to_categorical”还原出原始的类别编号。在语义上它和预测概率的argmax是两码事——一个是将编码还原一个是从概率选结果但写法完全一样。这个巧合导致不少人混淆了用途写错位置的情况时有发生。还有一个容易忽略的点如果你的数据是从ImageFolder这类目录结构加载的真实标签已经是整数很省事但如果你用的是CSV里的字符串类别名比如cat/dog/birdsklearn.metrics.confusion_matrix实际上是支持字符串标签输入的它会自己把字符串映射成内部索引。但不同版本的sklearn对标签顺序的排列规则可能不同按字典序还是按出现顺序建议还是自己在数据准备阶段用LabelEncoder显式地编码一遍避免图表出来后标签顺序对不上。2.3 画图前的归一化选对方式别让数字骗了你混淆矩阵画图的下一步是可视化。直接用plt.imshow()或者seaborn.heatmap画原始计数在类别均衡的数据集上问题不大但在类别严重不均衡的数据集上光看原始计数会被大类别带偏。比如类别A有1万张图类别B有100张图A和B互错的20个样本在表上看起来微不足道但实际上B类被错了20%20/100。所以多数时候我们画的是“归一化后的混淆矩阵”。归一化有几种做法按行归一化每行除以该行的真实样本总数得到的是“每个真实类别的召回情况”、按列归一化每列除以该列的预测总数得到的是“精确率视角”以及整表归一化除以总样本数。最常用的是按行归一化因为对角线直接反映每个类别的recall也就是“真实类别A的样本中被正确识别为A的比例”。cm_normalized cm.astype(float) / cm.sum(axis1, keepdimsTrue)注意cm.sum(axis1, keepdimsTrue)的写法先转成float再除避免整数除法的坑Python 2时代的习惯但有些老代码还在。如果某一行真实样本数为0字段里存在的类别在验证集中恰好一个样本都没有比如冷启动场景会出现除零。这种情况要么在表外标注要么加一个极小值eps 1e-7兜底不要让代码崩在画图这一步。实际使用中我建议打印两张表一张原始计数版方便回溯具体数字一张按行归一化版方便横向比较召回情况。因为归一化之后虽然百分比直观但丢失了“具体数量”的信息分析的时候容易忽略小类别样本数太少导致百分比波动巨大的问题。两张表对照着看分析结论基本不会偏差太大。2.4 常见绘图工具选型哪个顺手用哪个画混淆矩阵的主流工具无非两种matplotlib从零自己画或者seaborn.heatmap半自动画。seaborn的方式代码量最少import seaborn as sns import matplotlib.pyplot as plt fig, ax plt.subplots(figsize(8, 6)) sns.heatmap(cm_normalized, annotTrue, fmt.2f, cmapBlues, xticklabelsclass_names, yticklabelsclass_names, axax) ax.set_xlabel(Predicted label) ax.set_ylabel(True label) plt.show()annotTrue把数值填进格子里fmt.2f控制格式化归一化用百分比格式fmt.2%更直观cmapBlues用蓝色渐变颜色越深数字越大。这些都是日常中最常用的配置。但seaborn.heatmap有个设计上让人纠结的点它默认的热力图颜色映射会把最大值显示为最深色最小值显示为最浅色。如果对角线数字远大于非对角线对角线会非常抢眼这是好事。但在类别数很多时比如50个类别格子里密密麻麻的数字会糊成一片这时候可以调小字号、去掉数字标注或者换fmtd只标整数。用matplotlib自己画的好处是灵活比如你想在格子上加特定的边框、高亮某些错分类格子、或者在格子内部显示两种信息计数百分比自己画更可控。但绝大多数项目用到混淆矩阵只是为了快速评估模型性能seaborn.heatmap已经足够不必重复造轮子。3. 实操流程与完整代码实现3.1 训练完成后的标准五步走我现在把一段完整的实操流程按步骤整理出来。先说整体思路假设你已经有训练好的模型模型输出的是概率验证集有真实标签现在要画出混淆矩阵并基于它做分析。整个流程分五步。第一步加载模型并对验证集做预测# 伪代码框架适配Keras/PyTorch/TensorFlow均可 # 关键点在于拿到 probs: [num_samples, num_classes] 的 softmax 概率矩阵这里Keras的写法是model.predict(val_generator)PyTorch需要自己写一个推断循环把模型model.eval()之后对每个batch前向一次然后torch.softmax(output, dim1)再收集所有batch的预测结果。两种方式拿到的都是[样本数, 类别数]的二维数组。第二步把概率矩阵转成预测标签import numpy as np y_pred np.argmax(probs, axis1)第三步把真实标签转成一维整数数组。如果你的y_val本身就是整数数组直接跳过如果是one-hot就用np.argmax(y_val, axis1)还原。第四步调用sklearn.metrics.confusion_matrix构建矩阵from sklearn.metrics import confusion_matrix cm confusion_matrix(y_true, y_pred) # 如果你想限定类别的范围可以用 labels 参数 # cm confusion_matrix(y_true, y_pred, labels[0, 1, 2, 3, 4, 5, 6, 7, 8, 9])这里有一个值得展开的细节labels参数的作用。默认情况下confusion_matrix会根据y_true和y_pred中实际出现的类别自动确定矩阵的行列顺序而且只包含两者中出现的类别。如果某个测试类别在预测中从没出现即模型完全没预测出来这种类别而该类别在真实标签里存在默认行为仍然会包含它因为y_true里有矩阵行数不受影响。但如果y_pred里额外出现了一个y_true里没有的类别比如训练时没见过的新类别推理时才出现矩阵会把它的列也加进去导致表格形状和预期不一致。通过labels[...]显式指定类别列表可以确保输出矩阵的行列永远是同一个固定顺序这件事在后期读取具体格子的值时很关键。第五步画图并存盘import matplotlib.pyplot as plt import seaborn as sns fig, ax plt.subplots(figsize(8, 6)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelsclass_names, yticklabelsclass_names, axax) ax.set_title(Confusion Matrix) ax.set_xlabel(Predicted Label) ax.set_ylabel(True Label) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150, bbox_inchestight) plt.show()dpi150是输出图片清晰度的一个基准值多数论文和报告里够用了。bbox_inchestight会把图片的边缘裁剪到刚好容纳所有元素避免标签被截掉。3.2 一份可直接参考的完整示例为了让你能一边看一边抄我给一个基于模拟项目的相对完整示例。这个示例模拟的是一个10分类的图像识别任务模型已经训练好我们只做评估部分。import numpy as np from sklearn.metrics import confusion_matrix from sklearn.preprocessing import LabelEncoder import seaborn as sns import matplotlib.pyplot as plt # 1. 加载真实标签 # 假设 val_labels 是目录读取得到的字符串标签列表 # 假设 val_probs 是模型预测得到的概率矩阵shape[num_samples, num_classes] # val_probs model.predict(val_generator) # 具体写法依框架而定 # 字符串标签编码为整数 encoder LabelEncoder() y_true encoder.fit_transform(val_labels) class_names encoder.classes_ # 2. 预测概率转预测标签 y_pred np.argmax(val_probs, axis1) # 3. 构建混淆矩阵 cm confusion_matrix(y_true, y_pred, labelsnp.arange(len(class_names))) # 4. 行归一化版 cm_normalized cm.astype(float) / cm.sum(axis1, keepdimsTrue) # 5. 可视化与保存 fig, axes plt.subplots(1, 2, figsize(16, 6)) # 左计数版 sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelsclass_names, yticklabelsclass_names, axaxes[0]) axes[0].set_title(Confusion Matrix (Count)) axes[0].set_xlabel(Predicted Label) axes[0].set_ylabel(True Label) # 右归一化版 sns.heatmap(cm_normalized, annotTrue, fmt.2f, cmapBlues, xticklabelsclass_names, yticklabelsclass_names, axaxes[1]) axes[1].set_title(Confusion Matrix (Normalized by Row)) axes[1].set_xlabel(Predicted Label) axes[1].set_ylabel(True Label) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150, bbox_inchestight) plt.show()这段代码里我特意把两张子图画在一起左边是计数版右边是归一化版。这么做的原因之前说了单看计数版类别不均衡会被淹没单看归一化版又失去了数量感。子图并排能更全面地评估模型表现。注意代码里的np.arange(len(class_names))这个写法的关键是让confusion_matrix的行列严格按照class_names的顺序来避免因为标签编码器内部的顺序和矩阵自动推断的顺序不一致导致后面看图对不上号。3.3 画图细节的现场微调实际画图时尺寸、字体、颜色这些参数很影响可读性。类别数少比如2~5类时figsize(6, 5)就够类别数10类以上我一般用figsize(10, 8)到了50类以上heatmap里每个格子的大小已经很难容纳annot的数字这时候我建议改用imshow加文本标注的方式或者干脆不标数字只显示颜色深浅。字体的调整也很关键。sns.set(font_scale1.2)可以统一切换比例这对类别标签的显示帮助很大。如果类别名是中文记得先设置中文字体否则图上的中文标签会变成方块。在中文字体设置上plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei, Noto Sans CJK SC] plt.rcParams[axes.unicode_minus] False第二个参数axes.unicode_minusFalse是为了解决坐标轴上负号显示为方块的问题这个坑看上去小但几乎必踩。还有一个老手才注意的点seaborn的cmap选色。默认的viridis渐变是色盲友好的但用在混淆矩阵上多数人还是习惯Blues或Purples因为浅色到深色对应低到高的直觉很自然。如果你想打印成灰度图有些期刊要求那就用cmapGreys。如果你希望错分类的格子显眼可以用cmapRdYlGn_r红到绿翻转版让正确分类高值显示为绿色、错分类显示为红色视觉冲击力很强。这种相反配色方案在二分类里尤其好用。3.4 从矩阵到结论怎么读出一张图的信息画出图只是手段真正值钱的是从图里读出的结论。我总结了五条读图顺序供你参考。先看对角线整体亮度。对角线越亮整体分类准确率越高。如果对角线的亮度参差不齐说明模型在不同类别上的表现不均衡差别大的话要优先排查数据量最少的类。再看最吸睛的非对角线亮点即某个格子的颜色明显深于周围。这说明模型在某个真实类别A上大量误判为类别B。遇到这种情况去翻一下原始数据里A和B的图像看是不是特征上非常相似类别B的样本视觉上被A的典型特征覆盖了或是标注时本身就有不少错误标注。然后看特定行真实类别和特定列预测类别。某一整行颜色偏浅说明这个真实类别的召回率不高某一整列颜色偏深说明模型倾向于把所有类都预测成这一列对应的类别这是明显的类别先验偏置往往和训练数据中该类别占比过高有关。接着看矩阵是否对称于对角线。如果A误判成B的次数和B误判成A的次数差不多说明两个类别在特征空间里确实难以区分可能是语义上接近比如猫和豹子也可能是标注标准本身模糊。如果不对称比如A大量被预测为B但B很少被预测为A那更可能是类别不均衡导致的决策边界偏移。最后跟业务指标挂钩。在二分类任务里混淆矩阵可以直接换算TP、TN、FP、FN进而算精度、召回率和F1。在多分类里可以从每行的归一化值里读取每个类别的召回。数据的价值在分析完毕后才真正落地。4. 常见问题与排查技巧实录4.1 形状不匹配y_true两维y_pred一维这应该是出现频率最高的问题了。现象是调用confusion_matrix时报错Found input variables with inconsistent numbers of samples或者更直接的维度错误。原因就是y_true还是one-hot编码是[样本数, 类别数]而y_pred是argmax之后的一维数组[样本数]。解决办法前面已经提过对y_true也做一次np.argmax(axis1)。值得补充的是有些人在数据加载时用了tf.keras.utils.to_categorical验证集标签在读取时就一并转了结果训练中途打印shape时习惯性地以为它是整数标签到了评估阶段才发现。排查思路很简单打印一下y_true.shape和y_pred.shape一眼便知。4.2 类别顺序对不上图上的标签顺序和你想的不一样这类问题在类别名是非连续整数时最容易踩。比如类别编号是1到10而不是0到9sklearn默认生成的矩阵行列顺序是按数值排但如果你在画图时自己传了xticklabels而class_names顺序和矩阵内部顺序不一致格子里数字和标签就对不上位。前面提过的labels参数就是为这个问题准备的显式指定labelsnp.arange(1, 11)确认矩阵行列顺序和预期一致。另一种情况是类别是字符串比如[cat, dog, bird]你在画图时直接用原始字符串列表作为xticklabels。问题在于sklearn内部对字符串的排序是字典序也就是bird(0)、cat(1)、dog(2)如果你的class_names原始顺序是[cat, dog, bird]就直接错位了。所以要么先LabelEncoder编码并保存好编码器的classes_用它作为xticklabels要么在调用confusion_matrix时显式传labels参数字符串列表。我强烈建议前者因为方便在后续计算其他指标时保持一致。4.3 负样本和类别不均衡别让大类别带偏你的判断类别不均衡是混淆矩阵分析的经典陷阱。举个例子99%的样本是类别A1%是类别B。模型“无脑全猜A”时准确率高达99%混淆矩阵里B行的颜色几乎全白对角线看起来也很亮。如果只看整体对角线亮度会误以为模型性能很好但B类的召回率是0。所以我的习惯是永远同时打印一个classification_report它会直接给出每个类别的精确率、召回率和F1值和混淆矩阵互相对照。处理的方法是分两层。先看每个类别的召回率找出模型的弱点类别再回到混淆矩阵看那个弱点类别都被预测成了哪些类定位具体混淆对象。只靠整体准确率或者只有矩阵不配合报告分析都会得出结论不完整。4.4 超大类别数密密麻麻看不清怎么办当类别数超过50时标准的heatmap几乎必然糊成一团。我的做法是把图切割成局部视图对某个重点关注的类别子集单独画一张子矩阵。比如模型在“手写数字识别”里第8类和第3类混淆严重其他都正常那就只取这两个类对应的行和列来画便于汇报时放大讨论。另一个方案是改用“逐类别错误率柱状图”配合“最大混淆对分析”即不画完整矩阵而是只画出每个类别召回率从高到低的排序柱状图再列出每个类别最常见的两个错分类目标。这两种方式在类别数特别多的工业场景里比完整矩阵更实用。完整的混淆矩阵仍然要留档但汇报和排查时用这两种即可。4.5 框架相关的坑PyTorch的GPU张量、Keras的predict返回值PyTorch下代码常见的报错是直接把CUDA上的张量传给sklearn函数类型和存储位置都不对。标准解法probs_batch torch.softmax(output, dim1) probs_list.append(probs_batch.detach().cpu().numpy())这里有两步不能省detach()断开梯度cpu()搬到内存.numpy()转数组。如果你在torch.no_grad()的推断上下文里做detach()其实可省但写上是好习惯防止后续改动时埋雷。Keras/TensorFlow侧需要注意keras新版里的model.predict默认返回numpy数组不用额外处理但如果你用tf.data.Dataset做验证集predict返回的结构可能是个大数组记得确认shape是[样本数, 类别数]而不是带有batch维度或额外维度。有一种常见情况模型输出层是(None, 1)二分类sigmoid拿到的probs是[样本数, 1]这时候np.argmax(probs, axis1)的结果全是0因为每个样本只有一个值argmax永远选它自己。二分类的正确做法是(probs[:, 0] 0.5).astype(int)而不是argmax。这一点在标题只提softmax时容易让人忽视但实际项目里二分类sigmoid的输出和softmax双类输出处理方式并不相同写成这样更稳妥。5. 经验扩展与应用价值5.1 从混淆矩阵到指标联动矩阵只是第一步画完混淆矩阵后我建议顺带把另一张表也打印出来按类别统计的精确率、召回率、F1值。sklearn里现成的函数是classification_reportfrom sklearn.metrics import classification_report print(classification_report(y_true, y_pred, target_namesclass_names, digits4))digits4表示小数保留4位默认是2在对比不同模型的细微差异时更精准。这张表和混淆矩阵配合分析效率和深度都会提升明显。如果项目里还要做ROC曲线就从probs里取目标类别的概率值别用y_pred。5.2 更进一步把错分类样本导出来做人工复盘混淆矩阵只是告诉你哪里错了但没告诉你错的具体是谁。实战中我的习惯是根据矩阵标出混淆最严重的格子然后从数据里筛选出这些格子里面的原始样本单独建一个文件夹存起来逐个查看。这一步做下来很多模型的“最后一公里”性能提升都来自这里看清了模型混淆的具体对象才能针对性地补数据、调标注、改增强策略。这个操作在代码层面很直白先找错分类样本的索引再按索引取原始数据。以numpy为例wrong_idx np.where(y_true ! y_pred)[0]然后把这些索引对应的原始图片或文本导出来逐个看就行。实际操作中我一般会额外打印出y_true、y_pred和对应的置信度probs.max(axis1)置信度高但分错的样本往往说明是数据标注错误或特征重叠置信度低分错则说明模型对这类样本的区分能力确实不足。这两类问题的处理方向完全不同前者优先清洗数据后者则要考虑增加特征、增加数据量或换模型结构。5.3 自动化评估管线把混淆矩阵做成每次训练的固定环节与其每次训练完手动跑一段评估脚本不如把混淆矩阵生成集成到训练流程里在验证集上每轮或每几轮自动生成并保存。比如每隔N个epoch把当前模型的验证集混淆矩阵存盘训练结束时你会得到一组按时间推进的混淆矩阵能直观看出哪两个类别的混淆度随训练推进逐渐收敛哪些一直没有改善。后者往往是模型能力瓶颈所在。这个自动化的实现很简单在训练循环回调里添加一段评估逻辑即可Keras可用Callback自定义PyTorch则是在验证函数末尾调用画图代码。只要注意把图片输出到独立的目录别覆盖上一轮的图就行文件名带epoch编号即可。5.4 我的个人体会做了几个分类项目之后我的感受是混淆矩阵看似是个“画个图就完事”的小功能但它是模型评估链路里信息密度最高的一环甚至比整体准确率更值得花时间分析。因为它能指出“谁和谁容易被混”这是准确率这种单值指标完全给不了的信息。一次完整的模型评估不仅是拿个acc0.93就交差还得知道那7%的错误具体错在哪、模型在哪几个类别上持续犯困、该优先处理哪个方向。混淆矩阵就是回答这些问题的那张“错误地图”。最后再分享一个小技巧我习惯把混淆矩阵的图片文件名连同模型版本号、日期一起存。比如cm_v2.1_20250115.png。别偷懒这一步模型迭代多了以后这能省下很多对比时间。你把版本1的混淆矩阵和版本2的放在一起一眼就能看出改进在哪里、新引入了哪些混淆这种直观的对比比翻训练日志高效得多。这个内容后续还可以扩展的方向不少如果模型部署到线上混淆矩阵的逻辑可以直接移植到监控脚本里周期性统计线上预测结果的混淆情况如果做多标签分类混淆矩阵的形态会变成每个类别独立的二分类矩阵需要调整画法如果类别数量大到矩阵无法可视化也可以退一步做聚类式的混淆分析。万变不离其宗核心思路都是“把模型的犯错模式可视化出来”掌握好这一步后续的扩展就都是顺水推舟的事了。