news 2026/10/14 6:33:14

加权交叉熵在多分类不平衡数据中的Keras实现与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
加权交叉熵在多分类不平衡数据中的Keras实现与实战

做过多分类项目的人,多半都吃过类别不平衡的亏。数据里A类占了90%,B类占了9%,C类只占1%,模型训完一看整体准确率96%,再一看混淆矩阵,C类几乎全军覆没。这种时候你能想到的最直接方案,就是给损失函数里的每个类别加上权重,也就是标题里说的“加权交叉熵”。这篇文章我直接用Keras给你拆解多分类场景下加权交叉熵的实现,包括底层原理、权重计算方法、完整代码和踩坑记录,适合已经跑通基础分类模型、正在被不平衡数据折磨的开发者参考。

1. 为什么多分类模型需要加权交叉熵

1.1 问题根源:普通交叉熵在数据分布倾斜时的表现

先看一个非常典型的场景。假设你有一个三分类任务,训练集里类别A有5000张图,类别B有500张,类别C只有50张。如果用普通的categorical crossentropy训练,模型在反向传播时,每个样本对梯度的贡献是相等的。这意味着模型每看到10个类别A的样本,才看到1个类别C的样本,梯度平均下来自然被A类主导。

模型的最优策略就变成了“无脑预测A类”:因为只要预测A,90%的情况下都能对。对交叉熵损失来说,A类的大量样本把损失压得很低,C类的少量样本即使全错,在整体损失里也只占很小比例。最终训练出来的模型大概率会把几乎所有样本都判成A类,整体准确率还挺好看,但业务上完全用不了。

我自己的经验是,判断一个模型有没有被类别不平衡带偏,千万别只看准确率。一定要跑一遍混淆矩阵,或者看每一类的precision、recall、F1。只要少数类的recall明显低于多数类,基本就是不平衡的锅。

1.2 加权交叉熵如何改变梯度方向

加权交叉熵做的事情很简单:在计算每个样本的交叉熵损失时,根据该样本真实类别的权重进行缩放。比如C类的权重设为50,那么每遇到一个C类样本,它的损失等效于50个普通样本的损失。这样模型在梯度更新时,对C类的错误预测会产生足够大的惩罚,迫使模型在类别C上多花“注意力”。

从梯度的角度理解更直接。普通softmax交叉熵对某个样本的错误预测产生的梯度大小,和该样本损失本身的梯度方向是正相关的。加权相当于给每个样本的梯度乘了一个缩放系数:少数类的梯度被放大,多数类的梯度被压缩。于是决策边界会主动向多数类方向收缩,给少数类腾出更大的预测空间。这不是玄学,就是纯粹的数值调节。

1.3 加权交叉熵的适用范围

加权重不是银弹。它最适合的场景是:类别分布天然不平衡,且你已经有足够的理由相信测试集也保持类似的不平衡比例,或者业务上对少数类的识别有硬性要求。比如瑕疵检测、罕见病诊断、异常行为识别,都属于这类。

反过来,如果你的少数类样本量实在太少,比如只有个位数样本,单纯加权重很容易让模型在少数类上过拟合。这种情况更适合先去扩充数据、做数据增强,或者尝试用两阶段训练、迁移学习。加权交叉熵的目标是“让模型更公平地对待每个类别”,不是“让模型在数据不足的情况下硬学”。

另外要注意,加权交叉熵本质上是调整了损失函数,但模型最终输出的仍然是softmax概率。这个概率的含义会因为加权而变得“不校准”,简单说就是模型输出0.8不代表真实概率是0.8。如果后续需要阈值判决,建议在验证集上重新搜索阈值,而不是直接拿0.5做截断。

2. 先搞清楚Keras里交叉熵的几种形态

2.1 categorical与sparse两种标签接口

在动手写加权交叉熵之前,必须先搞清楚Keras里两个最常用的交叉熵损失分别对应什么标签格式。

categorical_crossentropy要求标签是one-hot编码。形状是(N, num_classes),比如类别索引是2,它的one-hot向量就是[0,0,1,0,...]。这也是传统意义上“多分类交叉熵”的标准输入格式。

sparse_categorical_crossentropy要求标签是整数索引。形状是(N,),比如[0, 1, 2, 1, 2, 0...]。它内部会先做one-hot转换再计算交叉熵,相当于一个语法糖,省去了手动转one-hot的步骤,也省了内存。

这两种形式在加权时的实现细节不一样,但思路完全相同:每个样本根据真实标签取一个权重值,乘到该样本的普通交叉熵上。下面第4节我会把两种成品代码都给你。

2.2 交叉熵的数学表达与Keras内部实现

多分类交叉熵的单样本公式长这样:

loss = -Σ y_i * log(p_i)

其中y是one-hot标签向量,p是softmax输出的概率向量。因为y只有一个位置是1,其余是0,所以这个公式实际等价于:

loss = -log(p_c)

也就是“真实类别对应的那个输出概率”的对数的负值。如果模型对正确类别输出概率越高,loss越小;输出越低,loss越大。

在TensorFlow / Keras的底层实现里,如果用from_logits=False,它会对传入的预测概率做一个clip操作,防止出现log(0)导致NaN。但注意这个clip的阈值通常很小(比如f32的epsilon,约1e-7),如果模型的softmax输出非常极端,梯度依然可能不稳定。最稳妥的做法是输出层不用softmax激活,直接让损失函数用from_logits=True,也就是在损失函数内部完成softmax和log的组合计算,数值稳定性好很多。

2.3 加权公式的三种等价写法

给定每个类别的权重w_c,某个样本的真实类别索引是c,那么加权后的该样本损失可以写成下面三种等价形式中的任意一种:

  1. 先算交叉熵,再乘权重:loss = -w_c * log(p_c)
  2. 先乘权重再取log:loss = -log(p_c^w_c),数学上等价但实际很少这么写
  3. 用one-hot向量和权重向量做内积:weight = Σ w_i * y_i,然后loss = weight * (-log(p_c))

实战中最推荐第3种写法,因为可以直接利用one-hot标签向量和tf.reduce_sum操作,不需要把真实类别单独取出来再做tf.gather。代码简洁,也不会在构造索引的时候出错。

3. 权重怎么算:从统计频率到业务代价

3.1 最常用的频率反比权重

权重计算最基础的方法是频率反比。假设每个类别的样本数分别是n_1, n_2, ..., n_C,那么类别c的初始权重可以设为:

w_c = N_total / (C * n_c)

其中N_total是所有类别样本总数,C是类别数。这个公式保证了所有类别权重的总和等于C,也就是说,在不平衡的数据集里,少数类获得一个比较大的权重,多数类获得一个小于1的权重,整体梯度的量级和普通交叉熵大致相当。

举个例子,上一节那个5000/500/50的数据分布,总样本数N_total = 5550,C = 3:

  • w_A = 5550 / (3 * 5000) ≈ 0.37
  • w_B = 5550 / (3 * 500) ≈ 3.70
  • w_C = 5550 / (3 * 50) ≈ 37.0

这个权重比例就是“模型每看到一个C类样本,就相当于看到37个A类样本”,梯度方向立刻被C类拉回来了。

3.2 中位数归一化与log平滑

频率反比权重有时候会让权重差异太大,比如上面那个例子里A和C差100倍,容易导致训练初期损失剧烈震荡。这时候可以改用中位数归一化:

w_c = n_median / n_c

也就是用所有类别样本数量的中位数作为基准,每个类别除以自己的样本数。这个方法不需要考虑类别总数C,权重的绝对数值也更温和。

还有一种更平滑的做法是log比例权重:

w_c = log(N_total / n_c)

取了对数之后,权重之间的差距被压缩。比如N_total/n_c=100时,普通频率反比是100倍差距,log之后只有4.6倍。这种方案在类别数特别多、样本分布特别极端的时候最好用,损失曲线也会稳很多。

3.3 让业务成本参与权重设计

有时候权重不应该只由数据频率决定,还要看错误的代价。比如银行欺诈识别里,漏掉一笔欺诈交易的代价可能是正常交易误判的几十倍。这时候可以设计一个代价矩阵cost_matrix,其中cost[i][j]表示真实类别是i、被预测成j的代价,然后每个类别的权重可以设定为:

w_c = 该类的漏报代价 + 该类的误报代价 * 经验系数

理解起来也很直观:如果一个类别搞错一次的代价是另一个类别的10倍,那它在损失函数里占的分量也应该近似是10倍。权重不是纯数学问题,本质上是你对“哪类错误更不可接受”的表达。

我自己在做一个某图像分类Demo时,就是先按频率反比算了一遍,再根据需求把多数类的权重手动调低了20%。这是因为业务上更关注少数类的召回,愿意牺牲一点多数类的精确率。加权交叉熵给了你这个调节开关,不要只会套公式,要会用公式。

4. Keras自定义加权交叉熵的完整实现

4.1 one-hot版:标准加权交叉熵损失函数

直接上一个可以用的代码。假如你的模型输出层是Dense(num_classes, activation='softmax'),标签是one-hot编码,那么自定义损失函数这样写:

import tensorflow as tf def weighted_categorical_crossentropy(class_weights): class_weights = tf.constant(class_weights, dtype=tf.float32) def loss(y_true, y_pred): # 防止log(0)带来的NaN y_pred = tf.clip_by_value( y_pred, tf.keras.backend.epsilon(), 1.0 - tf.keras.backend.epsilon() ) # 逐样本普通交叉熵 ce = -tf.reduce_sum(y_true * tf.math.log(y_pred), axis=-1) # 根据one-hot标签取出每个样本对应类别的权重 sample_weights = tf.reduce_sum(class_weights * y_true, axis=-1) return tf.reduce_mean(ce * sample_weights) return loss

使用方式很简单:

model.compile( optimizer='adam', loss=weighted_categorical_crossentropy([0.37, 3.70, 37.0]), metrics=['accuracy'] )

这个闭包结构有一个好处:class_weights在外面定义好,直接嵌入损失函数,训练、验证、推理走的都是同一个loss逻辑,不会出现“训练用了加权、验证忘了加权”的尴尬。

4.2 sparse版:整数标签也能直接加权

如果你的标签是整数索引,也就是平时用sparse_categorical_crossentropy的那种格式,用下面这个版本:

def weighted_sparse_categorical_crossentropy(class_weights): class_weights = tf.constant(class_weights, dtype=tf.float32) def loss(y_true, y_pred): # y_true: (None,) # 先把整数标签转成索引类型,方便后面tf.gather y_true = tf.cast(y_true, tf.int32) ce = tf.keras.losses.sparse_categorical_crossentropy(y_true, y_pred) # 按真实类别索引取出对应权重 sample_weights = tf.gather(class_weights, y_true) return tf.reduce_mean(ce * sample_weights) return loss

这个版本你用着会感觉更贴近sparse接口,省掉了一次one-hot的显存开销。本质上和one-hot版本没有任何区别,只是取权重的方式从“one-hot内积”换成了“索引查找”。

4.3 from_logits版本:数值稳定性最好的写法

上面两个版本用的是softmax输出概率,虽然做了clip防NaN,但梯度数值仍然不如from_logits稳定。更好的做法是输出层不加softmax,直接输出logits,然后在损失函数里用log_softmax:

def weighted_categorical_crossentropy_from_logits(class_weights): class_weights = tf.constant(class_weights, dtype=tf.float32) def loss(y_true, y_pred): # y_pred: logits, shape (None, num_classes) log_probs = tf.nn.log_softmax(y_pred, axis=-1) ce = -tf.reduce_sum(y_true * log_probs, axis=-1) sample_weights = tf.reduce_sum(class_weights * y_true, axis=-1) return tf.reduce_mean(ce * sample_weights) return loss

使用时的模型结构里,最后一层改成:

model.add(tf.keras.layers.Dense(num_classes)) # 没有softmax激活

对比下来,这个版本在训练深网络、batch比较小、学习率比较大的时候,几乎不会出现NaN或者梯度爆炸的怪问题,损失的数值也更平滑。我常规操作都是直接上from_logits版本。

4.4 和fit自带class_weight的区别与选择

可能有人会问:Keras的fit方法里不是自带class_weight参数吗?干嘛还要自己写损失函数?

确实,model.fit(x, y, class_weight={0: 0.37, 1: 3.7, 2: 37.0})也能实现类似的效果。fit的class_weight本质上是在每个batch计算loss之后,根据样本所属类别对损失做缩放,原理上和自定义加权交叉熵一致。

但有几个场景,fit的class_weight不够用:

  1. 分布式训练或自定义训练循环(GradientTape手写)的时候,class_weight很难直接生效。
  2. 你想控制加权的粒度,比如对每个样本单独设权重,而class_weight只支持按类别统一设权重。
  3. 你想把加权逻辑和损失值可视化、或者作为模型结构的一部分导出,用自定义损失函数更直观。
  4. 某些时候你希望验证集使用未加权loss、训练集使用加权loss,自定义损失函数可以灵活控制调用时机。

如果只是标准的model.fit流程,那我建议直接用class_weight,少写代码少出错。但如果你需要上面的灵活性,或者想在论文/实验里明确记录loss的定义,自定义损失函数是比class_weight更靠谱的方式。

还有一点必须提醒:class_weight和自定义损失不要同时用。两个机制都生效,损失会被二次加权,梯度放大,很容易把模型训坏。我在第一次使用加权交叉熵的时候就犯过这个错,怎么调学习率都不收敛,最后发现是两个加权叠加了。

5. 训练、评估与调参的实战要点

5.1 损失曲线的形态变化与预期

换上加权交叉熵之后,loss的绝对值会明显变大。比如不加权时loss可能在0.5左右,加权后变成3.0甚至更高。这完全正常,不要试图让加权后的loss数值和不加权的loss对齐。

加权后的loss曲线和普通交叉熵的曲线形态也不同:前几个epoch可能会降得很快,然后进入一个平台期,再缓慢下降。这是因为少数类样本的权重巨大,初期“纠正”少数类错误的收益非常显著,后面进入细节调整阶段收益就变小了。如果你看到loss曲线出现剧烈震荡,通常说明权重设置过高,或者学习率偏大,优先降低学习率试试。

监控训练时要额外关注每一类的recall变化。只盯整体loss看不出加权效果,要按类别输出指标。我自己习惯在callback里加一个自定义回调,每个epoch结束打印混淆矩阵或各类F1,这样能直观看到少数类的改善情况。

5.2 验证集和测试集到底该用什么loss

这是个容易被忽略的问题。加权交叉熵会影响模型对某一类的偏向,如果用加权的loss来做模型选择(early stopping / checkpoint保存),选出来的模型是在加权目标下最优的模型。这通常是你想要的:因为你本来就更看重少数类的性能。

但也要注意,如果你最终汇报给业务的数字是不加权的平均准确率,而模型是在加权loss下训练出来的,那么最终模型的整体准确率大概率会低于不加权训练的模型。这是加权策略的固有取舍。我的建议是:业务指标怎么考核,就用什么指标来做模型选择。如果考核指标是“整体准确率优先”,那加权就不该成为主力手段;如果考核指标里有各类别的F1、召回率,加权就是非常对症的方案。

另外,验证集的loss如果也用加权版本,最好保持每个验证batch的类别分布稳定,不然不同batch的loss绝对值会因为类别权重分布不同而出现波动,影响early stopping的判断。

5.3 加权和阈值调整怎么配合

加权交叉熵调整的是训练阶段的决策边界,阈值调整调整的是预测阶段的截断点。两者可以配合,而且是越配合效果越好。

模型训练完之后,对验证集每个类别单独画PR曲线,给每个类别选一个合适的置信度阈值。比如类别C的召回率要求90%,那就把类别C的预测阈值从默认的0.5降到0.2或者0.3,只要能保住precision不崩就行。加权loss先改变模型内部的概率分布,阈值再把这些概率转化为最终业务决策,两者角度不同,但有叠加效果。

我见过不少项目,训练时把权重调得特别猛,导致模型在少数类上过拟合,验证集表现反而不如中等权重。这时候如果再把阈值做一下微调,往往能达到同样的召回率目标,但precision更高。所以权重不是越大越好,要在合理区间内配合其他手段一起用。

5.4 多分类下的weights设置细节

类别数很多的时候,加权交叉熵有一些额外坑。

第一,权重的总和会影响梯度量级。如果直接用频率反比且类别数量很多,权重总和不再是C,梯度绝对值会偏大。建议在权重计算完成后做一次归一化,让所有权重平均值为1:

weights = np.array([0.37, 3.70, 37.0]) weights = weights / weights.mean()

这样加权后的loss量级和普通交叉熵基本一致,不用专门调整学习率。

第二,类别多的时候,少数类可能不止一个,而是一批尾部类。对这些尾部类统一设一个较高权重是可以的,但不要全部设成相同权重。比如有5个少数类,它们各自的样本量也可能差10倍。这时用log平滑权重公式往往比频率反比更稳。

第三,one-hot和sparse两种格式在权重的内部运算上略有性能差异。one-hot版本因为要算reduce_sum(class_weights * y_true),会多一次乘法;sparse版本用tf.gather,在大类别数(比如1000类)时代价更低。如果类别数超过100,建议无脑sparse版本。

6. 常见问题与避坑记录

6.1 加权后loss出现NaN

原因一般是两类:一类是log(0),概率预测到0导致负无穷;另一类是梯度爆炸,权重过大导致loss过大,然后梯度直接飞了。

log(0)的问题,用from_logits版本基本就消失了。如果是已经用了softmax输出,必须加clip,但clip也有上限,假如权重巨大比如几千,依然可能数值溢出。所以更根本的解法是权重归一化,把平均权重拉到1附近。

梯度爆炸则表现为loss在某个epoch突然变成NaN,或者快速增大到几千。遇到这种情况,先降学习率,第二步检查权重是否过大,第三步看看batch里是否存在极端类别样本。

6.2 加权后少数类反而变差了

说实话有时候加权重会让少数类训练效果变差,因为模型为了迎合少数类,把很多多数类样本也错判成少数类,而少数类自己的特征又没有学够。这种情况多数是因为少数类样本量太小,权重给得再大也是“巧妇难为无米之炊”。

排查思路是:先把少数类样本数量可视化,如果某个类只有几十个样本,加权靠不住,建议先做数据增强、半监督拟标签、或者用迁移学习初始化。

6.3 要不要在采样和加权之间二选一

我见过一些团队会用过采样复制少数类样本,或者欠采样删掉多数类样本,然后不加任何损失权重。这也能解决不平衡,但各有各的问题。过采样容易让模型在少数类上过拟合,尤其当复制比例高时;欠采样则会浪费大量多数类数据。

加权交叉熵和采样并不冲突。实际项目中我常用的组合是:轻度过采样少数类(比如把类别分布拉到一个不那么悬殊的比例,比如从100:1拉到20:1),再用加权交叉熵做剩余不平衡的微调。这样梯度信号比单靠一种手段更丰富,模型泛化也更好。

6.4 一个可直接参考的完整配置示例

最后把整个流程串起来,给你一个可以直接抄作业的模板代码。假设数据是(x_train, y_train),y_train是整数索引标签,类别分布为5000/500/50:

import numpy as np import tensorflow as tf from collections import Counter # 1. 统计类别权重 labels, counts = np.unique(y_train, return_counts=True) N_total = len(y_train) C = len(labels) class_weights = N_total / (C * counts) # 归一化,让平均权重为1 class_weights = class_weights / class_weights.mean() # 2. 自定义损失函数 def weighted_sparse_ce(weights): weights = tf.constant(weights, dtype=tf.float32) def loss(y_true, y_pred): y_true = tf.cast(y_true, tf.int32) ce = tf.keras.losses.sparse_categorical_crossentropy(y_true, y_pred, from_logits=False) sample_weights = tf.gather(weights, y_true) return tf.reduce_mean(ce * sample_weights) return loss # 3. 编译模型 model.compile( optimizer=tf.keras.optimizers.Adam(1e-3), loss=weighted_sparse_ce(class_weights), metrics=['accuracy'] ) # 4. 再配一个按类F1的自定义metric def macro_f1(y_true, y_pred): y_true = tf.cast(y_true, tf.int32) y_pred = tf.argmax(y_pred, axis=-1) conf = tf.math.confusion_matrix(y_true, y_pred, num_classes=C) precision = tf.linalg.diag_part(conf) / (tf.reduce_sum(conf, axis=0) + 1e-9) recall = tf.linalg.diag_part(conf) / (tf.reduce_sum(conf, axis=1) + 1e-9) f1 = 2 * precision * recall / (precision + recall + 1e-9) return tf.reduce_mean(f1) model.fit( x_train, y_train, validation_data=(x_val, y_val), epochs=30, batch_size=64, callbacks=[tf.keras.callbacks.EarlyStopping(monitor='val_macro_f1', mode='max', patience=5)] )

这段代码基本涵盖了本节所有要点:权重计算、归一化、sparse加权loss、按F1做early stopping。直接改类别分布和自己的模型结构就能跑起来。

我自己做多分类加权的经验是:加权交叉熵是一个成本很低、效果很直接的调试手段,但它需要你明确知道自己在优化什么指标。如果你关心的是整体准确率,加权的意义不大;如果你关心的是少数类的召回率和各类别均衡性,加权几乎是必选方案。动手之前先统计类别分布,动手之后盯着每一类的指标变化,别只盯loss曲线。项目跑完再看一看验证集上的混淆矩阵,如果你的少数类终于开始能被识别出来了,那这手加权就没白做。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 6:33:14

基于风光储和需求响应的微电网日前经济调度Matlab实现

做微电网调度的都知道,风光一多,电网就变得特别难伺候。白天光伏出力大,风机也转得欢,电多到用不完;到了傍晚,光伏骤降,晚高峰说来就来,一高一低的落差全靠储能和可控机组去顶。如果…

作者头像 李华
网站建设 2026/10/14 6:33:13

CSDN博客-2-FDE工作方法论-三阶段双团队与两层蒸馏

FDE 怎么干活?三阶段工作法 双团队模型 两层蒸馏,一套完整方法论面试官问"FDE 的方法论是什么",90% 的候选人只会背 Audit、Evaluate、Deploy 三个词。真正拿 Offer 的人,讲的是为什么这么做。 这是 FDE 系列第二篇&a…

作者头像 李华
网站建设 2026/10/14 6:33:00

游戏引擎基础系统与内存管理:从初始化顺序到分配器实战

开了这么多年引擎,我一直觉得有个很有意思的现象:很多朋友聊起游戏引擎,张口就是渲染管线、全局光照、物理模拟,但真正在工程上把项目搞垮的,往往不是这些花哨系统,而是最不起眼的基础系统和内存管理。我在…

作者头像 李华
网站建设 2026/10/14 6:33:00

从零实现自定义QAbstractItemModel:Qt模型视图架构原理与实战

在 Qt 的日常开发里,很多人一开始接触的都是 QListWidget、QTableWidget 这些现成控件,拖进来就能用,数据往里面一塞就完事。可只要数据量一上来,或者数据结构稍微复杂一点——比如要展示一棵目录树、要在同一个视图里混合不同类型…

作者头像 李华
网站建设 2026/10/14 6:32:54

Qt QAbstractItemModel 自定义模型实战:树形视图与性能优化

做 Qt 开发到现在,我自定义过至少三四个 QAbstractItemModel 的子类。要说哪个类是 Qt 框架里最值得花时间研究、却又最容易被绕开的,我第一个提名 QAbstractItemModel。它是整个 model/view 架构的地基,但官方文档给出来的示例大多停留在列表…

作者头像 李华
网站建设 2026/10/14 6:32:47

Java接口从契约到实战:多态、默认方法与抽象类完全解析

1. 接口到底解决了什么问题——先搞清楚为什么要有它很多人学 Java 接口的时候,第一反应就是:"这不就是个特殊的抽象类吗?" 这么理解不算错,但很容易把接口学窄了。我在实际项目里见过不少同事,语法全会&…

作者头像 李华