1. 在动手敲代码之前:三大任务的分工、经典算法版图与PyTorch选型理由
图像识别、目标检测、语义分割这三个方向的经典算法,我前前后后用PyTorch复现了两轮,第一轮是跟着别人的教程抄,第二轮是彻底丢掉教程自己从数据管线搭。这个综合整理系列就是想把两轮下来的理解沉淀成一份可以照做的清单:这个任务到底在解决什么问题,经典模型各自靠什么结构起家,用PyTorch复现时重点盯哪几行代码。
先说清楚三个任务的分工,因为很多初学者把分类准确率、mAP、mIoU混在一起比,比了半天也不知道谁强谁弱。图像识别又叫图像分类,任务是给整张图打一个标签,回答"这张图里是什么";目标检测要回答"哪里有目标、是什么目标",输出是一组边界框加类别;语义分割则要回答"每个像素属于什么",输出和输入一样大的像素级标签图。三者难度递增,评价指标也完全不同,所以复现重点天然不同。
经典算法版图大概是这样的,我按复现顺序整理了一张表:
| 任务 | 里程碑算法 | 核心贡献 | PyTorch复现难点 |
|---|---|---|---|
| 图像识别 | LeNet-5、AlexNet、VGG、ResNet、MobileNet | 深度、宽度、残差、轻量化 | 数据增强与训练细节 |
| 目标检测 | R-CNN系列、YOLO系列、SSD | 两阶段、单阶段、锚框机制 | 标签分配与后处理 |
| 语义分割 | FCN、U-Net、DeepLab系列 | 全卷积、跳跃连接、空洞卷积 | 像素级损失与上采样 |
为什么选PyTorch做这件事?最直接的理由就是调试手感好。复现算法有一半时间在查中间结果,PyTorch的动态图能让每个张量都带着形状和梯度信息跑,模型中间层的输出可以直接 print 出来看,不像静态图那种编译后再查的模式绕。另一个原因是PyTorch生态里第三方实现极多,你卡在某个细节时,往往能找到别人留下的参考实现来对照——当然,参考是为了理清逻辑,不是让你直接 pip install 一个现成库就跑,那就失去复现的意义了。
还有一点必须先说:复现经典算法,不是追求指标超过原论文,而是追求让每个模块都按论文意图跑通,理解为什么这样设计。所以下面每个任务我都会有意识地拆开结构、损失、训练技巧三层来讲,这样你替换数据集或者改成自己的模型时才有据可依。
2. 图像识别复现:线性堆叠与残差连接的两种搭建哲学
分类任务作为第一个复现对象是最合适的,因为它管线最短,没有锚框、没有后处理,数据从进来到loss出来是一条直线。我建议先跑VGG,再跑ResNet,两个结构放在一起对比,能直观感受到所谓"深度网络难训练"到底难在哪。
2.1 分类任务的最小闭环:五个组件一次打通
图像识别的PyTorch实现无非是五件套:Dataset、DataLoader、Model、Loss、Optimizer。第一次复现时最容易出问题的不是模型,而是Dataset里的 transform 和标签映射。
我用的是某公开植物叶片数据集做测试,目录结构是 train/类别名/图片.jpg,写自定义 Dataset 时核心就两件事:一是把图片路径读进来,二是把类别文件夹名映射成整数标签,用字典排序保证每次跑的标签顺序一致。transform 我用的是 Resize(256) + CenterCrop(224) + ToTensor + Normalize,这套组合是分类任务最稳的入场配置。
from torch.utils.data import Dataset from PIL import Image import torch class SimpleImageFolder(Dataset): def __init__(self, image_paths, labels, transform=None): self.image_paths = image_paths self.labels = labels self.transform = transform def __len__(self): return len(self.image_paths) def __getitem__(self, idx): image = Image.open(self.image_paths[idx]).convert("RGB") label = self.labels[idx] if self.transform is not None: image = self.transform(image) return image, torch.tensor(label, dtype=torch.long)数据集这部分我碰到的第一个坑是图片损坏和灰度图混在里面,PIL 直接打开会报错或变成单通道,后来统一加了一段异常处理,打不开就跳过并记录路径。分类任务对数据质量容忍度还算高,但这一步不做,训练到一半崩掉特别浪费时间。
CrossEntropyLoss 在PyTorch里的实现是 LogSoftmax + NLLLoss 的组合,所以模型最后一层直接输出原始logits就行,不需要手动加 Softmax。Optimizer 我用 AdamW,学习率给到 1e-3 左右,第一个 epoch 观察 loss 下降是否正常,再用 CosineAnnealingLR 做调度。
2.2 VGG值得复现的真相:卷积块的堆叠惯性
VGG的核心思想很朴素:全部用 3x3 卷积,连续堆叠多个卷积块后再接池化,最后接几层全连接。这个结构拿来练手再合适不过,因为它能让你看清"深度如何通过堆叠产生"这个玄学问题的工程答案。
PyTorch里复现VGG的套路极其固定:用 nn.Sequential 把一组 Conv-BN-ReLU 串起来做成 block,不同深度版本只是 block 数量和通道数不同。这里有个值得注意的设计细节,VGG原文把卷积层的 padding 都设为1,这样 feature map 经过卷积后尺寸不变,等通道数翻倍时,尺寸靠 MaxPool 来减半。这个"卷积保尺寸、池化降分辨率"的模式后来被大量模型继承。
复现VGG最容易翻车的不是卷积层,是最后那三层的全连接层。原版VGG16最后三个全连接分别是 4096、4096、1000,参数量加一起超过一亿,一个 batch 的中间特征很容易把显存吃满。我自己用 224x224 输入、batch size 32 跑VGG16,6GB显存就报警了。解决办法是干脆把最后的全连接换成全局平均池化,直接在最后一个卷积特征上接线性分类层,参数量能砍掉一大截,准确率损失很小。
import torch.nn as nn def make_vgg_block(in_ch, out_ch): return nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) class VGGLike(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( make_vgg_block(3, 64), make_vgg_block(64, 64), nn.MaxPool2d(2), make_vgg_block(64, 128), make_vgg_block(128, 128), nn.MaxPool2d(2), ) self.global_pool = nn.AdaptiveAvgPool2d((1, 1)) self.classifier = nn.Linear(128, num_classes) def forward(self, x): x = self.features(x) x = self.global_pool(x) x = torch.flatten(x, 1) return self.classifier(x)2.3 ResNet的精髓:残差块怎么解决"变深反而变差"
VGG堆到很深的层数后会出现一个反直觉的现象:训练集上的loss都不降,即所谓退化问题。ResNet给出的解法是给非线性映射加一条"高速公路",让每一层学习的目标从"完整的映射H(x)"变成"残差F(x)=H(x)-x"。
复现ResNet时,核心就是 BasicBlock 这个类,它对应 ResNet18/34,而 Bottleneck 对应 ResNet50/101,区别在于中间那层 1x1 卷积先降维、再升维,减少计算量。我在实现 BasicBlock 时特别注意了三点:第一个卷积的 stride 决定下采样,downsample 分支负责把 shortcut 的通道数和尺寸对齐,最后 addition 之后再做 ReLU。
class BasicBlock(nn.Module): def __init__(self, in_ch, out_ch, stride=1, downsample=None): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_ch) self.conv2 = nn.Conv2d(out_ch, out_ch, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_ch) self.relu = nn.ReLU(inplace=True) self.downsample = downsample def forward(self, x): identity = x out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) if self.downsample is not None: identity = self.downsample(x) out += identity out = self.relu(out) return out值得深入琢磨的是 BatchNorm 放在卷积之后、激活之前这个顺序。很多初学者习惯写成 Conv->ReLU->BN,这在训练时会出现数值不稳定,因为 BN 的归一化统计量应该作用于未激活的线性输出。
第二个要注意的是 stride=2 的那个残差块。当输入分辨率减半时,shortcut 也必须跟着减半,主流做法是用一个 stride=2 的 1x1 卷积对输入做下采样并改变通道数,而不是直接 pool 再接 padding。这块逻辑写错了,shape 对不上会直接报错,对得上但不对齐的话,训练出来特征就错位了。
我还是建议先拿 ResNet18 这种小模型完整跑通,再去碰 ResNet50。因为 ResNet18 结构简单,很容易手绘出每一层 feature map 变化,遇到维度报错也能快速定位。
2.4 分类复现的短平快技巧:预训练、冻结与增强
研究型复现自然是从零训练,但如果你想先验证自己的数据管线,强烈建议先用 torchvision 里带预训练权重的 ResNet18 跑一版对比。加载权重后先冻结 backbone,只训练最后的分类层,观察是否收敛到合理水平,再逐步解冻。这能帮你区分"模型写错了"和"训练不到位"两个问题。
预训练权重加载时有个老坑:如果类别数不是1000,最后一层 Linear 的权重形状对不上,加载会报 size mismatch。处理办法是加载时设置 strict=False,或者先把这个层从 state_dict 里剔除;如果是迁移学习,我通常直接重置最后一层。
增强方面,我比较推荐开源库里的 AutoAugment 或者简单的 Mixup。Mixup 的做法很直接:随机两个样本按比例混合,标签也按同样比例混合。它能让模型不那么死记硬背单张图,对提升泛化能力帮助明显。我自己试下来,小数据集上用 Mixup 配合随机裁切,准确率大概能提升两三个点,代价只是训练时长稍长。
3. 目标检测复现:锚框的生成、匹配、抑制全生命周期
如果说分类的核心是"结构",检测的核心就是"标签分配"。检测模型接收一张图,要输出多个框和多个类别,训练时怎么把成千上万个候选框跟真实框对应起来,这件事比网络结构本身复杂得多。我把检测复现的重点放在锚框机制上,因为它贯穿R-CNN系列和YOLO、SSD,搞懂锚框,检测就算入了门。
3.1 检测任务为什么比分类难一个量级
分类的loss一次只跟一个标签有关,检测的输出却是"任意数量目标的框+类别",所以至少多出三个新问题:如何生成候选区域、如何判断候选区域里有没有目标、如何让网络回归出准确坐标。
现在主流检测模型可以按两条路线分:两条路线都依赖锚框,区别在于怎么用。两阶段模型先通过区域建议网络生成稀疏的候选框,再对每个候选框做二次分类和回归,代表是Faster R-CNN;单阶段模型直接在密集网格上做分类和回归,一步到位,代表是YOLO和SSD。两阶段精度上限高但速度慢,单阶段速度快但正负样本不均匀的问题更严重。复现入门我更推荐从单阶段模型开始,逻辑链路短,显存压力小。
3.2 锚框的生成规则与IoU匹配:所有检测模型的地基
锚框本质上是一组预设的矩形,散布在图片的不同位置、不同尺度和不同长宽比上。训练时,如果真实框跟某个锚框的IoU超过阈值,这个锚框就负责预测该目标;这里的IoU就是两个框交集面积除以并集面积,复现检测代码时你会反复跟它打交道。
def compute_iou(boxes1, boxes2): """boxes1: [N, 4], boxes2: [M, 4], 坐标格式为x1y1x2y2""" tl = torch.max(boxes1[:, None, :2], boxes2[None, :, :2]) br = torch.min(boxes1[:, None, 2:], boxes2[None, :, 2:]) wh = (br - tl).clamp(min=0) inter = wh[..., 0] * wh[..., 1] area1 = (boxes1[:, 2] - boxes1[:, 0]) * (boxes1[:, 3] - boxes1[:, 1]) area2 = (boxes2[:, 2] - boxes2[:, 0]) * (boxes2[:, 3] - boxes2[:, 1]) union = area1[:, None] + area2[None, :] - inter return inter / union.clamp(min=1e-6)这段代码是我常用的向量化IoU实现,配合广播机制一次算完所有锚框和真实框的匹配矩阵。实际项目里要小心坐标格式:有 xyxy 和 xywh 两种,混用是最经典的bug来源之一。我自己就在复现SSD时把锚框格式从中心点宽高转成左上右下时少减了一个偏移,导致loss异常大,排查到凌晨才定位。
匹配策略一般是:对每个真实框,选IoU最大的锚框作为正样本;同时对每个锚框,IoU大于高阈值的也可以是正样本,IoU介于低阈值之间的算忽略样本,低于低阈值的算负样本。这个细节直接影响训练稳定性,千万别随便把IoU低于某一个固定值的都当负样本,很多明确的背景区域其实被忽略掉更合理。
3.3 回归目标不只是"框的偏移",还有归一化
检测模型里分类分支输出类别概率,回归分支输出的是坐标修正量。如果让网络直接回归绝对像素坐标,loss会被大图上的大坐标数值主导,模型很难收敛。正确做法是把偏移量做归一化:预测值表示锚框中心偏移和宽高缩放相对于锚框本身的比例。
以YOLO系列为例,网格单元负责预测相对于自己左上角的偏移,再乘上锚框的宽高作为缩放基准。复现时只要记住一件事:回归标签永远以锚框为参照物做标准化。锚框越接近真实框,这个回归任务越容易学习。
我自己复现时的启动顺序是:先生成固定数量锚框,手动在图上画出来,确认位置和尺度合理,再开始训练。这个"可视化锚框"的步骤能帮你提前发现很多抽象bug,比如坐标偏移导致锚框跑到图片外、某个尺度锚框数量太少覆盖不了小目标等。
3.4 NMS:训练完不代表能直接出结果
模型推理时同一个目标周围会输出大量高度重叠的框,非极大值抑制就是把这些冗余框清掉。流程很简单:按置信度排序,保留最高分框,删除与其IoU超过阈值的框,然后重复。
NMS的实现本身不难,但有个复现细节值得强调:阈值设置直接影响最终mAP。阈值太高,重叠框残留多,误检增加;阈值太低,同一目标只会出一个框,但如果两个目标靠得极近,其中一个会被误删。我通常先用0.5跑一遍,再分别试0.45和0.6对比,结合可视化结果选。
目标检测领域我强烈建议做一个"调试可视化脚本":把图片、真实框、预测框、置信度分数一起画出来。分类任务可以直接靠acc判断好坏,检测任务没有可视化,出了问题你根本不知道是定位错了还是分类错了。
4. 语义分割复现:像素级标签如何逼出上采样与空洞卷积
语义分割是三个任务里最贴近"场景理解"的,它要求模型对每个像素做分类。很多刚开始复现分割的同学会拿分类网络的骨架去套,结果发现输出尺寸跟输入不一致,这是因为缺少了分割特有的两个机制:全卷积化和上采样恢复分辨率。
4.1 先搞清楚两个基础问题:标签格式和评价指标
分割任务的标签不是一张普通图片,而是一个和原图同尺寸的索引图,每个像素值是类别编号,比如0代表背景、1代表道路。所以数据加载时不能直接当成三通道RGB读取,要用 Pillow 的单通道模式打开。这一点是新手最常见的报错来源,模型输出明明是 [B, C, H, W],标签却是 [B, H, W, 3],训练一跑起来就崩。
评价指标也不是准确率,而是mIoU:所有类别IoU的平均值。IoU在分割里计算的是"预测为该类别的像素"和"真实为该类别的像素"的交并比。我第一版分割模型训练完acc显示90%以上,mIoU却只有0.35,因为背景占了绝大多数字素,分类acc被背景主导,而目标类别的像素错误全被掩盖了。所以分割模型千万别只看acc,mIoU才是关键。
4.2 FCN:把全连接层扔掉,换成一堆卷积
FCN(全卷积网络)的核心改动就一句:把分类网络末端的全连接层替换成卷积层,让网络可以接受任意尺寸输入并输出密集预测。VGG末尾的4096维全连接,在FCN里被换成 1x1 卷积,输出通道对应类别数,然后把空间分辨率通过上采样恢复到输入尺寸。
FCN使用的上采样方式是转置卷积,也叫反卷积,本质是先给特征图插值,再用卷积操作让网络自己学会放大。复现时要注意转置卷积的 kernel_size、stride、padding 三者关系,设置不当会出现输出尺寸对不上目标尺寸的问题。一个更稳的做法是先算好输入输出尺寸是否存在整除关系,然后用 nn.ConvTranspose2d(in_ch, out_ch, kernel_size=2, stride=2) 这种最常见的"翻倍"配置。
4.3 U-Net的跳跃连接:低层细节和高层语义的握手
U-Net最初是给医学图像分割设计的,它的编码器-解码器结构非常直观:左边不断下采样提取语义,右边不断上采样恢复分辨率,中间用跳跃连接把同尺度的编码器特征直接拼到解码器特征上。为什么这个结构效果好?因为下采样过程中高频细节(边缘、纹理)大量丢失,而分割恰恰需要这些细节来精确定位边界;跳跃连接等于给解码器开了一条"细节专线"。
复现U-Net的核心是两个模块:下采样路径里的 DoubleConv,以及上采样路径里的 Up 模块。Up模块先转置卷积放大,再与编码器对应层的特征做拼接,最后过 DoubleConv 融合。注意拼接维度是通道维,所以编码器特征的通道数要和上采样输出一致,否则拼接时会报尺寸不匹配。
class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.conv(x) class Up(nn.Module): def __init__(self, in_ch, skip_ch, out_ch): super().__init__() self.up = nn.ConvTranspose2d(in_ch, in_ch // 2, kernel_size=2, stride=2) self.conv = DoubleConv(in_ch // 2 + skip_ch, out_ch) def forward(self, x, skip): x = self.up(x) x = torch.cat([x, skip], dim=1) return self.conv(x)U-Net还有一个容易被忽略的优点:它对小数据量很友好。因为它没有把空间信息全压碎再重建,跳跃连接相当于保留了原始位置上一定程度的精确信息,所以几百张图的训练集也能得到可用的结果。复现时先把输入缩成固定尺寸比如256x256,跑通流程后再尝试多尺度训练。
4.4 DeepLab与空洞卷积:用更少的参数看更大的范围
分割任务里感受野是个绕不开的话题。你要判断一个像素是"道路"还是"人行道",光看它周围几个像素显然不够,得看到更大范围的上下文。FCN的做法是靠连续池化扩大感受野,但池化会不断降低分辨率,跟分割要保空间精细结构这事天然矛盾。
DeepLab系列的解法是用空洞卷积(膨胀卷积)。它的原理是在卷积核的元素之间插入空洞,让同样大小的卷积核覆盖更大区域,而且不增加参数量。我在复现时最喜欢用 dilation=2 和 dilation=4 的 3x3 卷积来对比感受野变化,逻辑很直观:一个3x3空洞卷积当 dilation=2 时,等效感受野是 5x5,参数却还是9个。
DeepLabV3最具标志性的模块是ASPP(空洞空间金字塔池化),把不同膨胀率的空洞卷积并联,分别捕捉不同尺度上下文,再融合到一起。复现ASPP时可以用 nn.ModuleList 串联几个空洞卷积分支,最后把所有分支结果在通道维拼接再过1x1卷积融合。这算得上语义分割里最值得手写一遍的模块了,因为它把"多尺度"这个抽象概念落到了具体的并行结构上。
4.5 分割损失:类别不均衡下怎么配比
分割数据集里类别像素数量极不均衡,道路可能占了60%,路灯只占0.5%。直接用交叉熵,模型会偏向学那些大类别,小目标类别的区域全部被背景淹没,mIoU当然上不去。
主流做法有三种。第一是给交叉熵加类别权重,权重按像素频率的反比设置;第二是用 Dice Loss,它直接优化类别区域的重叠度,公式里消除了类别像素数量差异;第三是用 Focal Loss,降低易分样本的权重,让模型专注难分像素。实际项目中我习惯把交叉熵和Dice Loss按 0.5:0.5 混合使用,cnn 训练初期交叉熵主导快速收敛,后期Dice Loss主导精细边界。不要一上来就只挂Dice Loss,它在小数据集上很容易震荡不收敛。
5. 三大任务共用的训练配方:lr、增强、监控指标怎么配比
前四章把三个任务各自的套路讲清楚了,这一章聊聊三个任务交叉时反复出现的共性经验。很多复现失败根本不是模型结构有问题,而是训练配置不对,把分类任务上管用的超参数直接搬到检测或者分割上,结果性能怎么都上不去。
5.1 学习率:任务越复杂,初始学习率越保守
分类任务的初始学习率可以放到 1e-3 甚至 3e-3,配合 AdamW 表现稳定。但检测和分割的网络通常更大,损失函数里也包含更多子项,同一套学习率直接搬过来,loss极容易爆炸,尤其是检测的回归分支,对学习率更敏感。
我的习惯是检测和分割用 1e-4 作为初始学习率起步,观察前几百个迭代的 loss 走向,如果稳定下降再考虑调大,如果剧烈震荡就降到 3e-5。对于大batch训练,可以配合线性预热,在第一个epoch内把学习率从很小的值线性升到目标值,这能有效避免早期大步长导致的参数震荡。
调度策略上分类任务用 CosineAnnealing 没什么问题,检测分割我更偏好按epoch阶梯下降,比如每30个epoch降为0.1倍,因为检测和分割的收敛更慢,阶梯式下降更能保证后期稳定。
5.2 数据增强要跟着任务形态走
分类的增强只管图片本身,检测的增强必须把边界框坐标同步变换,分割的增强必须把标签掩膜和原图同步变换。这个"同步性"是复现时最大的隐性成本。
比如随机水平翻转,分类代码里一行 transforms.RandomHorizontalFlip() 就完了;检测里你得保存翻转前和翻转后的宽度关系,把每个框的 x 坐标也翻转过去;分割里要把标签图按同样的种子跟原图一起翻转。增强的随机性必须用同一个随机种子,否则图片和标签错位,模型就学到错误对应关系。我现在的做法是把增强统一封装成一个函数,输入图片和标签,内部保证同步处理,而不是像分类那样直接套torchvison的 transforms。
5.3 指标要分层看:loss、acc、mAP、mIoU不是一回事
训练过程中最常见的误解是"训练loss降到很低,模型应该很好"。检测和分割里,训练loss低只能说明拟合了训练集,泛化能力要看验证集指标;而且mAP和loss之间不是完全单调关系,有时候loss降了,mAP反而下降,比如模型过拟合到训练集的特有纹理上。
我的做法是每个epoch保存三样东西:训练集loss、验证集指标、可视化的预测样例。分类就画混淆矩阵,检测就画带框的预测图,分割就画原图和掩膜的叠加图。指标用来量化,可视化用来理解,两者配合才能判断模型到底学到了什么。
5.4 显存不够时的取舍顺序
PyTorch复现经典算法,最常见硬件瓶颈就是显存。分类模型还好,检测和分割模型输入分辨率高,显存经常直接爆掉。处理顺序我建议是:先调小batch size,配合梯度累积模拟原来的batch大小;再考虑混合精度训练,AMP在检测分割里收益明显;最后才是降低输入分辨率。千万不要一开始就砍分辨率,检测小目标和中分割细边界对分辨率非常敏感。
batch size小了之后,BatchNorm的统计量会变得不稳定,训练初期loss容易上下乱跳。如果掉到batch size等于4甚至更小,我建议换成 GroupNorm 或者使用多卡同步BN,这类问题会明显缓解。这个经验我在第六章还会展开讲,因为它是复现时翻车频率最高的细节之一。
6. 踩坑实录:复现路上最容易翻车的六个细节
最后一章写实在的踩坑记录。这些坑我基本都在某个任务里撞过,有的查了一整天才定位,写出来希望大家少走弯路。
6.1 BatchNorm与batch size打架,loss曲线像心电图
现象:检测或分割模型用小batch训练,训练loss曲线上下剧烈震荡,完全看不出收敛趋势,验证集指标也不涨。根因:BatchNorm在batch size很小时,均值和方差估计不准,尤其输入分辨率高、batch size只有2或4时,统计量抖动特别严重。
解决:第一选择是提高batch size,不行就上 GroupNorm 替换所有 BatchNorm2d,或者用 PyTorch 官方提供的 SyncBatchNorm 做多卡同步。我之前有一版分割模型batch size降到4之后,loss震荡到几乎没法看,换GroupNorm后训练立刻稳定下来。记住一条经验:BatchNorm是给一定batch size用的,不是无脑所有任务通吃。
6.2 坐标体系混用导致loss爆炸
现象:检测复现时训练loss前几十个迭代就掉到NaN,或者回归loss比分类loss大好几个数量级。根因:某个环节把 xyxy 格式的框当成了 xywh 格式在算,或者把归一化坐标和像素坐标混在一起直接进了IoU计算。
解决:在数据加载入口统一转成 xyxy 格式,在损失计算入口统一转回 xywh 格式,并且在每个转换函数里写清楚注释。我在复现过程中对坐标转换单独写了一个测试用例,输入几组已知坐标,跑一遍确认输出符合预期再进行训练,这个检查几秒钟,省下来的是通宵排查的时间。
6.3 转置卷积的棋盘效应
现象:分割图上出现规则的格子状纹理,尤其边缘区域明显。根因:转置卷积的kernel size和stride不匹配时,恢复出来的特征图会有重叠不均匀的区域,直观表现就是棋盘格。
解决:一是优先用转置卷积 kernel_size=2, stride=2 这种"刚好翻倍"配置,重叠最少;二是改用双线性插值上采样后再接一层普通卷积,代价是上采样过程不可学习,但视觉效果干净很多。我在工程中更推推荐后者,用 nn.Upsample(mode='bilinear') 加上一个 3x3 卷积来替代转置卷积,效果稳定还少一层参数。
6.4 数据增强与标签脱钩,模型学到错误映射
现象:分割训练loss能下降,但可视化预测时,目标边界和真实标签明显错位,尤其是经过了某个固定增强后,错误固定在同一个方位。根因:增强时用了两次不同的随机操作,图像翻转了但标签没翻转,或者随机裁剪时图片和标签用的裁剪参数不一致。
解决:要么用统一的增强函数同时操作图片和标签,要么用 AllKeys 风格的库保证随机种子同步。更笨但更稳的方法:每次增强后把图片和标签一起画出来看一眼,跑个十几张确认对齐,再开始训练。这个可视化检查对检测框、分割掩膜都适用。
6.5 加载预训练权重时key不匹配
现象:加载 torchvision 里预训练模型时,报 Missing key(s) 或 Unexpected key(s)。根因:模型结构跟前缀的差异,比如你的模型包装在自定义类里,权重key带了 "module." 前缀,或者分类层输出的类别数不同。
解决:打印一下 state_dict 的key列表和你模型内部命名的key列表,做一次字符串对比。常见处理是 strict=False 加载,然后单独初始化不匹配的层。我习惯写一个小工具函数,把所有不匹配的key打印出来确认是哪些层,而不是盲猜,因为很多复现时模型变量名稍微写歪一点,key就对不上了。
6.6 分割标签类别索引没对齐
现象:训练时loss正常下降,推理时预测结果却整体偏移,比如把背景预测成了第一类。根因:类别编号从一开始还是从零开始,或者数据集默认的 ignore_index 和模型输出对齐不上。
解决:确认标签像素值的取值范围,确认模型输出类别数等于最大索引值加1。如果某些类别标记为255(很多分割数据集的默认忽略值),必须在损失函数里设置 ignore_index=255。最简单的方法还是可视化:把标签索引图直接映射成彩色图看一遍,对照原图确认每个颜色对应的类别没搞混。
复现经典算法的过程,本质上就是在这些细节上不断跟自己的疏忽较劲。我自己的经验是,每次开工前先把"可视化检查"做扎实,数据可视化、锚框可视化、预测可视化,宁可多花半天确认输入输出,也不要让模型在错误数据上跑一整个晚上。
最后分享一个小习惯:每一个实验都固定随机种子,记录数据版本、增强配置、学习率、batch size、最终指标,写在同一张实验表格里。图像识别、目标检测、语义分割三个任务我都踩到过同一个问题——代码一样但结果不一样,最后发现要么是数据加载顺序变了,要么是随机种子没固定。把种子和数据版本稳住,复现才谈得上可重复,这也是整个综合整理项目里我最后才补齐的一块拼图。