ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

图像分割中CrossEntropyLoss的踩坑指南:从输入格式到类别平衡

图像分割中CrossEntropyLoss的踩坑指南:从输入格式到类别平衡 第一次用CrossEntropyLoss()做图像分割是在一个街道场景的语义分割实验里。网络结构用的还是当时很常见的编码-解码结构第一版代码跑起来之后loss从0.6慢慢降到0.3我当时还挺高兴结果验证集的mIoU一出来直接傻眼——背景类占了接近90%准确率很高但车辆、行人、建筑这些真正关心的类几乎全部没学到。后来花了好几个晚上排查发现问题基本都出在我对CrossEntropyLoss()这条函数本身的理解上输入格式、标签编码、类别分布、ignore_index每一个都能让训练“看似正常、实则报废”。这篇就把我用CrossEntropyLoss()做图像分割踩过的坑按类别整理一遍每个坑后面附上可照做的检查方式和修复思路希望能帮你少熬几个夜。1. 为什么图像分割任务绕不开CrossEntropyLoss1.1 CrossEntropyLoss到底在算什么首先回到一个最基本的认知图像分割的本质是逐像素分类。模型对每个像素输出C个类别的分数CrossEntropyLoss()的作用就是衡量“模型预测的分数分布”和“真实类别”之间的差异。PyTorch里的nn.CrossEntropyLoss()并不是一个复杂的黑盒它内部等价于F.log_softmax(input, dim1)加F.nll_loss()也就是说它会先把logits在类别维度上做log softmax再取正确类别对应的负对数概率作为loss。把公式拆开就是对某个像素如果真实类别是c模型对该类的得分是z_c那损失就是-log(exp(z_c) / sum_j exp(z_j))。得分越高loss越小得分越低loss越大而且梯度会因为log函数的性质变得更加明显。这也是它在分类类任务里比MSE好用的原因MSE对输出概率的错误惩罚是二次的到了饱和区梯度几乎消失交叉熵在错误严重时梯度很大优化起来效率高得多。有一个点容易被忽略F.log_softmax里的dim1意味着它默认输入的第一维是batch第二维是通道也就是类别维度。这个约定直接影响后面所有输入格式问题只要把通道维度放错位置整个loss计算就会在错误的方向上“丝滑”地跑下去。1.2 分割场景下它比BCE、MSE更合适的原因很多做分割的新手会因为“分割也是输出mask”就想到二分类于是直接拿BCE或者MSE去套这种做法在二前景二背景的简单任务里能跑通但一旦类别变多就立刻出问题。BCE是逐类别独立计算损失的它不关心类别之间是不是互斥的相当于把每个类都当成一个独立的二元分类问题而语义分割的类别通常是互斥的一个像素只能是某一个类用CE这种带softmax归一化的损失才能天然保证所有类别的概率加起来等于1模型输出的是真正的概率分布。MSE的问题更明显。如果把分割当成回归任务模型输出的向量被强行拉向one-hot一样的分布数值在饱和区梯度极小训练速度慢而且对类别判别边界非常不敏感。简单说就是MSE问“你预测的数字和真实数字差多少”CE问“你给正确类别的信心够不够高”后者更贴合分类任务的本质。所以主流分割项目的第一步基本都是criterion nn.CrossEntropyLoss()。但要注意默认情况下它给每个像素的权重是相同的也就是没考虑类别分布差异。这在类别极不平衡的数据集上是致命问题后面第4章会展开讲。1.3 使用它的前提约束logits与target的三条约定使用CrossEntropyLoss()前必须明确三条硬性约定第一输入必须是未经过softmax的原始logits不是概率第二logits的shape一般是(N, C, H, W)N是batchC是类别数H和W是空间尺寸第三target必须是整数索引形式的标签shape是(N, H, W)取值必须在[0, C-1]范围内或者用ignore_index指定一个特殊值来忽略某些区域。这三条约定错一条训练就会出现各种奇怪现象。最常见的错误就是有人先写了prob torch.softmax(logits, dim1)再把prob当作输入扔给CrossEntropyLoss()结果loss没有爆掉但模型越训越差。还有人是把标签做成了one-hot形式然后发现shape对不上直接报错。后面所有踩坑内容本质上都围绕这三条约定展开。2. 最容易踩的维度与形状坑2.1 target的shape永远比logits少一维这是我身边同事问过最多的问题。模型输出logits是(B, C, H, W)而mask标签往往是(B, H, W)有些人会习惯性把mask也做成(B, H, W, 1)或者(B, 1, H, W)然后直接丢进loss接着就会看到类似Expected target size [B, H, W], got [B, 1, H, W]的报错。为什么PyTorch要这么设计因为C通道是类别维度对每个像素来说真实类别就一个整数索引不需要展开成one-hot。如果你习惯把mask做成(B,1,H,W)在放进loss之前需要做一次squeeze(1)。如果你从图像处理库读出来的是(H,W,1)类似的多余通道也要先去掉目的就是让target保持(B,H,W)。我的建议是在dataloader的返回值里就把格式固化。图像返回(3,H,W)的float tensor标签返回(H,W)的long tensordataloader自动堆叠后就是(B,3,H,W)和(B,H,W)直接在loss里对齐。不要在训练循环里临时做各种view、squeeze、reshape少一步排错步骤就会少一个潜在坑。2.2 通道维度在第二维别对最后一维做softmaxPyTorch的图像张量默认是NCHW格式也就是说类别通道维度在第二维也就是dim1。但这个约定太容易被忽略尤其是从TensorFlow迁移过来的同学TF习惯把通道放在最后一维PyTorch里错误地写torch.softmax(logits, dim-1)非常常见。表面上看dim-1也能运行不会报错但它在W方向上做归一化而不是在类别方向上。举个例子如果logits的shape是(2, 19, 256, 256)dim-1会在最后一个维度的256个空间位置做softmax结果是每个通道内部的空间值被归一化了类别之间完全没有竞争关系。这种情况下loss还能正常计算因为CrossEntropyLoss()内部还会在dim1再做一次log_softmax但输入已经变成了一堆被空间归一化过的数字模型基本学不出有效的类别边界。诊断这类问题有一个很笨但有效的办法在loss计算前打印一行logits.shape然后自己默念一遍“第二维是类别数”再检查softmax的dim是1。如果嫌麻烦就把softmax这类操作封装成一个函数统一传dim1避免到处手写。2.3 提前对logits做softmax再送进loss等于白做这个坑我踩过而且是在一轮已经很深的训练之后才发现的。当时我为了在训练日志里输出“置信度”顺手在loss之前写了一句out torch.softmax(out, dim1)然后才把out传给CrossEntropyLoss()。结果模型训练一直很慢loss数值偏小验证集几乎不动。原因是CrossEntropyLoss()内部已经有log_softmax这一步。如果你先把softmax结果传进去它等于在[0,1]范围内的概率值上再取一次log得到的分布已经不再是模型原始分数的对数概率。尤其在概率接近0或1时数值会发生奇怪的偏移梯度方向虽然不至于完全反向但优化过程被严重扭曲。正确做法是模型最后一层输出logits直接丢给CrossEntropyLoss()需要看概率时单独在inference阶段做softmax。如果你非要在训练过程中监视概率就用with torch.no_grad()的代码块只算softmax做日志别让它进入损失计算图。2.4 上采样、padding、裁剪引起的尺寸错位分割模型里编码-解码结构非常常见很多模型在解码器末尾直接输出了下采样后的特征图而不是原图大小。此时如果直接把logits和原始分辨率mask去算CrossEntropyLoss()会报shape不匹配的错误。但比这个更隐蔽的情况是你用某种方式强行把它们对齐了但空间位置是错的。比如DeepLab系模型最后特征图是输入尺寸的1/8或1/16如果没做上采样shape对不上会立刻报错如果你写了logits F.interpolate(logits, size(H, W), modebilinear, align_cornersTrue)尺寸对了但因为mask和原图经过的裁剪、padding不一样实际像素错位loss还在继续算只是结果全程没有意义。我的排查顺序是先看原始图像进模型前的尺寸变换再看mask有没有做过相同的resize和crop。特别是在使用随机裁剪时必须保证image和mask使用同一个crop参数。如果你在dataloader里用了图像增强库一定检查是不是对mask也同步做了增强而且mask的插值方式要选最近邻。3. mask标签处理中隐藏的坑3.1 RGB的标注图不能直接当target现在很多公开分割数据集给的标签是RGB伪彩色图而不是每个像素一个索引的PNG。比如VOC格式的mask是一张彩图背景是黑色行人是红色汽车是蓝色。如果直接把RGB图读进来变成(H,W,3)或(3,H,W)再扔给CrossEntropyLoss()第一个崩溃点就是target维度不对第二个崩溃点是数值范围不对——CrossEntropyLoss希望target是单通道整数索引不是一个三元组。正确做法是建一个颜色映射表把每个RGB值映射到类别索引。速度上别用纯Python逐像素循环数据量一大就慢得致命。可以用NumPy一行搞定每一类先做一个全零的索引矩阵然后对每个类别用np.all(mask_rgb color, axis-1)得到一个布尔矩阵再把对应位置填成类别id。最后转成torch.from_numpy(index).long()。这个转换过程最好放在预处理阶段提前批量生成索引版mask保存下来而不是每次训练都在dataloader里现场转换。否则训练时每读一个样本都做一次全图颜色匹配CPU负载高到无法接受。3.2 标签值必须连续且从0开始这个坑极其隐蔽。很多数据集的原始标签里忽略区域用255背景用0但某些类别的ID可能是1、2、3之后直接跳到7、8、9中间没有4、5、6。人的直觉是“既然是类别ID那就直接用”但CrossEntropyLoss()内部做索引查表时是按target里的具体数值去取logits对应通道的。比如logits有10个通道代表类别0到9target里如果出现值12它会直接访问越界报错如果target里的值不连续但都没有越界比如某些类从0到9中间缺了几个那模型的一个输出通道可能永远不被用到另一个类别却对上了错误通道最终预测结果混乱。所以在训练前要做一件看起来多余但救命的事对训练集和验证集分别执行np.unique(mask)看看mask到底有哪些值。如果发现不连续或者存在超出类别数范围的值就建立一个重映射字典把所有有效值映射成0,1,2,...,C-1忽略值映射成255。这样CrossEntropyLoss的通道和标签才能一一对应。3.3 ignore_index是双刃剑别指望它自动处理所有无效区域ignore_index的设计初衷是忽略某些位置的像素比如VOC的边界白线、标注不全的区域等。常见做法是nn.CrossEntropyLoss(ignore_index255)然后在target里把无效区域的值设为255。这个机制很好用但它有几个副作用需要留意。第一个副作用是当reductionmean时分母只统计有效像素数不是所有像素。如果一个batch里有效像素特别少loss值会表现出不正常的剧烈波动。更极端的情况是整个batch里的像素全是255loss直接变成nan。这个问题在小batch训练、mask标注大量缺失时尤其明显。第二个副作用是如果你同时设置了类别权重weight和ignore_indexweight的长度必须等于真实类别数C不要把忽略区域当成一个类别放进weight里。否则要么报维度不匹配要么权重语义错乱。我习惯在计算weight时只统计非忽略区域的像素确保每个类别都有合理的频率估计。3.4 数据增强后mask错位和插值问题做分割的人都用过随机翻转、随机旋转、随机缩放这些增强。很多人会把图像增强做好但mask增强忘掉或者虽然做了用了双线性插值。双线性插值会产生介于两个整数之间的浮点值比如0.2、0.8这样的target直接传给CrossEntropyLoss()会报dtype错误或者被某些操作截断成0和1导致类别完全错乱。mask的resize必须用最近邻插值这在PyTorch的F.interpolate里就是modenearest在图像增强库里有类似参数。翻转和旋转这种不改变像素值的几何变换要特别确认边界填充值不要生成垃圾类别。随机旋转时如果边界填充用了0那么补齐区域会被当成背景类这往往不是你想要的结果需要显式用一个填充值然后在target里把它映射成ignore_index。另外一个容易被忽略的点如果对图像做了归一化mask不要做同样的归一化。图像需要减去均值除以标准差mask只需要做类型转换否则会引入浮点误差。很多新手在同一个transform里对图像和mask套了同一个标准化函数最后target直接变成了负数或者0.几的小数loss瞬间变成nan。4. 训练时loss表现异常的排查4.1 loss突然变成nan先从数据和target查起loss变成nan是分割训练里最常见的异常之一。很多人第一反应是调整学习率、换优化器但我经手的案例里大部分nan其实来自target和输入格式的问题而不是优化器的问题。排查顺序应该是先打印target.min()和target.max()确认没有超出类别数范围再打印logits.min()和logits.max()确认没有inf或者nan最后看loss.item()是不是一开始就是nan如果是那大概率是数据问题。比如target里有值200而你的模型输出通道只有19个CrossEntropyLoss在取log_softmax对应通道时会越界轻则报错重则在某些版本里直接产生nan。还有如果标签是浮点型必须转成long()。我建议在dataloader里就把mask mask.long()固定好不要等到训练循环里再改更不要依赖自动类型推断。排查时在loss.backward()前面加一行断言也很有用assert loss.item() loss.item()这能快速发现nan。加assert不能解决根本问题但它能把问题卡在最早出现的时刻避免训了半天才发现loss早就坏了。4.2 loss不下降多半是大类别把模型带偏了这个问题表面上跟CrossEntropyLoss关系不大但它确实是使用默认参数最容易踩的大坑。默认的weightNone意味着每个像素对loss的贡献相同。当背景像素占95%、前景只占5%时模型只需要把所有像素都判成背景loss就能降到很低mIoU却惨不忍睹。我比较推荐的做法是先跑一个epoch统计各类别像素占比然后计算中位数频率平衡权重。公式思路很简单weight_i median_freq / freq_i其中freq_i是第i类像素在所有有效像素中的比例median_freq是这些比例的中位数。这样做的效果是让出现少的类别获得更大权重同时不至于让极稀有类权重过大导致训练震荡。设置weight时还有一个细节weight的每个值对应logits的通道索引不是类别标签本身。如果你重映射过mask那么mask的值和weight的顺序都要按照重映射后的索引来。否则即使维度匹配权重也会“配错对象”。4.3 整个batch全是ignore区域时的空张量问题这个坑我调试了很久。当时用的数据集标注很稀疏一个batch里大部分像素都是未标注区域全是255。CrossEntropyLoss的ignore_index255把所有像素都过滤掉之后reductionmean要对0个元素求平均结果就是nan。这个问题在batch size比较小、类别又多又稀疏时很容易发生。修复思路有几个方向。最直接的是增大batch size或者过滤掉那些有效标注太少的数据样本。另一个通用做法是在训练循环里检测一下有效像素数量如果为0就跳过这个batch。但更治本的是检查数据pipeline确认255这个值到底是“忽略区域”还是“背景类0”被强行转成了255。我见过有人把mask填充值设成255但忘记标注它是ignore导致所有有效像素都被丢掉了。还有一个小技巧如果你发现训练集里某些类别的像素总数少得可怜与其硬扛CrossEntropyLoss不如先做数据清洗把标注明显错误的样本剔除。损失函数能缓解类别不平衡但补不了标注质量的问题。4.4 损失看起来太小或为0的几种情况loss异常“好”不一定代表训练正常。最常见的一种情况是忘记喂logits直接拿softmax后的概率值传给CrossEntropyLoss此时loss数值会比正常情况小很多因为它是在概率值上再取log而概率值的范围天然被压缩在0到1之间log后的负数绝对值非常小。这时候模型不一定完全学废但跑很久也很难收敛到理想状态。还有一种情况是target退化成了常数比如所有mask都是背景类0。此时模型只需要输出一个常数分布就能把loss压得很低。验证集mIoU可能不为0但仔细看每类IoU只有一个类有数值。要排查这个问题可以在训练开始前可视化几个样本的mask看一眼是不是存在不同类别的像素。如果验证时loss很低但mIoU不高还有一个隐藏原因是模型处于model.train()而不是model.eval()模式BatchNorm的统计量在验证时还在不断变化导致输出不稳定。这种问题在分割任务里很常见尤其在用验证集计算loss时必须用with torch.no_grad()加上model.eval()。5. 一次完整最小可复现的分割训练实操5.1 最小数据读取与标签格式转换下面给一个可以直接改来跑的流程重点不是模型多强而是让CrossEntropyLoss的输入输出严格合规。图像读取后统一转成float32的(3,H,W)张量mask读取后先做颜色映射或直接读索引图然后转成int64的(H,W)张量。def load_sample(image_path, mask_path): image read_image(image_path).astype(np.float32) # H,W,3 mask_raw read_mask(mask_path) # 可能是RGB或索引图 # 如果mask是RGB图这里要完成颜色到索引的映射 h, w mask_raw.shape[:2] mask np.zeros((h, w), dtypenp.int64) for cls, color in color_mapping.items(): mask[np.all(mask_raw color, axis-1)] cls # 统一格式图像CHW标签HW image torch.from_numpy(image.transpose(2, 0, 1)).float() mask torch.from_numpy(mask).long() return image, mask这里color_mapping要自己根据数据集定义比如{0: (0,0,0), 1: (128,0,0)}。映射完成后做一次np.unique(mask)确认最大值小于类别数或者把不需要的区域改成255。这个步骤虽然看起来啰嗦但能拦住后面至少一半的问题。5.2 一个最小分割模型与loss初始化模型不需要太复杂核心是最后输出logits。class SegmentNet(nn.Module): def __init__(self, in_channels3, num_classes19): super().__init__() self.backbone nn.Sequential( nn.Conv2d(in_channels, 32, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(32, 64, 3, padding1), nn.ReLU(inplaceTrue), ) self.head nn.Conv2d(64, num_classes, 1) def forward(self, x): return self.head(self.backbone(x))损失函数我一般先这样初始化criterion nn.CrossEntropyLoss(ignore_index255)如果已经统计好了类别频率就加上weight参数比如weightclass_weight_tensor。注意这个tensor要放到和模型一样的device上并且数据类型是torch.float32。不要一开始就上focal loss、dice loss之类的复杂变体先用默认CE跑通基线后续再根据问题换。5.3 训练循环中损失计算的常见写法对照训练循环的核心就几行但错误写法非常多。正确写法是for images, targets in dataloader: images images.to(device) targets targets.to(device) # (B,H,W), long logits model(images) # (B,C,H,W) loss criterion(logits, targets) optimizer.zero_grad() loss.backward() optimizer.step()常见错误写法我列几个如果你在代码里看到类似写法直接改loss criterion(torch.softmax(logits, dim1), targets)——错误重复softmax。loss criterion(logits, targets.unsqueeze(1))——错误target多了通道维度。loss criterion(logits, targets.float())——错误target必须是long。loss criterion(logits.view(-1, C), targets.view(-1))——这种做法本身没问题但前提是原始空间对应关系没乱。很多人是在HWC和CHW之间换得晕头转向结果像素顺序全乱了。与其手动view不如直接保持(B,C,H,W)和(B,H,W)传给loss。5.4 验证与保存指标时要注意的细节验证阶段很多人会直接pred logits.argmax(dim1)然后和target比较。这里最隐蔽的问题是argmax(dim1)是类别维度是对的但如果你因为之前习惯了TF的NHWC写成argmax(dim-1)那你的pred会在W方向上取最大得到的结果完全不是类别索引。计算mIoU时也要过滤掉ignore_index对应的像素。最简单的方法是只计算有效像素的混淆矩阵然后逐类计算IoU。混淆矩阵的维度是C x C但一定要先确认target的值都在0到C-1之间否则索引直接越界。另外保存可视化结果时索引mask直接保存成灰度图会很难看最好映射回原始RGB颜色再保存。但保存和可视化用的颜色映射不能影响训练数据它只是后处理别把它混进dataloader里。6. 问题排查速查表与实际心得6.1 常见报错与修复对照表这里整理一份我现在几乎每次排查都会对照的速查表能覆盖大多数分割任务中的CrossEntropyLoss问题。现象常见原因修复方式报错Expected target sizetarget多了一维或传了one-hot统一为(B,H,W)的long型标签loss是nantarget含越界值、ignore_index未设置、lr过高打印target min/max设置ignore_index降低lrloss数值异常小对softmax结果再算CrossEntropyLoss改为直接传logits训练acc高但mIoU低类别不平衡加类别权重或换focal/dice losslabel和模型通道错位使用了不连续的类别ID重映射标签为0到C-1mask增强后错位mask用了双线性插值或未同步变换用最近邻插值图像和mask走同一变换验证时loss波动大忘记model.eval()BatchNorm失效验证阶段固定model.eval()这张表每次排查都能用上但真正解决问题的往往不是最后一条而是前几条数据链路的问题。所以我的习惯是先检查数据再去动损失函数。6.2 几个我一直在用的诊断习惯我现在的固定流程是在第一次训练前单独写一个“冒烟测试”脚本把第一个batch的数据取出来手动前向一次打印以下信息logits.shape、target.shape、target.dtype、target.min()、target.max()、logits.min()、logits.max()。这一堆信息看一眼能拦住绝大多数低级错误。如果训练过程中loss出现异常我还会把当前batch的image和target可视化拼图保存下来。直接看图片比看数字快得多尤其是mask是否错位、是否有莫名奇妙的填充值、以及255和0到底哪个是背景一张图就能讲清楚。还有一个小习惯是给训练脚本设置固定随机种子并且使用确定性的dataloader。这样排查问题时能稳定复现不会因为数据顺序变化导致现象时有时无。分割任务本来训练周期就长能复现的问题最好在早期就控制住。6.3 回到最初的那个项目最后是怎么修好的回到我开头说的那个街道场景项目最后其实只改了三处把RGB mask转成了连续索引给CrossEntropyLoss设置了ignore_index255再按各类别像素频率计算了权重。没换模型结构没换优化器mIoU直接从不到20涨到接近50。那次之后我养成了一个习惯每次想把损失函数换成更复杂的东西之前先问自己几个问题——target是不是long型通道维度是不是在第2维数据增强是不是同步了mask里的值是不是在0到C-1之间如果这些答案有任何一个是“没确认”那就先别换loss因为CrossEntropyLoss本身很简单复杂的永远是它前后那堆数据。最后再分享一个小技巧如果你调来调去还是觉得loss表现诡异先临时把数据集缩小到一个batch拟合一个或几个样本看loss能不能降到接近0。如果连单样本都拟合不了那问题一定在数据链路或模型前向而不是损失函数。这个办法我在很多项目里都用过比反复调整优化器参数有效得多。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表