多分类别看准确率,先把混淆矩阵看懂再谈优化
上个月一个朋友跑过来跟我诉苦,说他的图像分类模型在测试集上准确率到了93.4%,结果上了业务线之后一塌糊涂,某些类别几乎全错,用户天天投诉。我让他把混淆矩阵打出来看一眼,问题一下就暴露了:模型把两个外观高度相似的类别搞混了,但因为这两个类别在样本里占比很小,对整体准确率的影响被其他大类“稀释”掉了——看起来93.4%很美,实际上核心业务场景完全没覆盖住。
这就是多分类评估最典型的一个坑:只看准确率,等于让高占比类别替你掩盖低占比类别的问题。这篇就用 Python 多分类混淆矩阵代码为主线,把多分类建模、评估、调优这条链路完整走一遍。适合刚接触多分类、或者已经在做分类但觉得评估指标讲不清楚的读者。内容会偏实操,代码可以直接拿去改。
1. 多分类和二分类的本质差异:从“是不是”到“是哪一个”
很多人在二分类里待惯了,转到多分类时还是用老一套思路来思考问题,这会在后续评估和调优时埋雷。多分类和二分类看起来只是“类别数量从2变成3以上”,但本质上是从判断题变成了选择题,这个转变带来的变化远比想象中大。
1.1 决策边界从一条线变成一个区域
二分类的任务是找到一个决策边界,把特征空间一分为二。模型输出的本质是“正类的概率”,只要这个概率大于0.5就判为正类。这里隐含了一个逻辑:负类的概率就是1减去正类概率,不需要单独建模。
多分类就不一样了。假设有猫、狗、鸟三个类别,模型需要为每个类别计算一个“得分”或“概率”,然后取最大的那个作为预测结果。这里的核心问题是:三个类别的概率之和必须等于1吗?不一定。取决于你用的模型和策略。
如果用的是逻辑回归做一对多(One-vs-Rest,简称OvR),那每个类别都会训练一个独立的二分类器,输出的是“属于这个类别的概率”,三个概率加起来通常不等于1。如果用的是softmax回归(也叫多项逻辑回归),那三个类别的得分经过softmax变换后,概率之和恒等于1,而且不管哪个类别得分高,最终预测都取argmax。
这两种方式的差异在实际项目中影响很大。OvR相当于训练了K个独立的二分类器,每个类别都有自己的一套权重;softmax则是一套权重同时服务于所有类别,参数共享,在一起训练。通常来说,类别之间有相关性时softmax效果更好,类别相对独立时OvR也有它的用武之地。但在多数实际任务中,两者的差异并不夸张,更多影响来自特征工程和数据质量。
1.2 评估视角的转变:单一指标不够用了
二分类时,准确率、精确率、召回率、F1这些指标含义很直观,混淆矩阵是2×2的结构,四个格子一目了然。多分类时,每增加一个类别,混淆矩阵就多一行一列,10个类别就是10×10的矩阵,100个类别的图像分类任务更是直接变成100×100。
更关键的变化是:像精确率和召回率这样的指标,在多分类里天然就是“针对单个类别”的。你需要问自己一个问题:我关心的是哪个类别的精确率?还是所有类别的综合表现?这两个问题的答案对应着完全不同的计算方式,这就是后面要讲的宏平均、微平均、加权平均的区别。
还有一个容易被忽视的视角变化:二分类里,正类和负类是对称的,你说“精确率”不用解释就知道是针对正类的;多分类里没有“正类”这个天然锚点,每个类别都可以是“正类”,所以你必须明确指定评估角度。
1.3 一个简单的例子感受一下
假设有三个类别A、B、C,每个类别100条样本,总共300条。某个模型的表现是:A类正确90条,B类正确80条,C类正确70条。整体准确率是(90+80+70)/300 = 80%。
但如果你只报了“准确率80%”,没人知道你A类表现比C类好很多。如果业务上C类是最重要的类别,那80%的准确率就是误导信息。这就是为什么多分类任务里,混淆矩阵和按类别拆分的指标比单一准确率重要得多。
2. 多分类建模的三个核心策略:OvR、OvO和Softmax
理解了多分类和二分类的本质差异后,接下来要解决的是:模型层面怎么处理多分类问题。这决定了你的模型输出什么、怎么解读预测结果。
2.1 OvR:训练K个二分类器
OvR(One-vs-Rest)的思路最直观:对每个类别都训练一个二分类器,判断“是这一类还是其他所有类”。假设有5个类别,就训练5个分类器。预测时,把样本分别送入5个分类器,取得分最高的那个类别作为最终预测结果。
sklearn里使用OvR非常简单:
from sklearn.linear_model import LogisticRegression from sklearn.multiclass import OneVsRestClassifier # 方法一:直接指定multi_class参数 model = LogisticRegression(multi_class='ovr', max_iter=1000) # 方法二:手动包装OvR(适用于不支持原生多分类的模型) base_model = LogisticRegression(max_iter=1000) ovr_model = OneVsRestClassifier(base_model) ovr_model.fit(X_train, y_train)这种方式的优点是理解成本低,任何一个二分类模型都可以通过这个策略扩展成多分类。缺点是当类别数量很大时,需要训练K个模型,训练和推理成本线性增长。
2.2 OvO:两两PK
OvO(One-vs-One)的思路是:为每对类别训练一个分类器。比如5个类别,就有C(5,2)=10个分类器。预测时,让每个分类器投票,得票最多的类别胜出。
你可能觉得OvO训练的分类器更多、更浪费,有意思的是,在某些模型上OvO的预测效果反而比OvR好。原因在于:OvR的每个二分类器面对的数据是不均衡的——比如10个类别,每个二分类器面对的“正类”只有10%的数据,“负类”占90%,这种天然的类别不平衡会影响分类器的学习效果。而OvO每个分类器面对的都是两个类别的均衡数据。
sklearn中SVM默认就使用OvO策略,因为SVM本身是天然的二分类器:
from sklearn.svm import SVC # sklearn的SVC在多分类时默认使用OvO策略 model = SVC(kernel='rbf', decision_function_shape='ovo') model.fit(X_train, y_train)在sklearn较新版本中,SVC的decision_function_shape参数虽然还存在,但默认的决策逻辑已经统一为OvO投票后取分数最高的类别。需要注意的是,SVC输出的是决策函数值(可以理解为到决策边界的距离),不是真正的概率。如果你想要概率输出,需要设置probability=True,但这会引入额外的Platt缩放计算,训练时间会明显增加。
2.3 Softmax:概率之和恒等于1的优雅方案
Softmax函数做的事情非常简洁:把一个K维的实数向量压缩成K维的概率向量,每个元素都在(0,1)之间,且所有元素之和等于1。
import numpy as np def softmax(scores): exp_scores = np.exp(scores - np.max(scores)) # 数值稳定性处理 return exp_scores / np.sum(exp_scores) scores = np.array([2.0, 1.0, 0.1]) probs = softmax(scores) print(probs) # [0.65900114, 0.24243297, 0.09856589] print(np.sum(probs)) # 1.0代码里减掉np.max(scores)这一步是数值稳定性的经典处理,防止指数运算时数值溢出。这在类别数很大的时候特别重要。
在sklearn中,逻辑回归的multi_class='multinomial'就对应softmax回归:
# softmax回归 model = LogisticRegression(multi_class='multinomial', solver='lbfgs', max_iter=1000) model.fit(X_train, y_train)使用softmax时有几个注意点:一是solver参数有约束,liblinear只支持OvR,lbfgs和newton-cg支持multinomial;二是softmax的预测结果是真正的概率分布,这在需要不确定性估计的场景下比SVM的决策函数值更有用。
2.4 怎么选
没有绝对最优的策略,但有几个经验可以参考:
- 类别数量很少(3~5类)时,三种策略差异通常不大,优先选softmax,输出概率解释性最好
- 类别数量中等(5~50类)时,OvR和softmax都可以,看模型是否支持原生多分类
- 类别数量很大(50类以上)时,考虑OvR的并行训练特性,或者直接上神经网络
- 对概率输出有硬性需求时,优先softmax
- 基线模型建议先从softmax开始,简单、稳定、可解释性强
3. 混淆矩阵就是多分类评估的X光片:逐块拆解
模型跑完,第一件事不是看accuracy_score,而是把混淆矩阵打出来。它在多分类评估里的地位,相当于医生拍的X光片——告诉你病灶具体在哪里,而不是只告诉你“你病了”。
3.1 混淆矩阵的每一格都代表什么
多分类混淆矩阵是一个K×K的方阵,K是类别数。行代表真实类别(True),列代表预测类别(Predicted)。第i行第j列的元素表示“真实类别为i、但被预测为j”的样本数量。
以一个3类别问题为例:
import numpy as np from sklearn.metrics import confusion_matrix y_true = np.array(['cat', 'dog', 'bird', 'cat', 'bird', 'dog', 'cat', 'cat', 'bird', 'dog']) y_pred = np.array(['cat', 'dog', 'cat', 'bird', 'bird', 'dog', 'cat', 'dog', 'cat', 'bird']) cm = confusion_matrix(y_true, y_pred, labels=['cat', 'dog', 'bird']) print(cm)输出结果:
[[2 1 1] [0 1 1] [1 1 1]]解读:
- 对角线上的元素:[0,0]=2、[1,1]=1、[2,2]=1,代表预测正确的样本
- 非对角线元素:全部是错误预测
- [0,1]=1,表示真实是cat但被预测成dog的样本有1个;[0,2]=1,表示真实是cat但被预测成bird的样本有1个
这里要多说一句:很多人容易把行和列的方向搞反。一个简单的记忆方法:对比y_true和y_pred的顺序——第一个参数是真实值,行方向对应第一个参数;第二个参数是预测值,列方向对应第二个参数。用labels参数显式指定类别顺序,避免出现类别顺序混乱的问题。
3.2 从混淆矩阵能读出哪些信息
混淆矩阵的价值在于,它能让你快速定位以下问题:
哪些类别容易被混淆。如果[真实为A,预测为B]的格子数值很高,说明模型在区分A和B时存在系统性困难。这类错误通常意味着A和B的特征区分度不够,或者特征被其他噪声掩盖了。
哪些类别被“吞掉”了。如果真实类别C所在那一行,主对角线以外的数值都很高,说明模型很难把C类样本识别出来,C类被其他类别“吸收”了。这种情况常见于某个类别的样本量过少或特征表达不足。
哪些类别是“过度自信”的。如果预测为D类的那一列,除了主对角线之外还有不少数值,说明模型倾向于把别的类别的样本判定为D类。这在业务上往往意味着D类的决策边界过于宽松。
还有一个信息容易被忽略:混淆矩阵可以告诉你错误的严重程度。比如在医学诊断场景里,把“早期肿瘤”预测成“健康”和把“健康”预测成“早期肿瘤”,代价完全不同。但在混淆矩阵里,这两个错误都是非对角线的一个数字。所以如果你在做业务模型,建议结合业务定义“错误矩阵”,给不同类型的错误赋予不同权重,这比单一考虑准确率要合理得多。
3.3 用热力图可视化混淆矩阵
光看数字矩阵还不够直观,尤其是类别多的时候。建议用热力图:
import matplotlib.pyplot as plt import seaborn as sns from sklearn.metrics import confusion_matrix def plot_confusion_matrix(y_true, y_pred, labels, title='Confusion Matrix'): cm = confusion_matrix(y_true, y_pred, labels=labels) plt.figure(figsize=(8, 6)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=labels, yticklabels=labels) plt.xlabel('Predicted Label') plt.ylabel('True Label') plt.title(title) plt.show() labels = ['cat', 'dog', 'bird'] plot_confusion_matrix(y_true, y_pred, labels)有几个细节值得注意:
- annot=True表示在格子里显示数字,fmt='d'表示显示整数格式
- xticklabels和yticklabels必须显式指定,否则seaborn会默认用数字作为刻度,对应不上真实类别名称
- cmap选'Blues'只是为了视觉上容易识别深度,实际可以换成任何色带,但建议选顺序型色带(sequential colormap),不要选发散型,否则焦点会被颜色吸引走
如果数据量大,数字过于拥挤,可以考虑在热力图基础上加归一化版本,将每行除以该行总数,显示的是“真实为该类别的样本中,各类预测占比”。这在对比不同类别的错误分布时非常有用:
cm_normalized = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis] sns.heatmap(cm_normalized, annot=True, fmt='.2f', cmap='Blues', xticklabels=labels, yticklabels=labels)这两个版本我都建议画出来。原始版本看数量,归一化版本看比例。比例版本尤其适合发现“某个类别虽然总量少,但错误率极高”的隐藏问题。
4. 多分类评估指标体系:从精确率和召回率到宏微平均
刚才说过,精确率、召回率这些指标在多分类中天然是针对单个类别的。现在的问题就变成了:多个类别各有各的精确率和召回率,怎么汇总成一个数字?这就是宏平均和微平均出现的原因。
4.1 按类别的精确率、召回率、F1
对每个类别,都可以把它当作“正类”,其他所有类别当作“负类”,计算二分类指标:
from sklearn.metrics import precision_recall_fscore_support y_true = ['cat', 'dog', 'bird', 'cat', 'bird', 'dog', 'cat', 'cat', 'bird', 'dog'] y_pred = ['cat', 'dog', 'cat', 'bird', 'bird', 'dog', 'cat', 'dog', 'cat', 'bird'] labels = ['cat', 'dog', 'bird'] precision, recall, f1, support = precision_recall_fscore_support( y_true, y_pred, labels=labels ) for i, label in enumerate(labels): print(f"{label}: precision={precision[i]:.3f}, recall={recall[i]:.3f}, " f"f1={f1[i]:.3f}, support={support[i]}")输出:
cat: precision=0.500, recall=0.500, f1=0.500, support=4 dog: precision=0.500, recall=0.500, f1=0.500, support=3 bird: precision=0.500, recall=0.500, f1=0.500, support=3这里的precision_recall_fscore_support函数返回四个数组:每个类别的精确率、召回率、F1和样本数(support)。
这里要特别说明support的含义:它是每个类别的真实样本数。在解读按类别的指标时,要结合support一起看。如果一个类别的support只有10,就算它的F1是0.9,也不如另一个support为1000、F1为0.85的类别有参考价值。小样本类别的高分可能只是运气。
4.2 宏平均和微平均:两种截然不同的哲学
宏平均(macro average):先计算每个类别的指标,再对所有类别取算术平均。这个指标对每个类别一视同仁,不管你类别里有多少样本,权重完全一样。
微平均(micro average):把所有类别的预测结果汇总到一起计算指标。具体来说,就是所有类别的True Positive加起来除以所有类别的样本数。对于准确率来说,微平均就等于整体准确率。
用代码理解更直接:
import numpy as np from sklearn.metrics import classification_report, accuracy_score # 手动计算宏平均F1 macro_f1 = np.mean(f1) # 手动计算微平均F1 total_tp = np.sum([cm[i][i] for i in range(len(labels))]) total_samples = cm.sum() micro_accuracy = total_tp / total_samples还有一个加权平均(weighted average),是宏平均的一个变体:每个类别的指标乘以该类别样本占比,然后求和。这在类别不平衡时更有参考意义,因为大类别的影响被保留了下来。
sklearn里classification_report函数一行就能输出所有这些指标:
from sklearn.metrics import classification_report report = classification_report(y_true, y_pred, labels=labels, target_names=labels, digits=3) print(report)输出:
precision recall f1-score support cat 0.500 0.500 0.500 4 dog 0.500 0.500 0.500 3 bird 0.500 0.500 0.500 3 accuracy 0.500 10 macro avg 0.500 0.500 0.500 10 weighted avg 0.500 0.500 0.500 10注意看输出中的差异:
- accuracy那一行只有一个值,0.500,是所有样本整体的准确率
- macro avg是三个类别指标的平均,不受类别样本量影响
- weighted avg按样本占比加权,更贴近“随机抽一个样本,模型表现如何”
4.3 实际项目中指标选择的建议
单独看宏平均和微平均哪个“更好”,意义不大。核心问题是你的业务需求是什么:
如果你的目标是对所有类别公平对待,不希望大类别主导评估结果,宏平均更合适。典型场景:异常检测,每个异常类型都很重要,即使某种异常很罕见。
如果你的目标是反映“真实用户遇到的平均体验”,加权平均值更合适。典型场景:用户请求分类,大类别占比高,用户遇到分类错误的概率与大类别占比直接相关。
如果业务中只有少数几个关键类别真正重要,那就别看平均值了,直接列出这几个关键类别的精确率和召回率,逐项核对。
我在实际项目里的做法是:每个模型都必须打印一份classification_report,再加一张混淆矩阵热力图。看完这些再决定要不要调阈值、换模型、做特征工程。跳过这一步直接调模型的,大概率是在盲目尝试。
4.4 一个特别容易踩的坑:classification_report的数字格式
classification_report里的digits参数控制小数位数。默认是2,但对小样本类别来说,2位小数可能看不出差异。比如某个类别F1一个是0.501,一个是0.499,精确到2位小数就都显示成0.50。建议设置digits=3或更高。
另外target_names参数建议显式指定,否则报告里会显示0、1、2这种索引,需要你对照labels手动查类别名,很容易看错。
5. 完整实操:从训练到混淆矩阵绘制的全流程
前面讲了很多概念,这里给出一套完整可跑的代码,用鸢尾花数据集演示从训练到评估的完整链路。这套代码可以直接改成你自己的业务数据。
5.1 数据准备和模型训练
import numpy as np import pandas as pd import matplotlib.pyplot as plt import seaborn as sns from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.metrics import (confusion_matrix, classification_report, accuracy_score, precision_recall_fscore_support) # 加载数据 iris = load_iris() X, y = iris.data, iris.target class_names = iris.target_names.tolist() print(f"类别: {class_names}") # ['setosa', 'versicolor', 'virginica'] print(f"样本量: {X.shape}") # 切分训练集和测试集 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) # 标准化 scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) # 训练softmax回归 model = LogisticRegression(multi_class='multinomial', solver='lbfgs', max_iter=1000) model.fit(X_train_scaled, y_train) # 预测 y_pred = model.predict(X_test_scaled) y_pred_proba = model.predict_proba(X_test_scaled)这里有几个细节需要说明:
train_test_split里的stratify=y参数非常重要。它保证切分后训练集和测试集的类别分布与原始数据一致。如果不设置这个参数,在类别不平衡的数据集上,可能出现某个类别在测试集里样本极少甚至没有的情况,导致评估指标完全失真。
StandardScaler标准化在逻辑回归等基于梯度的模型中是必须的。如果不做标准化,特征的量纲差异会导致模型收敛缓慢,还可能让数值较大的特征主导决策。
5.2 输出评估指标
# 整体准确率 accuracy = accuracy_score(y_test, y_pred) print(f"整体准确率: {accuracy:.4f}") # 分类报告 print(classification_report(y_test, y_pred, target_names=class_names, digits=3)) # 观看前5个样本的预测概率 print(pd.DataFrame(y_pred_proba[:5], columns=class_names))输出看起来像这样(数据集切分固定的话,结果可复现):
整体准确率: 0.9333 precision recall f1-score support setosa 1.000 1.000 1.000 15 versicolor 0.867 0.933 0.897 15 virginica 0.933 0.867 0.897 15 accuracy 0.933 45 macro avg 0.933 0.933 0.933 45 weighted avg 0.933 0.933 0.933 45 setosa versicolor virginica 0 1.0000 0.0000 0.0000 1 0.9821 0.0179 0.0000 2 0.0000 0.0001 0.9999 3 0.0000 0.0475 0.9525 4 0.0000 0.2930 0.7070鸢尾花数据集很干净,三个类别线性可分程度高,所以指标看起来还不错。真实业务数据通常远没这么理想,但流程是完全一样的。
5.3 绘制三张必看的图
第一张图:标准化混淆矩阵(数值版)
def plot_confusion_matrices(y_true, y_pred, labels, save_path=None): fig, axes = plt.subplots(1, 2, figsize=(14, 6)) # 数值版 cm = confusion_matrix(y_true, y_pred, labels=range(len(labels))) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', ax=axes[0], xticklabels=labels, yticklabels=labels, cbar=False, annot_kws={'size': 14}) axes[0].set_title('Count', fontsize=14) axes[0].set_xlabel('Predicted', fontsize=12) axes[0].set_ylabel('True', fontsize=12) # 行归一化版本 cm_norm = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis] sns.heatmap(cm_norm, annot=True, fmt='.2f', cmap='Blues', ax=axes[1], xticklabels=labels, yticklabels=labels, cbar=False, annot_kws={'size': 14}) axes[1].set_title('Row Normalized', fontsize=14) axes[1].set_xlabel('Predicted', fontsize=12) axes[1].set_ylabel('True', fontsize=12) plt.tight_layout() if save_path: plt.savefig(save_path, dpi=150, bbox_inches='tight') plt.show() plot_confusion_matrices(y_test, y_pred, class_names)这张图是核心交付物。画完之后我建议盯着看30秒,回答三个问题:
- 对角线上的数字是不是都够大?
- 非对角线上的数字集中在哪些格子里?这代表模型最容易混淆哪一对类别。
- 有没有某些行整体偏暗?如果有,说明该类别的召回率有问题。
在真实项目里,曾经有一张混淆矩阵让我发现模型把“售后咨询”和“投诉建议”两个类别混得很厉害,因为它们的关联词高度重叠。后来加了会话上下文特征,这个问题基本消失。没有混淆矩阵,这个问题可能永远发现不了。
第二张图:各类别的精确率、召回率、F1条形图
precisions, recalls, f1s, supports = precision_recall_fscore_support( y_test, y_pred, labels=range(len(class_names)) ) fig, ax = plt.subplots(figsize=(10, 5)) x = np.arange(len(class_names)) width = 0.25 ax.bar(x - width, precisions, width, label='Precision') ax.bar(x, recalls, width, label='Recall') ax.bar(x + width, f1s, width, label='F1') ax.set_xticks(x) ax.set_xticklabels(class_names) ax.set_ylim(0, 1.05) ax.set_title('Per-class Metrics') ax.legend() plt.show()这张图的价值在于快速对比类别间差异。如果一个类别的精确率明显低于其他类别,说明模型经常把别的类别的样本判成这个类;如果召回率明显偏低,说明这个类别的样本被漏掉了很多。两者都低,说明这个类别的特征表达有问题,或者样本量太少。
第三张图:预测置信度分布
# 查看预测正确和错误的置信度分布 confidence = np.max(y_pred_proba, axis=1) correct = (y_pred == y_test) fig, ax = plt.subplots(figsize=(10, 5)) ax.hist(confidence[correct], bins=20, alpha=0.6, label='Correct', color='steelblue') ax.hist(confidence[~correct], bins=20, alpha=0.6, label='Incorrect', color='salmon') ax.set_xlabel('Confidence (max probability)') ax.set_ylabel('Frequency') ax.set_title('Confidence Distribution: Correct vs Incorrect') ax.legend() plt.show()这张图能告诉你模型的“自知之明”。如果错误的样本集中在高置信度区域,说明模型过拟合了,需要正则化或者增加数据;如果错误样本集中在低置信度区域,恭喜你,模型的不确定性输出是有价值的,可以在业务里设置置信度阈值,低于阈值的样本走人工审核。
5.4 保存评估结果的规范做法
模型训练完,评估完,建议把结果持久化到文件,便于后续回归对比:
import json from pathlib import Path # 保存模型 import joblib joblib.dump(model, 'model.pkl') joblib.dump(scaler, 'scaler.pkl') # 保存评估结果 results = { 'accuracy': round(accuracy, 4), 'classification_report': classification_report( y_test, y_pred, target_names=class_names, digits=3, output_dict=True ), 'class_names': class_names } with open('evaluation_results.json', 'w', encoding='utf-8') as f: json.dump(results, f, ensure_ascii=False, indent=2)有个细节:classification_report里设置output_dict=True后返回的是字典结构,方便直接存JSON或导入pandas做进一步分析。这在做模型版本对比时特别有用,可以直接写脚本比较两个版本的指标差异。
6. 多分类调优中容易被忽视的几个问题
模型跑通了,指标也打印出来了,下一步就是调优。多分类调优的思路和二分类有一些不同的地方,这里把最容易踩的几个坑单独拿出来说。
6.1 类别不平衡:别只盯着整体准确率
前面已经反复提到类别不平衡的问题。当数据严重不平衡时,整体准确率会非常有欺骗性。比如99%是A类,1%是B类,模型全部预测A类就有99%的准确率,但这明显是个废模型。
解决方案有几个:
- 重采样:对少数类做上采样(用SMOTE等过采样算法),或对多数类做下采样
- 调整类别权重:sklearn很多模型支持class_weight='balanced'参数,会自动根据类别占比调整损失函数中的权重
- 换评估指标:对少数类的精确率、召回率、F1单独评估,不依赖整体准确率
这里说一个使用class_weight时的副作用:模型预测概率会偏向少数类。如果你后续要做置信度阈值判断,用class_weight训练完的模型预测概率不能直接当真实概率用,需要做概率校准。
6.2 阈值调优:默认的0.5不一定适合多分类
二分类经常讨论阈值调整,但一到多分类,很多人就默认pick ad-hoc的argmax,不再考虑阈值问题。其实多分类同样可以调阈值,只不过调整的方式不一样。
对softmax输出来说,你可以为每个类别设置不同的阈值,只有当某类别的概率超过它的阈值时才接受预测,否则归为“不确定”或“其他”。这在业务中很有用,尤其是分类错误的代价很高时。
还有一种方式是在输出概率上乘一个类别权重系数,相当于人为拉高或压低某些类别的预测倾向:
# 调整输出概率的示例 def adjusted_predict(probs, class_weights): # class_weights: 每个类别的调整系数 adjusted = probs * class_weights return np.argmax(adjusted, axis=1) class_weights = np.array([1.0, 0.8, 1.2]) # 压低第2类,抬高第3类 adjusted_pred = adjusted_predict(y_pred_proba, class_weights)这种方式的本质是在精确率和召回率之间做取舍。每种业务对错误的容忍度不同,你需要根据业务需求确定类别权重的方向。
6.3 错误案例的深入分析:比调参更有价值
调参是战术层面的优化,分析错误案例是战略层面的优化。每次模型跑完,我都建议花时间把预测错的样本翻出来逐条看:
# 找出预测错误的样本 error_indices = np.where(y_pred != y_test)[0] errors = pd.DataFrame({ 'true_class': [class_names[y_test[i]] for i in error_indices], 'pred_class': [class_names[y_pred[i]] for i in error_indices], 'true_prob': [np.max(y_pred_proba[i]) if y_pred[i] != y_test[i] else np.nan for i in error_indices], }) print(errors.head(20))不断问自己几个问题:错误的类别集中在哪几对?正确类别的预测概率是不是本来就不高?错误样本在上游特征层面有什么共同模式?这些分析会直接影响你的下一步动作,而不是盲目试一堆模型。
6.4 多分类模型对比的可复现性
多分类项目往往要反复实验,训练多个候选模型。这时一个可复现的评估流程比个别模型的性能还重要。我的做法是固定三个东西:随机种子、数据切分方式、评估脚本。每次实验只改模型配置,其他全部保持不变,这样指标差异才是真实由模型引起的。
另外,提交给业务方或老板的报告里,除了数值指标,一定要附上混淆矩阵热力图和关键类别的错误示例。这两样东西能让人直观理解模型在哪类问题上表现弱,也更容易换取业务方的信任和理解。
7. 多分类项目的经验沉淀:我在实际应用中的几点体会
时间关系,最后分享几个从真实项目里沉淀下来的体会,每个都是拿教训换来的。
第一,首次跑通基线模型时,不要花时间调参,先把数据质量彻底查一遍。多分类模型效果差,原因大概率不在模型,而在数据。你需要检查每个类别的样本量是否充足、类别间是否存在严重重叠、标签是否有噪声。我见过很多项目,调了一个月模型效果上不去,最后发现是标注数据有大量标签错误。
第二,评估报告一定要附带support数据。任何不附带support的按类别指标,都是脱离上下文的自嗨。有了support,别人才能判断这个类别的F1值是不是靠少量样本撑出来的。
第三,多分类模型上线后,要做分布漂移监控。线上数据的类别分布、特征分布都会随时间变化。建议定期在当天数据上跑一遍混淆矩阵,看指标有没有明显下滑。这个工作量不大,但收益很大,能提前发现线上问题。
第四,不要迷信单一指标,也不要在多个指标之间反复横跳。把核心指标定下来,后面所有实验都围绕它做对比较。如果要看辅助指标,单独列出来看,不要混在一起算总分。多分类评估的指标足够多了,做好它们之间的分工,比追求一个“全覆盖”的合成指标更靠谱。
写到这里,你会发现多分类的核心套路并不复杂:模型选型、混淆矩阵解读、按类别指标分析、错误案例驱动优化。这套流程跑熟了,不管面对什么业务数据都不会慌。如果这篇文章对你有帮助,可以从上面任意一段代码开始,结合自己的数据跑一遍,很快就能建立起属于你自己的多分类评估框架。