1. 为什么这个损失函数老是被用错
BCEWithLogitsLoss在多标签分类任务里被误用的频率,比很多人想象的要高。它位于 PyTorch 的torch.nn里,名字已经说得很清楚:Binary Cross EntropyWith Logits。但实际项目里,我见过大量代码写成loss = nn.BCEWithLogitsLoss()(torch.sigmoid(logits), labels),也见过标签是LongTensor直接崩在 shape 校验上,更见过 logits 是[B, C]、标签是[B, 1],程序一路跑完、loss 曲线平滑下降、指标纹丝不动。多标签分类这四个字背后是"每个类别独立做一次二分类判断",而BCEWithLogitsLoss恰恰是最贴合的损失函数,它的输入要求只有一个核心约束:logits 与标签形状严格一致,且标签必须是浮点型。这个约束看起来简单,但踩过的人都知道,PyTorch 的广播机制会在某些形状组合下默默替你"修正"数据,让你在错误的路上一路狂奔几个 epoch。
这篇内容写给三类人:刚接触多标签任务、把CrossEntropyLoss的思路直接平移过来结果撞墙的新手;模型能跑但指标死活上不去、怀疑损失函数写错了的中级选手;以及需要在类别极度不平衡、标签带软值(soft label)、需要自定义阈值这些复杂场景下把损失函数用准的老手。我会把 logits 与标签的形状契约、内部数值实现、pos_weight的计算逻辑、阈值后处理、以及那些只会在真实训练日志里暴露的静默错误,一条条拆开讲清楚,代码全部可以直接抄走。
1.1 从一次"loss 在降但模型没学"的排查说起
几年前我接手一个多标签图像打标的任务,八类标签,每张图可能同时命中多个。训练脚本交接过来时 loss 从 0.69 平稳降到 0.31,看起来非常健康,但验证集的 mAP 一直卡在 0.12 上下,几乎等于随机。我第一反应是学习率、数据增强、模型容量,折腾了大半天没结果,最后打印了logits.shape和labels.shape,发现前者是[32, 8],后者是[32, 1]——标注流水线里为了兼容单标签任务,把多热向量压成了一列索引,标签处理脚本又做了一次unsqueeze,最终形状变成[32, 1]的浮点数。
PyTorch 没有报错。[32, 8]和[32, 1]广播成[32, 8],标签那一列被复制了八次,模型学到的是"八个类别共享同一个标签"。loss 当然会降,因为它确实在拟合一个可解的问题,只不过这个问题不是我们要的那个问题。这个案例之后,我在所有多标签训练脚本里加了第一行断言:
assert logits.shape == labels.shape, f"{logits.shape} vs {labels.shape}" assert labels.dtype == logits.dtype, f"{labels.dtype} vs {logits.dtype}"两行代码,救过我至少三次。多标签分类任务里最贵的 bug 从来不是报错,而是静默广播——它不会打断你的训练,只会安静地浪费你的算力和时间。
1.2 多标签与多分类,差的不只是一个损失函数
CrossEntropyLoss服务的是互斥分类:一张图是猫就不能同时是狗,概率在类别维上做 softmax,总和为 1。BCEWithLogitsLoss服务的是独立二分类:一张图可以同时有"人""车""树",每个类别单独问一句"有没有",各类别之间互不干扰。这个语义差异直接决定了输出层的激活函数:多分类用 softmax,多标签用 sigmoid,且是逐元素的 sigmoid,不是沿类别维的归一化。
很多人从多分类迁移过来时,会下意识地在模型最后一层加nn.Softmax(dim=1),然后再接BCEWithLogitsLoss。这个组合的后果是:每个样本的八类概率被强行压缩成和为 1 的分布,模型失去了"同时输出多个高置信度标签"的能力,loss 能算、梯度也有,但表达力被砍掉了一大截。正确的做法是模型只输出裸 logits,激活和损失全部交给BCEWithLogitsLoss内部完成,推理阶段再单独调用torch.sigmoid转概率。
顺便提一句数值稳定性,这也是With Logits这个后缀的全部意义。标准 BCE 是 $-[y\log\hat{y} + (1-y)\log(1-\hat{y})]$,其中 $\hat{y} = \sigma(x)$。当 $x$ 是很大的负数时,$\sigma(x)$ 下溢到 0,log(0)直接变成-inf,反向传播就是 NaN。PyTorch 的实现改用log_sigmoid的稳定形式:
$$ \mathcal{L} = -\left[, y \cdot \text{log_sigmoid}(x) + (1-y) \cdot \text{log_sigmoid}(-x) ,\right] $$
log_sigmoid(x)内部走的是 $-\log(1+e^{-x})$ 的数值稳定分支,大正大负都不会溢出。所以绝对不要在传入前自己算 sigmoid,那等于把这个稳定性设计原样扔掉,还额外叠了一层饱和,梯度会更小。
1.3 一次 sigmoid 还是两次,梯度会告诉你答案
nn.BCEWithLogitsLoss()和nn.BCELoss()的区别,本质就是"内部是否已包含 sigmoid"。写成BCELoss()(torch.sigmoid(logits), y)在数学上等价于BCEWithLogitsLoss()(logits, y),但前者在logits绝对值较大时会出现梯度消失甚至 NaN,后者不会。我做过一个很小的对照实验:让 logits 取 ±20 这种极端值,BCELoss路线 loss 直接报nan,BCEWithLogitsLoss路线返回有限的 20 左右的 loss,梯度正常。
下面这张表是我整理的两者差异,建议贴在自己的笔记里:
| 对比项 | nn.BCELoss | nn.BCEWithLogitsLoss |
|---|---|---|
| 输入要求 | 已经过 sigmoid 的概率,取值 (0,1) | 未激活的裸 logits,任意实数 |
| 数值稳定性 | 差,极端值易nan | 好,内部用log_sigmoid稳定实现 |
是否支持pos_weight | 支持 | 支持,且与稳定实现结合更好 |
| 推荐场景 | 几乎不推荐 | 多标签、多任务二分类的默认选择 |
| 推理时 | 无需额外激活 | 必须补torch.sigmoid |
一个务实的判断标准:如果你在代码里看到sigmoid出现在损失函数参数位置附近,基本可以判定写错了。正确的位置永远是推理阶段、计算指标之前。
2. logits 与标签的形状约定:最容易踩坑的地方
形状问题之所以难缠,是因为 PyTorch 的广播规则在大多数张量运算里是优点,在损失函数里却成了隐患。BCEWithLogitsLoss的官方文档写得很克制:"input 和 target 应具有相同形状",但如果形状不同且可广播,它不会主动阻止你。这就形成了一个灰色地带:一部分形状错误报错,一部分形状错误静默通过,还有一部分形状错误只在特定 batch size 下才会暴露。
2.1 标准形状 [N, C] 和它的两个合法变体
多标签分类的标准形状是[N, C],N是 batch size,C是类别数。logits 是模型直接输出的实数张量,标签是同形状的 0/1 浮点张量。所有其他形状都应该被视作可疑信号,直到你能说清楚它为什么是那样。
合法变体一是C = 1的单标签二分类,形状[N, 1]。这种情况下 logits 和标签都是[N, 1],一切正常。但要注意别写成[N],[N]和[N, 1]在一起会广播成[N, N],那是一个灾难性的矩阵,loss 值看起来还挺正常,但梯度已经全乱了。
合法变体二是高维输入,比如逐像素分割式的多标签任务,形状[N, C, H, W]。BCEWithLogitsLoss对任意形状都支持,逐元素计算后按reduction聚合,所以形状一致性要求同样适用。我在做多标签图像分割时就是直接用[B, C, H, W]的 logits 和同形状的 mask,不需要 reshape 成二维。
至于[N, C, 1]这种"多出来一维"的形状,绝大多数情况是模型结构问题。常见的来源有两个:nn.Conv2d输出没有 flatten,或者在全连接层后面手贱加了一个unsqueeze(-1)。这种形状如果标签是[N, C],会触发广播,[N, C, 1]和[N, C]对齐后变成[N, C, C],loss 数值翻了好几倍,你还会以为是学习率设置问题。我的习惯是在损失计算前统一做一次logits = logits.view(logits.size(0), -1),把除了 batch 维之外的所有维度压平成类别维,代价几乎为零,收益是形状永远可控。
2.2 广播导致的静默错误:三种典型组合
我把实际遇到过的错误形状组合整理成了一张表,按危险程度排序:
| logits 形状 | 标签形状 | 实际发生 | 危险等级 |
|---|---|---|---|
[B, C] | [B, C] | 正确计算 | 安全 |
[B, C] | [B, 1] | 标签沿类别维复制 C 次 | 极高,静默 |
[B, C] | [B] | 视 B、C 关系报错或错误广播 | 极高 |
[B, C] | [C] | 每个 batch 共用同一行标签 | 极高,静默 |
[B, C] | [B, C, 1] | 广播出[B, C, C] | 高,loss 异常大 |
[B, C, 1] | [B, C] | 广播出[B, C, C] | 高,loss 异常大 |
[B] | [B] | 单标签二分类,合法 | 安全 |
第二行[B, 1]是最阴险的,因为它在数学上"讲得通"——每个样本一个标签值,广播到所有类别,等于告诉模型这八类共享同一个答案。训练会收敛,只是收敛到一个错误的目标。第四行[C]更隐蔽,在多卡训练或自定义 sampler 里偶尔出现,效果是每个 batch 都用同一套标签,模型学到的是一组与输入无关的常数先验。
一个实操层面的检测手段:训练开始后的前几个 step,打印labels.mean(dim=0)(每类的正样本比例)和logits.mean(dim=0)。如果所有类别的统计量完全相同,几乎可以断定是标签被广播复制了。这个方法比看 loss 曲线快得多。
2.3 dtype 也必须对齐,LongTensor是头号公敌
形状对了,dtype 错了,照样崩。BCEWithLogitsLoss要求target与input同为浮点类型,因为数学公式里的 $y$ 参与的是乘法而非索引。CrossEntropyLoss习惯了LongTensor标签的人,迁移过来时最容易在这里翻车,报错信息通常是:
RuntimeError: result type Float can't be cast to the desired output type Long修复方式非常简单,labels = labels.float(),但要注意float()会新建张量,在 DataLoader 的collate_fn里做一次,比在训练循环里每次做要省不少开销。如果标签是从 CSV 或者 JSON 解析来的多热向量,解析后直接.astype("float32")转成 numpy 再torch.from_numpy,一条链路上只做一次类型转换。
还有一个容易忽略的点:混合精度训练时,logits 是float16,标签如果是float32,PyTorch 会在损失内部做类型提升,通常会成功但会带来额外开销,偶尔在某些版本组合下报类型不匹配。我的做法是模型输出后立刻logits = logits.float(),再做损失计算,显存代价很小,稳定性提升明显。
注意:
labels.float()之后不要再用.long()做索引类操作,两个用途的标签建议在数据集里就分开存成labels_float和labels_idx,避免来回转换。
3. 从零搭一条多标签训练管线
光讲形状容易抽象,我按一个完整的最小可运行例子走一遍,从数据集构建到损失配置、训练循环、推理阈值,每一步都标注为什么这么写。任务设定:八类标签,每张样本可以同时命中多类,类别分布不均匀,最长尾的类正样本比例只有 0.6%。
3.1 标签构建:多热向量要在数据集层做干净
数据集的核心职责是把原始标注转成[C]的 0/1 浮点向量,并且保证维度与C严格一致。我见过的最常见问题是标签集合动态变化:训练集里没出现过的类别,在验证集里出现了,导致C对不上,模型输出 8 维、标签 9 维,报错报在很晚的地方。解决办法是在数据集初始化时传入固定的class_list,不在任何环节动态推断。
import torch from torch.utils.data import Dataset class MultiLabelDataset(Dataset): def __init__(self, samples, class_list, transform=None): self.samples = samples # List[Tuple[path, List[str]]] self.class_list = class_list # 固定顺序的类别名列表 self.cls_to_idx = {c: i for i, c in enumerate(class_list)} self.num_classes = len(class_list) self.transform = transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, tag_names = self.samples[idx] image = self._load_image(path) if self.transform: image = self.transform(image) label = torch.zeros(self.num_classes, dtype=torch.float32) for name in tag_names: # 未知标签直接忽略并计数,不要静默扩展类别维 if name in self.cls_to_idx: label[self.cls_to_idx[name]] = 1.0 return image, label关键点有三个。dtype=torch.float32写死在构造时,别指望后面.float()补救;未知标签走"忽略并计数"的路径,我一般会挂一个Counter统计,训练结束后检查有没有大量标注被丢掉,那往往意味着class_list漏了类;num_classes作为属性暴露出来,模型的输出维度直接读它,两处用一个来源,永远不会对不上。
标签里还有一种特殊情况需要提前想清楚:全零样本。一张图八个类别一个都不命中,标签是[0,0,...,0]。BCEWithLogitsLoss对这种样本完全能算,它的贡献就是八个负样本项,教模型"这些都没有"。这类样本有没有价值取决于业务——如果标注流程是人工逐条打标,全零说明"确实什么都没有",值得保留;如果是弱标注或者关键词匹配,全零很可能是漏标,保留下来会让模型学会过度保守。我的习惯是抽样几十个全零样本人工核对,再决定保留比例。
3.2 模型输出层与损失配置
模型这边只有一条铁律:最后一层不加任何激活。不管前面用 ResNet、ViT 还是自己堆的 MLP,输出都是[B, C]的裸 logits。
import torch.nn as nn class MultiLabelModel(nn.Module): def __init__(self, backbone, num_classes, drop_rate=0.2): super().__init__() self.backbone = backbone feat_dim = backbone.num_features self.head = nn.Sequential( nn.Dropout(drop_rate), nn.Linear(feat_dim, num_classes), # 不加 sigmoid,不加 softmax ) def forward(self, x): feat = self.backbone(x) return self.head(feat) # shape: [B, C]损失函数的配置要跟着任务特性走,把常用参数一次说清楚:
import torch class_list = [f"cls_{i}" for i in range(8)] C = len(class_list) # 训练集各类正样本数,用于计算 pos_weight pos_counts = torch.tensor([120, 340, 88, 1500, 60, 210, 95, 40], dtype=torch.float32) neg_counts = 5000 - pos_counts pos_weight = (neg_counts / pos_counts).clamp(max=20.0) # 上限裁剪,避免极端类主导 criterion = torch.nn.BCEWithLogitsLoss( pos_weight=pos_weight, # shape: [C] reduction="mean", )pos_weight的形状必须是[C],和 logits 的最后一维对齐,这点后面单独讲。clamp(max=20.0)是经验做法:正样本极少的类别,理论pos_weight可能到 100 以上,过大的权重会让模型对这类样本过度敏感,产生大量假阳性,同时梯度尺度失控。裁剪到 20 以内,配合后面的阈值调优,效果比一味加大权重稳定得多。
3.3 训练循环:一个小断言省下几小时
训练循环本身没什么特别,关键是加保护。我在每个 epoch 的第一个 step 打印一次形状信息和梯度的范数,用来第一时间发现异常:
from torch.utils.data import DataLoader loader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=4) optimizer = torch.optim.AdamW(model.parameters(), lr=2e-4, weight_decay=1e-4) scaler = torch.amp.GradScaler("cuda") for epoch in range(num_epochs): model.train() for step, (images, labels) in enumerate(loader): images = images.cuda(non_blocking=True) labels = labels.cuda(non_blocking=True).float() with torch.amp.autocast("cuda", dtype=torch.float16): logits = model(images) # 断言放在 autocast 外面,保证形状与 dtype 检查基于 float32 logits = logits.float() assert logits.shape == labels.shape, f"shape {logits.shape} vs {labels.shape}" assert labels.dtype == torch.float32 loss = criterion(logits, labels) optimizer.zero_grad(set_to_none=True) scaler.scale(loss).backward() scaler.unscale_(optimizer) grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) scaler.step(optimizer) scaler.update() if epoch == 0 and step == 0: print(f"logits {logits.shape} labels {labels.shape} " f"loss {loss.item():.4f} grad_norm {grad_norm:.4f}") print(f"per-class pos rate: {labels.mean(dim=0).cpu().numpy().round(3)}")scaler.unscale_之后再算grad_norm是必须的,否则拿到的是被缩放过的梯度范数,判断阈值会失真。clip_grad_norm_在多标签任务里几乎必开,因为pos_weight会放大某些类的梯度量级,不加裁剪时偶发的 loss 尖峰会直接把模型带偏。
推理阶段记得补 sigmoid,且不要在训练时算概率存下来复用,logits 才是损失函数的输入:
@torch.no_grad() def predict_proba(model, images, batch_size=64): model.eval() probs = [] for i in range(0, len(images), batch_size): batch = images[i:i + batch_size].cuda(non_blocking=True) logits = model(batch).float() probs.append(torch.sigmoid(logits).cpu()) return torch.cat(probs, dim=0) # shape: [N, C],取值 (0,1)torch.sigmoid放在no_grad里、放在 CPU 上算,都是小优化,但推理批量大时省下的显存和时间很可观。
4. 类别不平衡与 pos_weight 的正确算法
多标签任务的类别分布几乎从来不是均匀的,头部类占比 30%,尾部类占比 0.5%,这在一张图上同时出现八个标签的场景里是常态。不加权的 BCE 会被头部类主导,尾部类的梯度被淹没,最终模型对所有样本都倾向于预测"没有",因为从期望损失看这是最安全的策略。
4.1 pos_weight 的数学含义
带pos_weight的 BCE 形式是:
$$ \mathcal{L} = -\left[, w_p \cdot y \cdot \text{log_sigmoid}(x) + (1-y) \cdot \text{log_sigmoid}(-x) ,\right] $$
$w_p$ 只乘在正样本项上,负样本项不动。这意味着pos_weight = k等价于告诉优化器:"漏掉一个正样本的代价,是误判一个负样本的 k 倍。"直觉上,这个 k 应该等于负正样本比例N_neg / N_pos,让两类在损失里"势均力敌"。
但理论值和实际最优值经常差很远。原因在于损失里的"势均力敌"不等于指标上的"势均力敌"。多标签任务常用的指标是 mAP 或者 F1,它们对排序质量敏感,对绝对概率标定不太敏感。我做过对比实验,在八个类的任务上,把pos_weight从精确比例值调到它的 0.5 倍,mAP 反而涨了 2 个点,因为过度加权会让尾部类的概率整体偏高,排序上不再有区分度。所以我的建议是:以理论值为起点,在其 0.5x 到 2x 区间做小规模网格搜索,不要迷信公式。
4.2 从训练集统计到权重张量,一步都别用手写
pos_weight必须从训练集统计,不能用验证集或全体数据,否则会引入信息泄露。我给的标准流程是跑一遍 DataLoader,把每类的pos和neg累计出来:
def compute_pos_weight(dataset, num_classes, cap=20.0): counts = torch.zeros(num_classes, dtype=torch.float64) for _, label in DataLoader(dataset, batch_size=256, shuffle=False, num_workers=4): counts += label.sum(dim=0).double() total = float(len(dataset)) neg = total - counts weight = (neg / counts.clamp(min=1.0)).clamp(max=cap) return weight.float()几个细节。用float64累加是因为某些类的计数可能上万,float32累加几百个 batch 后精度会漂;clamp(min=1.0)防止某类在训练集里一个正样本都没有导致除零,这种情况应该先去检查数据而不是硬算;cap和前面说的一样,避免极端权重。算完之后我会把权重和对应的类名打出来看一眼,如果某个类的权重大于 10,基本可以肯定这类样本太少,需要补数据或者改用更强的数据增强,而不是靠权重硬撑。
还有一个weight参数容易和pos_weight混淆。weight是和输入同形状的张量,按元素加权整个损失项,正负样本都受影响;pos_weight是一维的[C],只影响正样本项。多标签的不平衡处理用pos_weight,按样本难度加权才用weight。两个一起用的时候注意权重会相乘,实际效果可能远超预期。
4.3 阈值后处理:0.5 往往不是最优解
模型输出的概率过了 sigmoid 之后,用什么阈值判定"命中"?绝大多数人写 0.5,然后抱怨 F1 上不去。在多标签场景里,每个类的概率分布都不一样,头部类可能集中在 0.8 以上,尾部类可能全部在 0.3 以下,一个全局 0.5 对谁都不友好。
我的做法是在验证集上做逐类阈值搜索,用 F1 或者业务更关心的指标作为目标:
import numpy as np from sklearn.metrics import f1_score def search_thresholds(probs, targets, grid=np.arange(0.05, 0.96, 0.01)): probs = np.asarray(probs) targets = np.asarray(targets) num_classes = probs.shape[1] best_th = np.full(num_classes, 0.5) best_f1 = np.zeros(num_classes) for c in range(num_classes): for th in grid: pred = (probs[:, c] >= th).astype(int) score = f1_score(targets[:, c], pred, zero_division=0) if score > best_f1[c]: best_f1[c] = score best_th[c] = th return best_th, best_f1这套搜索在验证集上跑一次,把best_th存进模型产物的配置里,推理时逐类比较。注意两个坑:验证集要足够大,尾部类如果只有十几个正样本,搜出来的阈值会过拟合,这时候可以退化成"全类统一阈值 0.5 或 0.3";阈值要在验证集上搜、在测试集上验证,别用同一份数据反复调,那只是自欺欺人。
顺带说一个指标层面的心得。多标签分类里f1_score的average参数选什么,直接影响你看到的数字。macro让每个类等权,尾部类表现差会明显拉低总分,适合评估"是否所有类都要能用";micro按样本数加权,头部类主导,适合评估"整体准确度"。我在项目里一般两个都报,macro用来发现长尾问题,micro用来汇报总体水平。
5. 常见问题排查速查表:报错、静默错误、不收敛
这一节是我这些年攒下来的问题清单,按症状分类。遇到BCEWithLogitsLoss相关的怪事,先在这张表里对一遍,能省掉大量盲猜。
5.1 报错类问题:形状和 dtype 的正面冲突
| 报错信息 | 根因 | 修复方式 |
|---|---|---|
result type Float can't be cast to Long | 标签是LongTensor | labels = labels.float(),最好在collate_fn里做 |
The size of tensor a (C) must match the size of tensor b (B) | logits[B,C]对标签[B],且 B≠C | 统一成[B,C],用view显式压平 |
Target size must be the same as input size | 形状不可广播 | 检查模型输出,去掉多余的unsqueeze |
pos_weight相关维度报错 | 权重张量长度不等于 C | 用compute_pos_weight从数据集统计生成 |
Expected floating point type for target | 标签是 bool 或 int | .to(torch.float32) |
第二行这条报错我遇到最多,报错文案像是从别的算子借来的,实际原因是[B, C]和[B]在最后一维对齐失败。修起来不难,但报错信息不直观,容易浪费时间在别处找。
5.2 静默错误:不会报错但模型学不到东西
这一类才是真正耗时间的。我列了五个高频症状和对应的检查手段:
- loss 平滑下降但验证指标接近随机。先查
labels.shape是不是[B, 1],再看labels.mean(dim=0)是否所有类相同,两个检查加起来不到一分钟。 - loss 数值异常大,是正常值的 C 倍左右。查 logits 是不是
[B, C, 1],广播出[B, C, C]后元素数翻了 C 倍,reduction="mean"也压不回来。 - 某些类的预测概率永远是 0 或 1。查这一类的
pos_weight是否被设得过大,导致 sigmoid 饱和、梯度消失。把pos_weight裁剪到 20 以内通常能缓解。 - 训练前几个 step loss 是 nan。查有没有重复 sigmoid,或者 logits 里本身就有 inf,后者通常是上游学习率过大导致参数爆炸。
- 验证 loss 比训练 loss 低很多。这通常不是损失函数的问题,而是验证集的正样本比例更高,BCE 对先验敏感。检查两份数据的标签分布统计。
关于最后一条展开说一句。BCEWithLogitsLoss在reduction="mean"时,是所有元素损失的算术平均,正负样本数量都会影响结果。训练集如果做了重采样、验证集没做,两边的 loss 数值本来就不可比,别拿它当早停的唯一依据,用好 mAP 或者 F1。
5.3 不收敛:超参数与标签质量的双重怀疑
真遇到 loss 抖动、指标上不去时,我按这个顺序排查,命中率比较高:
第一步看标签质量。抽 30 个样本,把每类的标签图和原图/原文并排看一遍,统计标注错误率。多标签任务的标注成本高,错误率经常在 5% 以上,标签噪声大过模型容量问题时,调参是白费功夫。
第二步看pos_weight的量级。打印每个类的权重和正样本数,如果某类正样本少于 50 且权重被压到 20,这类基本上学不出来,要么补数据,要么在评估里把它单独看。
第三步看学习率和 batch size。多标签任务里BCEWithLogitsLoss的梯度尺度比CrossEntropyLoss大一些,同样的学习率容易震荡。我一般从 2e-4 起步,配合clip_grad_norm_(max_norm=5.0),如果 loss 曲线前 200 step 有明显尖峰,先降学习率而不是加 warmup。
第四步看阈值。指标不涨可能是阈值选错了而不是模型不行,用 4.3 的搜索脚本扫一遍,经常能发现"模型其实学到了,只是 0.5 不适合"。
第五步才怀疑模型容量和特征质量。前面四步都排除之后再上更大的 backbone,性价比更高。
5.4 几个只有踩过才知道的细节
软标签要保留浮点精度。做 label smoothing 时把 1 改成 0.9、0 改成 0.05,BCEWithLogitsLoss完全支持,因为公式里 y 参与乘法。但要注意别把软标签转成LongTensor,那会把 0.9 截断成 0,等于把正样本全标成了负样本。
mixup 类增强依赖浮点标签。两个样本按比例混合后,标签也是混合的,形状仍为[B, C],值在 0 到 1 之间。这是BCEWithLogitsLoss相对CrossEntropyLoss的一个结构性优势,后者处理软标签还得额外实现。
空 batch 要防。某些自定义 sampler 在数据量除不尽时会产出空 batch,logits是[0, C],此时 loss 是nan。加一句if logits.numel() == 0: continue就行。
分布式训练下reduction="mean"的语义。DDP 里每个进程各自算 mean 再平均,如果各卡的样本数或正负比例差异大,等权平均会带来偏差。样本数不均衡时改用reduction="sum"再手动除以全局样本数,结果更准确。
保存模型时把阈值和pos_weight一起存。我见过太多"模型文件在、配置丢了"的情况,推理时用默认 0.5 阈值,效果和训练时差一大截。用一个config.json固定住class_list、pos_weight、best_thresholds、input_size四项,部署环节能省掉一整轮排查。
验证集指标用 logits 排序、用阈值判定,两件事分开做。mAP 只关心排序,跟阈值无关,适合用来判断模型本身好不好;F1 依赖阈值,适合判断当前配置能不能上线。两个指标一起看,能区分"模型不行"和"阈值没调好"这两种完全不同的情况。
我个人在这些年的多标签项目里体会最深的一点是:BCEWithLogitsLoss本身几乎不会出问题,它稳定、简单、行为可预期,出问题的永远是喂给它的东西——形状被广播悄悄改过的标签、忘记转换的 dtype、从多分类任务沿袭过来的 sigmoid、以及一个从来没被认真选过的 0.5 阈值。把这几处盯死,多标签任务的训练稳定性会比想象中好很多。