☰
Keras多分类加权交叉熵:解决类别不平衡的损失函数实战指南
2026/10/14 23:33:54 网站建设 项目流程

做多分类任务时,如果你没被类别不平衡折磨过,那要么数据集太干净,要么运气真的很好。现实里不管是工业质检的缺陷类型识别、医疗影像的病灶分类,还是风控场景的异常样本识别,常见类别的样本动辄成千上万,而你想重点抓出来的少数类别可能只占百分之一不到。这种数据喂给标准交叉熵训练出来的模型,往往对多数类很熟练,少数类直接躺平——不是模型笨,是损失函数没给它足够的压力去重视这些稀缺样本。解法之一,就是在损失函数层面给不同类别的样本分配不同权重,也就是多分类加权交叉熵。这篇文章专注 Keras 落地,聊聊从原理到实操的完整路径,包括三种实现方式、权重计算策略、训练调参的踩坑经验。

适合谁读?主要是在用 Keras/TensorFlow 做图像分类、文本分类等多分类任务的开发者,尤其是发现少数类验证指标不理想、但不知道从哪里下手的人。如果你对交叉熵本身还比较生疏,我也会把机制讲得通俗一点,跟着代码能直接用起来。

1. 加权交叉熵要解决的问题:类别不平衡的困境

1.1 标准交叉熵为什么在多分类不平衡场景下会“失灵”

这里我要先把公式摊开讲清楚,因为只有理解损失函数是怎么构成的,后面调权重时才不会瞎试。

多分类交叉熵的常见形式是:

L = -∑ y_i log(p_i)

其中 y_i 是真实标签的 one-hot 编码,p_i 是模型对第 i 类的预测概率。因为 one-hot 向量里只有一个 1,所以单个样本的损失实际上就是真实类别对应位置的 -log(p_true)。

这里的关键是:样本对总损失的贡献,只取决于它自己真实类别的预测置信度。假设三类样本数量是 10000、1000、100,模型把所有样本都预测为第一类,全局准确率有 90%,交叉熵损失同样被海量第一类样本的“正确预测”主导。少数类样本即使全被分错,它们的 -log(p_true) 往往也不够大,因为模型在少数类上预测概率接近 0 时确实会产生很大的 loss,但这个量级会被 10000 个多数类样本平均掉,淹没在 batch 的总和里。

用数学的眼光看:标准交叉熵相当于是用每个类别的样本频率隐式作为权重,频率越高的类别在总损失中的话语权越大。这本来在类别均衡时没问题,一不平衡就会让表征被多数类绑架。加权交叉熵就是打破这种隐式权重,把频率权重替换成你指定的显式权重 w_i:

L = -∑ w_i * y_i * log(p_i)

一个类别样本少,但你希望模型重视它,就把它的 w_i 调大。具体放大到什么程度,这就是后面要解决的问题。

1.2 从二分类加权到多分类加权:一条自然的升级路径

很多同学第一次接触加权是在二分类任务里看到 tf.nn.weighted_cross_entropy_with_logits 这个 API。它其实就是给正样本的损失乘一个正样本权重 pos_weight,负样本权重默认为 1。多分类并不能直接照抄这个函数,因为类别不是两两对称的,你需要为每一类维护一个独立的权重。

不过底层机制完全一致:模型在迭代中更新参数的方向由损失对参数的偏导决定,而损失是逐样本损失的加权求和。你在多分类里把所有类的权重组织成一个向量,本质上就是二分类权重的一个自然扩展。

我个人觉得,多分类加权真正复杂的地方不在实现,而在“权重要怎么分配”这个问题。二分类你往往只需要盯住正样本一个权重,多分类你面对的是一个权重向量,类别多的时候还需要考虑类别之间的相对重要度。所以在进入代码之前,先把权重的来源想清楚,后面会顺手很多。

2. 在 Keras 里落地加权交叉熵的三种思路

2.1 方案一:class_weight 参数,最轻量但限制了发挥空间

如果你只是想在现有模型上快速试验加权到底有没有用,最简单的就是使用 model.fit 的 class_weight 参数,直接传一个字典:

model.fit( x_train, y_train, class_weight={0: 1.0, 1: 5.0, 2: 15.0}, epochs=30 )

key 是类别索引,value 是该类别的权重。Keras 在训练循环里会先计算每个样本的标准损失,再根据样本真实类别乘上对应权重,最后取平均用于更新。这个方案最明显的优势是“零侵入”:模型定义、compile 都不用改,适合快速验证,也适合给别人代码时保持最小改动。

但它的劣势也很明显:

  • 权重需要手动写死,类别多或数据集经常更换时维护成本高,容易漏类、写错索引。
  • 只支持按真实类别进行样本加权。如果你想让某个类别对之间的错分惩罚更高,或者想结合样本自身难度做衰减,它无能为力。
  • 权重和模型没有绑定在同一个对象里,模型保存时不会保存 class_weight,换环境复现容易出问题。

所以我的建议是:它适合做“这个方向到底可不可行”的可行性测试,真要正经训练模型,还是走后面两种方案。

2.2 方案二:自定义损失函数,把权重写进计算图

这才是业界比较通用的做法。把权重向量作为超参数,传入一个自定义损失函数,让加权操作成为模型计算图的一部分。这样训练、验证、推理的路径非常清晰,模型结构里就承载了这个设计决策。

先给一个新手最容易照抄的版本:

import tensorflow as tf from tensorflow import keras from tensorflow.keras import backend as K def weighted_categorical_crossentropy(weights, from_logits=False): def loss(y_true, y_pred): y_pred = K.clip(y_pred, K.epsilon(), 1 - K.epsilon()) ce = -y_true * K.log(y_pred) weighted_ce = ce * weights return K.sum(weighted_ce, axis=-1) return loss

这个写法的思路最直接,就是照着公式一步步算:clip 防止 log(0),交叉熵逐元素计算,再乘上类别权重向量(这里会自动广播到 batch 维),最后沿类别维度求和,得到每个样本的 loss。

但我必须提醒你,这个版本存在数值稳定性隐患。softmax 输出的概率在训练初期可能是 0.0001 甚至更小,clip 到 epsilon 之后 log 值依然十分夸张,一旦少数类权重又比较大,梯度很容易爆炸。你可能看到 loss 曲线先下降,然后某一轮突然跳成 NaN。

更稳的写法是复用 Keras 内置的交叉熵实现,配合 from_logits=True:

def weighted_categorical_crossentropy(weights, from_logits=True): def loss(y_true, y_pred): ce = keras.losses.categorical_crossentropy( y_true, y_pred, from_logits=from_logits ) sample_weight = tf.reduce_sum(y_true * weights, axis=-1) return ce * sample_weight return loss

第二段代码里我没再手动 clip,因为 keras.losses.categorical_crossentropy 内部基于 tf.nn.softmax_cross_entropy_with_logits 实现,做了 max-subtract 等数值稳定处理。前提是模型最后一层不加 softmax,直接把 logits 输出,from_logits=True 告诉它入参不是概率而是未归一化的对数。

这里的 sample_weight 算的是每个样本真实类别对应的权重。因为 y_true 是 one-hot,y_true * weights 在类别维度上只有真实类别那一位置不为零,求和后自然就取到了该类别对应的权重。最后 ce 是 (batch,) 的逐样本交叉熵,sample_weight 是 (batch,) 的逐样本权重,两者逐元素相乘就是最终加权损失。

如果你的标签不是 one-hot,而是稀疏整数索引,就要换 sparse 对应的损失:

def weighted_sparse_categorical_crossentropy(weights, from_logits=True): def loss(y_true, y_pred): ce = keras.losses.sparse_categorical_crossentropy( y_true, y_pred, from_logits=from_logits ) sample_weight = tf.gather(weights, y_true) return ce * sample_weight return loss

y_true 在这个情况下是整数数组,比如 shape 是 (batch,),dtype 一般用 int32。tf.gather(weights, y_true) 按索引取权重,等价于根据每个样本的类别挑选对应权重。

这里有一个容易被忽略的细节:自定义损失函数返回的是 per-sample loss,Keras 默认会做一次取平均得到最终的训练 loss。也就是说你返回 (batch,) 的向量,最终梯度来自这个向量所有元素的均值,单个样本权重越大,对梯度的影响按比例放大。这个行为正好符合加权交叉熵的预期,不需要额外再写 reduce_mean。

2.3 方案三:自动化权重计算 + 自定义损失组合拳

很多新手初期会把增大少数类权重视为一个“体力活”,每次都要根据数据集统计样本数,手工填一个字典进入 class_weight。实际上这个流程应该自动化。写一个函数来自动计算每个类别的权重,再跟自定义损失函数组合,整个流程才具备可复用性。

import numpy as np def compute_class_weights(labels, num_classes, mode="inverse_freq", sqrt=False): counts = np.bincount(labels, minlength=num_classes).astype(np.float64) counts = np.maximum(counts, 1e-8) if mode == "inverse_freq": weights = len(labels) / (num_classes * counts) elif mode == "median_freq": freq = counts / len(labels) median_freq = np.median(freq[freq > 0]) weights = median_freq / freq else: raise ValueError("unknown mode") if sqrt: weights = np.sqrt(weights) weights = weights / np.mean(weights) return weights.astype(np.float32)

这里面的几个细节都是我自己踩坑之后才加的。为了防止某个类别在训练集中完全没出现导致除零,counts 加了一个极小的平滑值;median_freq 模式在计算中位频率时只考虑非零频次,避免全零样本数干扰;最后一步归一化是为了不改变模型初始损失的整体量级,从而不影响你已经调好的学习率。

训练时组装起来非常顺:

weights = compute_class_weights(y_train, num_classes=5, mode="inverse_freq", sqrt=True) model.compile( optimizer=tf.keras.optimizers.Adam(1e-3), loss=weighted_categorical_crossentropy(weights, from_logits=True), metrics=["accuracy"], )

模型定义建议最后一层不接 softmax,直接输出 logits,这样配合 from_logits=True 最稳定。如果你需要概率输出做业务判断,可以在预测时再对 logits 过一遍 softmax,或者用 tf.nn.softmax 包一层,不影响训练时的数值稳定。

3. 实操:手写一个多分类加权交叉熵损失

3.1 准备工作:确认环境、API 和标签格式

写代码之前,先把三个关键问题确认清楚,否则后面大概率要返工。第一个问题是你使用的到底是 tf.keras 还是独立 Keras 包。现在新项目我建议直接用 TensorFlow 自带的 tf.keras,API 最稳定,社区资料最多;独立 Keras 虽然在不断迭代,但一些细节差异会干扰你排查问题,没必要在环境层面给自己增加难度。

第二个问题是你的 y_true 标签格式。它是 one-hot 编码矩阵,还是稀疏整数数组?这个决定了你是选择 categorical_crossentropy 还是 sparse_categorical_crossentropy,也决定了加权实现里权重取法的写法,是 tf.reduce_sum(y_true * weights, axis=-1) 还是 tf.gather(weights, y_true)。别看这就是一个 API 的区别,选错之后运行起来报错可能不太直观,但权重其实在错误地工作,问题更隐蔽。

第三个问题是模型最后一层是已经接 softmax,还是输出原始 logits。我通常建议训练阶段不用 softmax 层,直接用 logits 配合 from_logits=True,这样数值最稳定。如果业务上需要概率输出,就在预测时再用 tf.nn.softmax 对 logits 转换一次,对训练没有任何影响。这三个问题确认清楚,你基本就能避免那种“看起来对、一训练就炸”的损失函数了。

3.2 代码实现:从简到繁的版本演进

假设场景是一个 5 分类图像分类模拟项目,输入是 64x64 的 RGB 图,训练集 10000 张,但五类的数量分布严重不均。下面是一段完整可跑的代码骨架:

import numpy as np import tensorflow as tf from tensorflow import keras def weighted_categorical_crossentropy(weights, from_logits=True): def loss(y_true, y_pred): ce = keras.losses.categorical_crossentropy( y_true, y_pred, from_logits=from_logits ) sample_weight = tf.reduce_sum( y_true * tf.constant(weights, tf.float32), axis=-1 ) return ce * sample_weight return loss # 假设 labels 是整数形式的类别索引 labels = np.random.randint(0, 5, size=(10000,)) y_train = keras.utils.to_categorical(labels, num_classes=5) y_val = keras.utils.to_categorical(val_labels, num_classes=5) weights = compute_class_weights(labels, num_classes=5, mode="inverse_freq", sqrt=True) print("class weights:", weights) model = keras.Sequential([ keras.layers.Input(shape=(64, 64, 3)), keras.layers.Conv2D(16, 3, activation="relu", padding="same"), keras.layers.MaxPooling2D(), keras.layers.Conv2D(32, 3, activation="relu", padding="same"), keras.layers.GlobalAveragePooling2D(), keras.layers.Dense(5, name="logits"), # 不加softmax ]) model.compile( optimizer=keras.optimizers.Adam(1e-3), loss=weighted_categorical_crossentropy(weights, from_logits=True), metrics=["accuracy"], ) history = model.fit( x_train, y_train, validation_data=(x_val, y_val), epochs=20, batch_size=64, )

这段代码的每一部分都有它存在的理由。Conv2D 之后接 GlobalAveragePooling2D 是为了把空间特征压缩成向量,避免使用 Flatten 导致参数量爆炸;最后一层 Dense 命名成 logits,是提醒自己和协作者不要在训练时加 softmax。如果你想在验证时输出概率,可以用 model.predict 得到 logits,再用 tf.nn.softmax 转换。

3.3 权重计算与训练集成

权重计算只应该基于训练集。我之前见过有人图省事,直接用全量数据的类别分布计算权重,结果验证时看起来挺漂亮,但上线后完全对不上,原因就是权重里混入了“未来信息”,模型被人为剧透了不该知道的数据分布。这在工程上是一个隐蔽的数据泄漏点,务必避免。

训练过程中我习惯保存权重检查点而不是只关心最后的模型,因为加权交叉熵的收敛过程通常不是单调下降的,中间某个 epoch 的验证指标反而更好。用 ModelCheckpoint 按照验证集上的损失保存最佳模型,会比盲目保存最后一个 epoch 靠谱。

checkpoint = keras.callbacks.ModelCheckpoint( "best_model.h5", monitor="val_loss", save_best_only=True, save_weights_only=True, )

自定义损失函数不会影响模型权重的结构,所以加载权重后可以照常推理。但如果你用的是保存整个模型的方式,需要确保自定义损失函数在加载时可见,否则会报加载失败。最稳妥的做法是保存权重,需要时重新定义同样的模型结构再 load_weights。

验证阶段不要只看全局 accuracy。在类别不平衡情况下,全局 accuracy 对少数类的变化极不敏感。我一般用 sklearn 的 classification_report 输出每类的 precision、recall、f1-score,再根据业务目标决定取舍。

from sklearn.metrics import classification_report y_pred_probs = model.predict(x_test) y_pred = np.argmax(y_pred_probs, axis=-1) print(classification_report(y_test, y_pred, digits=3))

这份报告才是判断加权是否有效的核心依据。如果少数类 recall 提上来了,多数类 recall 下降在可接受范围内,那么加权方案就是有效的。

4. 实战踩坑实录:常见问题与排查技巧

4.1 数值稳定性:softmax 与 log 的相爱相杀

我最早实现自定义交叉熵时,用的是最朴素的公式展开:y_pred = K.softmax(logits),然后 -y_true * K.log(y_pred)。第一次训练到第 7 个 epoch,loss 从 1.2 瞬间跳到 nan,我当时以为是学习率太大,调小了也没用,后来逐行打印才发现少数类的预测概率已经跌到 1e-7 量级,log 之后直接爆掉。

这个坑的本质是:softmax 输出后概率越小,log 的梯度越陡峭,数值越容易溢出。Keras 内置的 categorical_crossentropy 在 from_logits=True 时会把 softmax 和交叉熵合并成一个数值稳定的算子,内部先做 max 减法和 exp 操作,基本不会出现 log(0) 这种极端情况。

所以我的建议非常直接:所有自定义交叉熵都优先用内置 loss 函数,不要自己手动展开 softmax + log。如果你确实需要自己展开,也一定要用 K.clip 兜底,并且意识到这是“保命”而非“方案”。

4.2 标签格式冲突:one-hot 与稀疏整数标签

自定义损失函数最容易翻车的点就在标签格式。使用 categorical_crossentropy 时 y_true 是 one-hot,sample_weight 用 y_true * weights 再求和;使用 sparse_categorical_crossentropy 时 y_true 是整数索引,必须用 tf.gather(weights, y_true)。两种搞混的结果就是运行时报错或者权重取到完全错误的位置。

有一个很容易被忽略的细节:one-hot 标签通常是 float32,稀疏整数标签通常是 int64 或 int32。你在自定义损失里做类型转换时,不要盲目 cast 成 float,否则 tf.gather 会报错;而 one-hot 如果 cast 成 int,后面的乘法也可能出问题。我一般会在损失函数开头根据标签格式做一次显式处理。

def loss(y_true, y_pred): y_true = tf.cast(y_true, tf.int32) # 稀疏标签 # 或者 y_true = tf.cast(y_true, tf.float32) # one-hot

这样能提前避免很多 dtype 不匹配的报错,排查起来也更省心。

4.3 class_weight 与自定义损失重复加权

这个坑出现得非常隐蔽。有位朋友在自定义损失里加了类别权重,又觉得效果不够明显,顺手在 model.fit 里加了 class_weight,结果少数类反而更差了。因为 Keras 对自定义损失返回的 per-sample loss 再乘一次 class_weight,权重变成了平方级,训练目标被扭曲了。

排查方法很简单:出现异常时,先检查损失函数内部是否已经加权,再看 fit 有没有传 class_weight,两者只保留一个。我的经验是优先用自定义损失函数,因为权重计算可以被自动化,而且权重信息随损失定义块一起存在代码里,可复现性更好。class_weight 更适合快速验证,但别和自定义加权混用。

4.4 权重极端值导致的训练不稳定

逆频率权重在某些极度不平衡的数据集里会很夸张。比如某类样本占比 0.1%,权重算出来可能是 1000 甚至更高。这么极端的权重会让少数类样本的梯度主导每一次更新,模型可能为了迁就少数类而把大量多数类样本分错,造成验证集整体性能大幅波动。

我常用的三种压制手段分别是:开根号、按均值归一化、设置权重上限。开根号最简单也最常用,比如权重 100 压到 10,保留了类别间相对差异但没那么激进;归一化主要解决整体损失的尺度偏移问题;权重上限则适合你明确知道某些类别不能给太高权重的时候。实际项目中,我一般先用开根号的逆频率权重,再看各类别 recall 的变化趋势决定下一步调整。

5. 进阶:权重策略优化与替代方案

5.1 权重计算公式的选择:逆频率、开根号与中位频率平衡

有经验的读者应该会发现,权重怎么算其实比“在哪实现”更重要。逆频率公式是 w_i = N / (C * n_i),它让所有类别对损失的贡献大致均衡,但它在极端不平衡时给的权重太大。所以实战里我很少直接用完整逆频率,而是先开根号再看效果。

为了直观对比,这里放一个模拟结果。假设三类样本数是 9000、900、100,N = 10000,C = 3:

策略类别0权重类别1权重类别2权重
不加权111
逆频率0.373.7033.33
开根号逆频率0.611.925.77
中位频率1.001.009.09

可以看到,完整逆频率给少数类的权重高达 33,这会让单个样本的梯度变化非常剧烈;开根号之后变成 5.77,温和很多;中位频率的策略是让低于中位数频率的类别整体获得 1 倍权重,高于中位数的一半,也相对平稳。具体选哪个,建议在验证集上做小规模对比试验,而不是拍脑袋。

再从经验角度补充一个点:权重不是只能设置一次。训练过程中可以用验证集反馈去迭代权重,比如发现类别 A 提升了但类别 B 反而掉,就手动给 A 减少一点权重、给 B 增加一点。这种半手动调参比起网格搜索,既省时间又能结合模型行为判断,是比较实用的工程手段。

5.2 当加权交叉熵不够用的时候:focal loss 的引入

在某些正负样本比例极端的情况下,加权交叉熵会暴露出一个不足:它只改变了不同类别的相对重要性,并没有改变模型对所有样本难易程度的感知。一个多数类里特别难分的样本,和一个少数类里特别简单的样本,加权交叉熵并不能很好地分辨处理。focal loss 给每个样本的损失乘了 (1-p)^γ,让模型把注意力集中在那些预测置信度低的样本上,而不是被大量“已经学会”的样本牵着走。

focal loss 也可以与类别权重结合,公式通常是:

L = -∑ w_i * (1-p_i)^γ * y_i * log(p_i)

这里 γ 控制对难分样本的强调程度。它的实现其实就是在加权交叉熵基础上,把每个样本的 loss 再乘一个调制因子。如果你对类别不平衡的理解已经到“加权都拉不动”的程度,focal loss 值得研究,但要注意它多了一个需要调的 γ 超参,调试成本会上升。我的原则是先用加权交叉熵,确认它有效后再考虑更复杂的方案。

6. 一点实操体会

讲了这么多,最后说一点自己的习惯。我每次做加权实验都会先跑一版不加权的 baseline,记录每类的 precision、recall、f1,然后再加上权重,对比每类的变化。不要只盯全局指标,因为加权交叉熵的目标从来不是让整体准确率最大化,而是让少数类得到更合理的表现。我见过不少案例,加权后多数类准确率掉了一两成,少数类 recall 从 10% 提到 70%,这在很多真实业务里其实是更划算的交易。权重怎么设,最终要回到业务里“哪些类更重要”这个根本问题。

另一个小建议是,把损失函数定义、权重计算方法和初始化代码放进同一个模块,写清楚注释。时间一长,别说别人看不懂,你自己都有可能忘记当时的权重策略是怎么算出来的。保存模型时同样建议用 save_weights_only,把损失函数定义留在代码里,这样复现成本最低,排查问题也最方便。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询