☰
TinyImageNet图像分类微调:预训练权重与分层学习率实战
2026/10/28 15:24:48 网站建设 项目流程

简介:面向深度学习图像分类学习者与开发者的TinyImageNet预训练模型微调实践包,围绕小规模TinyImageNet数据集上的迁移学习展开,适合已具备PyTorch基础、希望用较少算力完成模型适配与实验复现的人群。内容包含数据加载与预处理、预训练模型加载、冻结与微调分类层、损失函数与优化器选择、训练验证及测试评估等环节的脚本组织,能帮助读者理解从ImageNet预训练权重迁移到200类64×64图像任务的完整流程,并掌握requires_grad控制、ResNet18量化相关脚本等实践要点。压缩包共34个文件,以8个Python源码为主,辅以3个shell运行脚本、20个pyc缓存文件及LICENSE、README、gitignore等说明文件,整体约58KB,目录按训练、模型、量化与工具模块划分,便于按需查阅和二次修改。已有480人学习,适合课程实验、小型课题和资源受限环境下的图像分类入门与快速验证。

1. 从 64×64 小图说起:这个项目卡住的从来不是模型

拿到 TinyImageNet 的人,第一反应基本都是「200 类、64×64、十万张训练图,拉个 ResNet 跑就完事」。真跑起来第一个小时就卡在数据上:验证集的标签藏在 txt 里、测试集压根不给标签、想用 ImageNet 预训练权重又发现输入分辨率和 224 对不上、单卡显存装不下想要的 batch。TinyImageNetTorch 这套方案要解决的不是「能不能训起来」,而是把「预训练骨干 + 微调」做成一份能复现、能交付、能讲清楚的工程:数据怎么组织、增强怎么配、哪些层冻、学习率怎么分层、中断了怎么续、最后拿什么指标验收。它适合图像分类方向做毕设或课设的人,也适合第一次把 torchvision 预训练权重接到自己数据集上的 Python 开发者。

2. 需求与选型:TinyImageNet 的规模约束下怎么挑骨干和分辨率

这个项目的需求边界其实很窄:单机单卡或者两卡、Python + PyTorch、从公开预训练权重出发、在 TinyImageNet 上把 top-1 做到一个能写进报告、能扛住追问的数字。边界窄是好事,因为每个决策都能算清楚,而不是靠调参玄学碰运气。真正需要提前定死的只有三件事——输入分辨率、骨干容量、训练预算,这三者互相咬合,任何一个拍脑袋都会在后面返工。

2.1 数据规模与显存预算:先把 batch 和分辨率钉死

TinyImageNet 的结构值得逐条拆开看,因为它直接决定了 Dataset 要写几个类。训练集是 200 个 wnid 文件夹,每个文件夹 500 张,合计 10 万张;验证集是 1 万张平铺在一个 images 目录里的图,标签单独放在 val_annotations.txt,每行是「文件名 + wnid + 框坐标」;测试集 1 万张只有图没有标签。图片原生尺寸 64×64,格式是 JPEG。这四条信息决定了后面所有工程细节,也解释了为什么很多人第一次写 Dataset 时验证集准确率会莫名其妙地崩掉。

分辨率是第一个要拍板的参数,因为它直接乘在算力上。原生的 64×64 最省,但和 ImageNet 预训练权重的假设差得远——预训练模型是在 224 输入、用 0.485/0.456/0.406 这组均值方差训出来的,浅层卷积的感受野和 BN 统计都是按 224 的尺度校准的。直接用 64 输入,等于让前几个 stage 重新学一遍。上采样到 224 能完全对齐,但 64→224 是三点五倍插值,像素细节不会凭空长出来,算力却是 112 的四倍。中间路线是 112×112:ResNet18 在 112 下前向大约 0.45 GFLOPs,只有 224 的四分之一,和预训练分布的偏移也可控。

输入分辨率相对算力与预训练分布的对齐度适用场景
64×641×偏差大,浅层要重学显存极紧、只做流程验证
112×112约 4×中等,可接受默认方案,能跑多组消融
224×224约 16×完全对齐追求极限精度、有充足算力

batch 大小不要拍脑袋,跑一个 step 看显存占用最靠谱。经验值是 12GB 卡、ResNet18 + 混合精度、112 输入,batch 128 到 192 一般能撑住;换到 224 大概只剩 64 到 96。这个数字决定了后面 BN 能不能稳、学习率能开多大,值得花二十分钟实测一遍,比事后猜「为什么收敛慢」省事得多。

2.2 骨干网络与预训练权重的选择逻辑

十万张图、200 类,在图像分类里属于中小规模。这个量级有个特点:模型容量带来的收益很快见顶,而训练稳定性和增强策略的收益反而更大。所以骨干不需要往大了堆。

骨干参数量在 TinyImageNet 上的表现倾向建议
ResNet18约 11M收敛快,基线稳首选基线,报告里的对照组
ResNet34约 21M精度通常比 18 高 1~2 个点性价比甜点,主方案
ResNet50约 25M112 输入下收益不明显需谨慎,见下方说明
MobileNetV3-Large约 5M轻量,收敛略慢端侧部署方向可考虑
ViT-S/16约 22M小数据上对增强和 schedule 极敏感第一次做不建议

ResNet50 在这个任务上有个容易被忽略的坑:它的总下采样倍率是三十二倍,112 输入进到最后一个 stage 只剩 4×4 的特征图,空间信息基本被压干了,多出来的通道容量换不来对应的精度。要用 50,输入至少得给到 160 以上,那算力预算又是另一回事。ViT 类模型在小数据上不是不能微调,但它对优化器、warmup 步数、RandAugment 强度的敏感度远高于 CNN,第一次做这个题的人很难在有限时间里把它调到能和 ResNet 对打的水平。

预训练权重的选择也有讲究。ImageNet-1k 的监督预训练权重就够了,不必去追更大的预训练集。原因很实际:预训练类别体系和 TinyImageNet 的 200 类差异越大,迁移过来的特征越偏向通用低级纹理,而更大的预训练集往往附带更严格的输入尺寸和归一化假设,反而增加适配成本。这类判断没有绝对对错,但报告里要能说出理由,而不是「我看别人都用这个」。

还有一处值得单独提:conv1 是 7×7、stride 2 的大核,在 64 或 112 输入下,它一个卷积就吃掉了相当大的感受野比例。常见做法有两个,一是把 conv1 换成 3×3、stride 1,二是保留原结构但给 stem 更小的学习率。前者改动大、收益不稳,后者更稳妥,后面分层学习率那节会用上。

2.3 目录工程结构:一份能复现的仓库骨架

毕设和课设最常见的失分点不是精度不够,而是「过两周自己都跑不起来」。所以工程结构要在写第一行业务代码之前定好,配置、数据、模型、训练、评估彻底分开。

tinyimagenet-torch/ ├── configs/ │ ├── r18_112.yaml # ResNet18 + 112 输入基线 │ └── r34_112_aug.yaml # ResNet34 + 强增强对照 ├── data/ │ ├── tiny-imagenet-200/ # 原始数据,不入版本库 │ └── splits/ # 从 train 切出的本地验证集索引 ├── src/ │ ├── datasets.py # Dataset 与增强流水线 │ ├── models.py # 骨干构建、权重加载、层替换 │ ├── engine.py # 训练/验证循环、AMP、日志 │ ├── checkpoint.py # 保存与恢复 │ └── evaluate.py # 指标计算与混淆矩阵 ├── scripts/ │ ├── train.py # 入口,只读配置不写死超参 │ └── predict.py # 对 test 集出提交文件 └── outputs/ # 权重、日志、TensorBoard

配置要放在 yaml 里而不是写死在代码里,因为答辩时一定会被问「你这个学习率是怎么定的」。有一份可改的配置,就能当场把某个超参改掉重新跑一组对照,这比嘴上解释有说服力得多。

# configs/r18_112.yaml seed: 42 data: root: data/tiny-imagenet-200 input_size: 112 # 先上采样再送进网络 num_classes: 200 model: arch: resnet18 pretrained: true replace_conv1: false # 保留 7x7 stem,用更小的 lr 补偿 train: epochs: 30 batch_size: 128 head_lr: 1.0e-3 # 新分类头 backbone_lr: 1.0e-4 # 预训练骨干 weight_decay: 5.0e-4 warmup_epochs: 1 freeze_backbone_epochs: 2

这份配置的字段顺序其实就是训练流程的顺序:先固定种子,再定数据,再定模型,最后是训练超参。把「冻结轮数」和「warmup 轮数」拆成两个独立字段是个小设计,很多人会把它们混成一个,结果解释不清为什么要先冻后放。

3. 总体设计:数据管线、模型组装、训练器三层怎么拆

这套方案按三层拆:数据层负责把两种完全不同的目录结构归一成统一的(image, label)样本,模型层负责把预训练权重装进正确的骨架并把分类头换掉,训练器层负责调度、记录和恢复。三层之间只靠张量和配置字典通信,任何一层出问题都能独立定位。这个拆法不是架构美学,而是为了排查——微调项目里九成的异常都出在数据层,如果数据层和训练循环糊在一个文件里,debug 就只能靠猜。

3.1 Dataset 与增强流水线设计

训练集和验证集必须写成两个 Dataset 类,这不是洁癖。训练集靠目录名推标签,验证集靠 txt 文件查标签,两者的索引方式根本不同,混在一个类里迟早会因为class_to_idx的构建顺序不一致而出错。标签索引必须用sorted()固定顺序,训练集和验证集共用同一份映射表,这是整个数据层最重要的一条约束。

增强流水线的设计目标是「在 64×64 的图上做增强,而不是假装它是 224」。这句话是血泪经验:很多教程直接抄 ImageNet 的标配增强,RandomResizedCrop 的默认缩放区间是 0.08 到 1.0,在 64 小图上裁出来的往往是几十像素的模糊色块,模型学到的基本是噪声。我的做法是:

  • 训练侧:RandomCrop(64, padding=8)或先上采样到 112 再RandomCrop(112, padding=12),加RandomHorizontalFlip,加轻度ColorJitter(亮度对比度 0.2 左右),最后用 ImageNet 的均值方差归一化。
  • 验证侧:只做Resize加CenterCrop加同一组归一化,绝不开任何随机性。
  • 强增强(RandAugment、CutMix、Mixup)作为可选开关,用来跑消融,不要默认就开——小数据上强增强能涨点,但在 30 个 epoch 的短 schedule 下往往涨不动,反而让训练曲线难看。

上采样这一步放在 Dataset 的 transform 里而不是网络里,好处是验证集和训练集走同一套逻辑,不会出现「训练时 112、验证时 64」这种隐蔽错误。这种错误很典型:训练 loss 正常下降,验证指标就是上不去,查半天以为是过拟合。

3.2 微调策略设计:三阶段与分层学习率

微调不是「加载权重 + 换个头 + 从头训」,那样预训练权重就白拿了。这套方案用三个阶段推进,每个阶段解决的问题不同。

第一阶段是分类头预热。骨干冻结,只训新换上的全连接层,学习率给到 1e-3。新分类头初始是随机的,如果一上来就让它和骨干一起用大学习率更新,随机初始化的梯度会顺着反向传播一路污染骨干的权重,把预训练特征冲掉。预热一到两轮就能让头进入一个合理的区间,这个现象在第一次做微调的人里翻车率极高。

第二阶段是全量微调。解冻骨干,用分层学习率:骨干 1e-4,分类头保持 1e-3,也就是头部学习率是骨干的十倍。骨干加载的是已经训好的特征,需要小步慢走;头部是新初始化的,需要快速收敛。用同一个学习率是省事,但要么骨干被冲坏,要么头收敛太慢。同时给 stem 单独一组更小的学习率(比如骨干的十分之一),用来补偿 conv1 在 112 输入下的尺度错配。

第三阶段是低学习率收尾。余弦退火把学习率降到初始值的百分之一量级,跑三到五轮,让模型落进一个更平的极小值。这一阶段经常被省掉,但它是验证指标能不能再涨零点几个点的关键,尤其是你在答辩时需要展示「最终结果」的时候。

权重衰减也要分层:对 BN 的权重和 bias 不做衰减,只对卷积和线性层的权重做。这一点在 PyTorch 里需要手动构造参数组,默认的optimizer = SGD(model.parameters(), weight_decay=...)会把 BN 参数一起衰减掉,小数据上表现为训练后期验证指标莫名下滑。

3.3 训练器与检查点设计

训练器的职责要收敛到四件事:跑一个 epoch、算指标、写日志、存检查点。任何和模型结构相关的逻辑都不该出现在这里。

检查点的内容决定了你能不能安全地中断训练。只存model.state_dict()是最常见的省事写法,代价是一旦训练被打断就只能从头再来,或者恢复后精度掉一截——因为优化器动量、学习率调度器的步数、混合精度的缩放因子全都丢了。这个坑我踩过,后来一律存全套。

字段类型作用漏存的后果
modelstate_dict模型权重无法恢复
optimizerstate_dict动量、二阶矩恢复后前几轮震荡
schedulerstate_dict当前学习率进度学习率从头开始
scalerstate_dictAMP 缩放因子混合精度溢出风险上升
epochint已完成轮次无法对齐数据顺序
best_accfloat历史最佳指标最佳权重被覆盖
configdict本次运行的超参无法复现

日志字段也要提前定:每轮记 train_loss、train_acc、val_loss、val_top1、val_top5、当前学习率、epoch 耗时。这几列数据在写报告和做消融表时直接可用,不用回头重跑。用 TensorBoard 或者直接写 CSV 都行,关键是字段名从一开始就统一,中途改名等于把前面的记录全废掉。

4. 关键实现:从 Dataset 到训练循环的可运行代码

这一章给的是能直接抄进项目的代码骨架,参数和字段与前面两章的设计一一对应。代码只保留主干,日志、进度条之类的装饰性部分省掉,重点是结构和你需要改的那几个位置。

4.1 数据加载与增强的落地实现

训练集靠目录结构推标签,验证集靠 txt 查标签,两个类共用一个class_to_idx。

import os from PIL import Image import torch from torch.utils.data import Dataset from torchvision import transforms # ImageNet 预训练的归一化统计,微调时保持一致 IMAGENET_MEAN = [0.485, 0.456, 0.406] IMAGENET_STD = [0.229, 0.224, 0.225] class TinyTrainSet(Dataset): """train/<wnid>/<img>.JPEG 结构,标签由目录名推出""" def __init__(self, root, class_to_idx, input_size=112, train=True): self.samples = [] for wnid in sorted(class_to_idx): # 排序保证索引稳定 folder = os.path.join(root, wnid, "images") if not os.path.isdir(folder): folder = os.path.join(root, wnid) for name in sorted(os.listdir(folder)): if name.lower().endswith((".jpeg", ".jpg", ".png")): self.samples.append( (os.path.join(folder, name), class_to_idx[wnid]) ) self.transform = self._build_transform(input_size, train) @staticmethod def _build_transform(size, train): if train: return transforms.Compose([ transforms.Resize(int(size * 1.15)), # 先放大再裁,避免糊 transforms.RandomCrop(size, padding=int(size * 0.1)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(0.2, 0.2, 0.2), # 轻度,别下手太狠 transforms.ToTensor(), transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD), ]) return transforms.Compose([ transforms.Resize(int(size * 1.15)), transforms.CenterCrop(size), transforms.ToTensor(), transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD), ]) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] img = Image.open(path).convert("RGB") # 强制三通道,防灰度图 return self.transform(img), label class TinyValSet(Dataset): """val/images 平铺 + val_annotations.txt 提供标签""" def __init__(self, root, class_to_idx, input_size=112): self.samples = [] ann = os.path.join(root, "val_annotations.txt") with open(ann, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split("\t") if len(parts) < 2: continue name, wnid = parts[0], parts[1] if wnid in class_to_idx: # 丢弃映射表外的类别 self.samples.append( (os.path.join(root, "images", name), class_to_idx[wnid]) ) self.transform = TinyTrainSet._build_transform(input_size, train=False) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] img = Image.open(path).convert("RGB") return self.transform(img), label

几个参数值得说明。input_size就是第 2 章定的 112,改这一处就能切到 64 或 224 做对照。int(size * 1.15)这个放大系数是为了给随机裁剪和中心裁剪留边,比例太小裁不出变化,太大等于把所有图都放大成模糊块。ColorJitter的 0.2 是刻意调低的,小图上颜色扰动过强会破坏判别性纹理。convert("RGB")不能省,原始数据里混有少量灰度或四通道图,不统一通道数会在第一个 batch 就报错。

验证集里那句if wnid in class_to_idx也要留着,它的作用是过滤掉标注文件里可能出现的、训练集没有的类别,防止KeyError直接崩掉整个训练。

4.2 分层学习率与参数组构造

参数组构造是分层学习率落地的地方,写错这一步,前面所有策略讨论都白费。

def build_param_groups(model, head_lr=1e-3, backbone_lr=1e-4, wd=5e-4): """ 返回三组参数: head -> 新分类头,大 lr backbone -> 预训练卷积层,小 lr stem -> conv1 + bn1,更小 lr(可选) 同时把 BN 权重和 bias 从 weight decay 中排除。 """ head_names = ("fc", "classifier", "head") stem_names = ("conv1", "bn1") groups = { "head": {"params": [], "lr": head_lr, "weight_decay": 0.0}, "stem": {"params": [], "lr": backbone_lr * 0.1, "weight_decay": 0.0}, "backbone": {"params": [], "lr": backbone_lr, "weight_decay": wd}, } seen = set() for name, p in model.named_parameters(): if not p.requires_grad: continue if name.startswith(head_names): key = "head" elif name.startswith(stem_names): key = "stem" else: key = "backbone" # BN 的 weight/bias 不做衰减,避免后期指标下滑 is_bn = ".bn" in name or name.startswith("bn") if is_bn and name.endswith(("weight", "bias")): groups[key].setdefault("_no_wd", []).append(p) else: groups[key]["params"].append(p) seen.add(name) # 把 BN 参数单独拆成不衰减的组 result = [] for key, g in groups.items(): if g["params"]: result.append(g) for p in g.get("_no_wd", []): result.append({"params": [p], "lr": g["lr"], "weight_decay": 0.0}) return result

三个组对应三种更新节奏。head组负责快速拟合新类别,权重衰减设为 0 是因为分类头的参数量本来就少,再衰减容易欠拟合。stem组的学习率是骨干的十分之一,对应第 2 章提到的 conv1 尺度错配问题。_no_wd那一段是整段代码里最容易被忽略、收益又最直接的部分:BN 的缩放和平移参数本质上是统计量的调节旋钮,加衰减会让它们被拉向 0,训练后期的表现就是验证指标缓慢下滑。

需要注意,这个函数要求调用前已经设置好requires_grad。冻结骨干的阶段,骨干参数requires_grad=False,函数会自动跳过它们,所以同一套代码能同时支撑预热阶段和全量微调阶段,不用写两份优化器构造逻辑。

4.3 训练与验证循环

循环里最重要的是混合精度的使用位置和梯度裁剪的时机。

import torch from torch.cuda.amp import autocast, GradScaler def train_one_epoch(model, loader, optimizer, scaler, criterion, device, grad_clip=1.0): model.train() total_loss, correct, total = 0.0, 0, 0 for imgs, labels in loader: imgs = imgs.to(device, non_blocking=True) labels = labels.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) # 比 zero_grad() 省显存 with autocast(): # 前向用半精度 logits = model(imgs) loss = criterion(logits, labels) scaler.scale(loss).backward() # 梯度缩放后再反传 if grad_clip: scaler.unscale_(optimizer) # 先还原再裁剪 torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) scaler.step(optimizer) scaler.update() total_loss += loss.item() * labels.size(0) correct += (logits.argmax(1) == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total @torch.no_grad() def evaluate(model, loader, criterion, device, topk=(1, 5)): model.eval() total, loss_sum = 0, 0.0 top_correct = {k: 0 for k in topk} for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) with autocast(): logits = model(imgs) loss_sum += criterion(logits, labels).item() * labels.size(0) # 一次算出 top-1 和 top-5,省一轮前向 maxk = max(topk) _, pred = logits.topk(maxk, dim=1, largest=True, sorted=True) for k in topk: hit = (pred[:, :k] == labels.view(-1, 1)).any(dim=1) top_correct[k] += hit.sum().item() total += labels.size(0) metrics = {f"top{k}": v / total for k, v in top_correct.items()} metrics["loss"] = loss_sum / total return metrics

scaler.unscale_(optimizer)那行是顺序问题,很多人写成直接clip_grad_norm_,结果裁的是被放大过的梯度,阈值等于失效。set_to_none=True是把梯度置空而不是置零,省一次写显存的操作,在 30 轮训练里能省出一杯咖啡的时间。验证函数里topk一次算完也是同样的思路:top-5 指标在 200 类任务上是必报的,单独再跑一遍验证等于浪费一半时间。

model.eval()和torch.no_grad()都不能漏,前者切换 BN 和 Dropout 的行为,后者关掉计算图。少任何一个,显存占用都会翻倍,而且验证指标会比你预期的低——BN 在验证时用 batch 统计量,batch 一小结果就抖。

4.4 断点续训与日志

def save_checkpoint(path, model, optimizer, scaler, scheduler, epoch, best_acc, config): torch.save({ "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scaler": scaler.state_dict(), "scheduler": scheduler.state_dict(), "epoch": epoch, "best_acc": best_acc, "config": config, # 存配置,方便日后复现 }, path) def load_checkpoint(path, model, optimizer=None, scaler=None, scheduler=None): ckpt = torch.load(path, map_location="cpu") model.load_state_dict(ckpt["model"]) if optimizer is not None: optimizer.load_state_dict(ckpt["optimizer"]) if scaler is not None and "scaler" in ckpt: scaler.load_state_dict(ckpt["scaler"]) if scheduler is not None: scheduler.load_state_dict(ckpt["scheduler"]) return ckpt["epoch"], ckpt["best_acc"], ckpt.get("config", {})

map_location="cpu"是为了让检查点能在不同机器之间搬运,先落到 CPU 再加载进模型,避免显卡编号变化导致加载失败。config一起存进去是个小习惯,但它解决了「两周后想不起来这组结果是哪套超参跑出来的」这个经典问题,做消融表的时候会感谢自己。

存储策略建议同时保留last.pth和best.pth两个文件:前者每轮覆盖,后者只在验证指标刷新时写入。中断后用last.pth续训,出报告时用best.pth评估,两者职责不冲突。

5. 避坑与排查:微调 TinyImageNet 时最容易翻车的五件事

这一章的五条都是按「现象 → 原因 → 解决」写的,每条对应一个我在类似项目里真见过或者真踩过的问题。如果你正在跑训练,建议对照着检查一遍,尤其是前两条,它们的影响最大、排查成本也最高。

5.1 验证集准确率第一轮就九十多

现象很直观:第一个 epoch 结束,val top-1 直接给到 0.9 以上,训练 loss 也在快速下降。看到这种数字先别高兴,200 分类任务上用十万张图微调,第一轮能到 0.9 基本只有一种解释。

原因通常是数据泄漏。最常见的形态是从 train 目录里切验证集时,用文件名的随机采样切分,但同一个 wnid 的图片存在高度相似甚至重复的样本,切分后 train 和 val 里出现了近重复图;另一种形态是把 test 集的伪标签结果混进了验证流程,指标自然虚高。还有一种更隐蔽的:多次实验之间复用了同一个outputs/目录,best.pth是上一组实验留下的,评估脚本又默认加载best.pth。

解决办法是把切分逻辑写死并单独落盘:从 train 里按类别分层抽样,每类留出固定数量做本地验证,索引写进splits/local_val.txt,训练脚本只读这个文件。评估前强制打印检查点的路径和修改时间,发现对不上就直接退出。另外,正式报告里应该同时给出「本地验证集」和「官方 val 集」两组数字,前者用于调参,后者用于最终结论,两套数据不混用。

5.2 loss 在降但 top-1 一直贴着随机水平

现象是训练 loss 从 5.3 慢慢降到 4 左右,看起来在学,但 top-1 常年在 0.5% 附近晃,也就是 200 类里的随机猜测水平。

原因几乎总是标签映射错位。训练集的类别索引来自sorted(os.listdir(train_root)),验证集的索引来自val_annotations.txt里的 wnid 字符串,如果验证集的映射表是独立构建的、没有和训练集共用同一份class_to_idx,两边的索引就是两套完全不同的编号。模型学得再好,对不上号就等于全错。这种情况 loss 依然会降,因为模型确实在拟合训练分布,只是验证时标签被错位解释了。

解决办法只有一个:class_to_idx在训练脚本入口处构建一次,作为参数传给两个 Dataset,绝不允许各自sorted()一遍。加一段断言检查更保险:训练前打印len(class_to_idx),确认是 200;随机抽 20 个验证样本,人工比对文件名里的 wnid 和映射表。这类检查花五分钟,能省掉一整天。

5.3 训练 loss 一路降,验证指标来回震荡

现象是训练侧指标稳步上升,验证侧每个 epoch 上下波动三到五个点,看不出趋势。

原因通常出在 BN 的统计量上。小 batch、强增强、加上从 64 上采样到 112 带来的插值模糊,三者叠加会让每个 batch 的均值方差估计很不稳定。BN 在训练时用当前 batch 的统计量,验证时用滑动平均,两者差距一大,指标就抖。

解决办法有三条,按代价从低到高。第一,把 batch size 提到 128 以上,或者用梯度累积模拟大 batch,累积步数要和 scheduler 的步数计算对齐。第二,训练后期解冻 BN 的统计量更新,或者干脆全程冻结 BN 的 running stats,让预训练统计量保持不变,这在数据量不大、batch 又小的时候效果明显。第三,把 BN 换成 GroupNorm,代价是收敛速度会慢一些,但稳定性提升明显。我一般先用第二条,成本最低。

5.4 恢复训练后精度掉一截

现象是从last.pth续训,前两三轮的验证指标比中断前低了两个点以上,之后才慢慢爬回来。

原因是检查点只存了模型权重,优化器动量和学习率调度器状态丢了。动量重置为零相当于让优化器重新起步;调度器重置更是直接把学习率打回初始值,本来已经退火到低位的 lr 突然跳高,模型被踢出当前的极小值。

解决办法就是第 4 章那段save_checkpoint的完整字段。如果已经踩了坑,补救方式是把学习率手动设成中断时的值再续训,虽然不如原状态精确,但比从初始 lr 重来强。这条经验后来变成了我的固定习惯:任何超过十轮的训练,检查点必须全套。

5.5 换台机器结果就对不上

现象是同一份代码、同一份数据,在另一台机器上跑出来的最终指标差了一到两个点,训练曲线形状也不一样。

原因是随机性没有完全固定。PyTorch 的随机源不止torch.manual_seed一个:NumPy、Python 内置 random、DataLoader 的 worker 进程,各自有独立的种子;CUDA 侧的 cuDNN 默认会做基准测试选最快算法,不同显卡选出来的算法不同,浮点累加顺序就不同。

解决办法是写一个set_seed函数,把四个种子一起固定,加上torch.backends.cudnn.benchmark = False和torch.backends.cudnn.deterministic = True。DataLoader 里给generator传一个固定种子的生成器,worker_init_fn也设一下。要说明的是,完全确定性会牺牲一点速度,但对毕设和课设这种需要「可复现」的场景,这点速度换得值。如果换了不同型号的卡,就算种子全固定也可能有零点几个点的差异,这是硬件层面的,报告里注明即可,不用死磕。

6. 验收与答辩:用一张消融表证明微调真的生效

这套方案的验收不看你嘴上说做了什么,看四组数字能不能自洽。第一组是基线,从头训 ResNet18,不给预训练权重;第二组只加载权重、冻结骨干、只训分类头;第三组是完整的三阶段微调;第四组在第三组基础上加增强或换骨干。四组用同一份数据切分、同一个评估脚本、同一个随机种子,差异只来自你声称的那个变量。这张表就是答辩时最硬的证据,因为它把「预训练到底有没有用」「分层学习率到底有没有用」拆成了可量化的两行差值。

验收项合格线检查方式说明
官方 val top-1高于基线 8 个点以上固定 best.pth 评估一次证明预训练迁移有效
官方 val top-5有明确数值并写进报告同一次评估200 类任务必报
分层学习率增益相对单一 lr 有正向差值两组对照实验差值小也要如实写
训练可复现同种子两次结果差 < 0.5 点重跑一次对比检验种子与确定性设置
断点续训恢复后曲线连续第 5 轮中断再续检验检查点完整性
test 集提交文件格式正确、行数与样本数一致脚本校验只有图没有标签,别硬算指标

填这张表时有两个诚实性要求。一是基线必须真跑,不能引用文献里的数字,因为你的数据切分、增强、schedule 都和文献不同,跨条件比较没有意义。二是差值小的时候照实写,比如分层学习率只带来 0.6 个点,那就写 0.6 个点,然后解释为什么在 112 输入、30 轮预算下收益有限。答辩时被追问「为什么这么小」,你能答出「因为预热阶段已经让分类头进入合理区间,全量微调的 lr 差异被压缩了」,比硬编一个漂亮数字安全得多。

讲消融实验有个技巧:按「假设 → 变量 → 结果 → 结论」四句话组织,一组实验控制在两分钟内讲完。比如「假设是预训练特征在 112 输入下仍然有效,变量是有无预训练权重,结果是从头训 41.2、微调 63.8,结论是迁移增益 22 个点,说明即使分辨率错配,浅层特征依然可复用」。四句话讲完,评委不会追问细节,因为你已经主动交代了最关键的那个疑点。

从那以后我每次做这类微调项目,都会在写第一行训练代码之前先把验收表和四组实验的配置文件名定下来,跑完直接填表。这样做的最大好处不是省事,而是逼着自己在动手前想清楚「我要证明什么」,而不是跑完一堆实验再回头找故事。希望这套结构能帮到你,把毕设从「跑出个数」变成「说清一件事」。

本文还有配套的精品资源,点击获取

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

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

立即咨询