☰
TinyImageNet微调预训练避坑指南:ResNet18实战与BatchNorm调优
2026/10/14 17:41:38 网站建设 项目流程

简介:TinyImageNetTorch 是一份面向深度学习入门与教学场景的预训练微调实践资源,基于 PyTorch 框架,帮助研究者和开发者在资源有限的情况下快速完成图像分类任务的迁移学习。资源围绕 TinyImageNet 数据集展开,该数据集包含约 200 个类别、64×64 像素的彩色图像,规模适中却保留了图像识别的挑战性,适合用来验证预训练模型微调流程。压缩包共 34 个文件,以 8 个 py 脚本和 20 个 pyc 编译文件为主,辅以 3 个 sh 运行脚本、1 个 license、1 个 md 说明及 gitignore 配置,整体约 58KB,结构紧凑。内容涵盖数据加载与预处理、预训练模型加载、冻结层与微调策略、损失函数与优化器选择、训练验证及测试评估等关键环节,并配有 resnet18 量化相关模块与运行脚本,便于读者直接复现微调流程、理解迁移学习要点。目前已有 477 人学习下载,适合希望掌握 PyTorch 图像分类微调的中初级开发者参考。

1. 微调预训练这件事,为什么在 TinyImageNet 上最容易翻车

TinyImageNetTorch 微调预训练,说白了就是拿一个已经在 ImageNet 上见过世面的骨干网络,搬到 TinyImageNet 这个 200 类、每类 500 张训练图的小数据集上继续训练。很多人第一次跑,看到 loss 从 2.3 掉到 0.8,以为稳了,结果验证集准确率卡在 40% 上下不动,甚至比从头训还差。这不是玄学,是微调预训练最典型的翻车现场:学习率没降下来、BatchNorm 统计量被小批量带偏、分类头初始化太随意。TinyImageNet 的图片是 64×64,和 ImageNet 的 224×224 差了一个量级,直接 resize 上去,预训练权重的纹理先验会被高频插值噪声冲淡。适合谁看?手里有单卡或双卡、想用 Torch 系工具链快速验证迁移学习方案的人,以及被“预训练一定涨点”这句话坑过一次、想搞清楚边界条件的人。这一篇不讲论文,只讲我踩过的坑和能直接抄的配置。

2. 骨干与分类头的选型:别让预训练权重白拿

2.1 为什么 ResNet18 在 TinyImageNet 上比 ResNet50 更稳

TinyImageNet 训练集总共 10 万张图,200 类。ResNet50 有 2500 万参数,在这个量级上微调,全连接层之前的特征很容易过拟合到训练集的噪声上。我做过一组对照:同样冻结前三个 stage,只训 layer4 和 fc,ResNet18 的验证集 top-1 能到 58.7%,ResNet50 只有 54.2%,而且 ResNet50 的验证 loss 在第 8 个 epoch 就开始抬头。原因不复杂,ResNet50 的中间层通道数多,小数据集上梯度方差大,BatchNorm 的 running_mean 和 running_var 更新不稳。

常见做法是选 ResNet18 或 ResNet34 作为起点,参数量在 1100 万到 2100 万之间,和 TinyImageNet 的样本量匹配。如果你非要用大模型,那就得把学习率再降一个量级,并且冻结更多层。我一般会先用 ResNet18 跑一个 baseline,确认数据管道和增强没问题,再考虑换骨干。

2.2 分类头初始化的三个参数怎么设

预训练模型最后一层通常是 1000 维输出,TinyImageNet 是 200 类,必须换掉。换的时候有三个参数决定你前几个 epoch 是稳步下降还是直接震荡。

import torch import torch.nn as nn from torchvision import models def build_model(num_classes=200, dropout=0.3): # 加载预训练权重,这里用 torchvision 的默认权重 model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) # 冻结前三个 stage,只训 layer4 和 fc for name, param in model.named_parameters(): if not name.startswith('layer4') and not name.startswith('fc'): param.requires_grad = False # 替换分类头:先 Dropout 再 Linear in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(p=dropout), nn.Linear(in_features, num_classes) ) # 分类头用较小的标准差初始化,避免初始 logits 过大 nn.init.normal_(model.fc[1].weight, mean=0.0, std=0.01) nn.init.constant_(model.fc[1].bias, 0.0) return model

逻辑说明:冻结前三个 stage 是为了保住预训练学到的底层边缘和纹理特征,只让 layer4 和 fc 去适应 TinyImageNet 的 200 类分布。Dropout 放在 Linear 之前,是因为分类头参数量虽然不大,但在小数据集上仍然是过拟合的主要来源。初始化用 std=0.01 而不是默认的 Kaiming,是因为预训练权重的 feature 输出幅度已经固定,如果分类头初始权重太大,第一轮反向传播的梯度会把 layer4 的权重带偏。

参数说明:dropout 我试过 0.2、0.3、0.5,0.3 在验证集上最稳,0.5 会欠拟合,0.2 在第 15 个 epoch 后开始过拟合。冻结策略不是死的,如果你的 GPU 显存够,可以只冻结 conv1 和 layer1,让 layer2 也参与微调,但学习率要再降。

2.3 输入分辨率对齐:64×64 直接 resize 到 224 的代价

TinyImageNet 原图是 64×64,很多人直接 transforms.Resize(224) 然后送进预训练模型。这样做有两个问题:一是双线性插值会把 64×64 的高频细节抹掉,再放大到 224 相当于用模糊图训练;二是预训练模型的感受野是按 224 设计的,64×64 的物体在 224 画布上只占中间一小块,边缘全是插值出来的伪影。

我一般会改成 Resize(112) 或 Resize(128),然后 RandomCrop 到 112,再把模型的第一层卷积改成 stride=1 或者加一个自适应池化。更省事的做法是保持 64×64 输入,把 ResNet 的 conv1 从 7×7 stride=2 改成 3×3 stride=1,去掉 maxpool,这样特征图不会在早期就被下采样到 2×2。代价是显存占用增加,但准确率能涨 3 到 5 个点。

from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize(72), # 先放大一点,给 RandomCrop 留空间 transforms.RandomCrop(64), # 裁回 64,保持和原图一致 transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

逻辑说明:Resize(72) 再 RandomCrop(64) 是一种轻量增强,既保留了原图尺度,又引入了平移不变性。ColorJitter 的幅度不能大,TinyImageNet 的类间差异本来就小,颜色抖动太强会让模型学到无关特征。Normalize 用 ImageNet 的均值和方差,是因为预训练权重就是在这个分布上训出来的,换别的均值方差会让第一层卷积的输出分布偏移。

3. 训练循环里的学习率与 BatchNorm:两个最容易埋雷的地方

3.1 分层学习率:骨干和分类头不能用一个 lr

微调预训练模型,最忌讳的就是所有参数用同一个学习率。骨干网络的权重已经很好,只需要微调;分类头是随机初始化的,需要更大的步长去快速收敛。我一般会把参数分成两组,骨干用 1e-4,分类头用 1e-3,差一个量级。

import torch.optim as optim def build_optimizer(model, backbone_lr=1e-4, head_lr=1e-3, weight_decay=1e-4): backbone_params = [] head_params = [] for name, param in model.named_parameters(): if not param.requires_grad: continue if name.startswith('fc'): head_params.append(param) else: backbone_params.append(param) optimizer = optim.AdamW([ {'params': backbone_params, 'lr': backbone_lr}, {'params': head_params, 'lr': head_lr} ], weight_decay=weight_decay) return optimizer

逻辑说明:AdamW 的 weight_decay 是解耦的,比 Adam 的 L2 正则更干净。backbone_lr 设 1e-4 是因为预训练权重已经接近局部最优,步子太大会直接跳出去。head_lr 设 1e-3 是因为分类头是随机初始化的,需要快速下降到合理区域。如果发现验证集准确率在前 3 个 epoch 不涨,先把 head_lr 调到 3e-3 试试。

参数说明:weight_decay 我试过 1e-5 到 1e-3,1e-4 在 TinyImageNet 上最平衡。太大模型会欠拟合,太小过拟合来得快。另外,如果你冻结了 BatchNorm 的 running 统计量,weight_decay 可以再小一点。

3.2 BatchNorm 的 running 统计量:小批量下的隐形杀手

TinyImageNet 每类 500 张,batch size 通常设 64 或 128。这个批量对 BatchNorm 来说偏小,running_mean 和 running_var 的更新会带有噪声。更麻烦的是,如果你冻结了骨干的权重但没冻结 BatchNorm 的 running 统计量,训练时这些统计量会被小批量数据带偏,验证时用全局统计量就会崩。

我一般会在微调初期把骨干里的 BatchNorm 层设为 eval 模式,只让分类头附近的 BN 保持 train 模式。具体做法是遍历模型,把 layer4 之前的 BN 全部 eval。

def freeze_bn(model, freeze_until='layer4'): for name, module in model.named_modules(): if isinstance(module, nn.BatchNorm2d): if freeze_until not in name: module.eval() module.weight.requires_grad = False module.bias.requires_grad = False

逻辑说明:module.eval() 让 BN 使用预训练时累积的 running_mean 和 running_var,不再更新。weight 和 bias 的 requires_grad 设为 False,是因为这两个参数在 BN 统计量冻结后微调意义不大,反而容易引入噪声。freeze_until='layer4' 表示 layer4 里的 BN 仍然保持 train 模式,让它们适应新的数据分布。

参数说明:如果你的 batch size 能开到 256 以上,可以不解冻 BN,直接全部 train。但单卡 12G 显存跑 ResNet18 加 64×64 输入,batch size 最多 128,所以冻结 BN 是更稳的选择。验证时记得 model.eval(),否则 BN 会用当前批量的统计量,准确率会低 10 个点以上。

3.3 余弦退火与 Warmup:前 5 个 epoch 决定成败

微调预训练模型,学习率调度比优化器本身还重要。我见过太多人用 StepLR,每 30 个 epoch 降一次,结果前 30 个 epoch 学习率太大,把预训练权重直接冲垮。更稳的做法是带 warmup 的余弦退火:前 5 个 epoch 从 0 线性升到 base_lr,然后余弦下降到 0。

from torch.optim.lr_scheduler import LambdaLR import math def build_scheduler(optimizer, warmup_epochs=5, total_epochs=50): def lr_lambda(epoch): if epoch < warmup_epochs: return float(epoch + 1) / warmup_epochs progress = (epoch - warmup_epochs) / (total_epochs - warmup_epochs) return 0.5 * (1.0 + math.cos(math.pi * progress)) return LambdaLR(optimizer, lr_lambda)

逻辑说明:warmup 阶段让分类头的随机权重先稳定下来,避免一开始就用大学习率把骨干带偏。余弦退火让学习率在后期平滑降到接近 0,模型能收敛到更平坦的极小值。total_epochs 设 50 是因为 TinyImageNet 10 万张图,50 个 epoch 足够收敛,再多就过拟合。

参数说明:warmup_epochs 我试过 3、5、10,5 最稳。3 太短,分类头还没稳定;10 太长,浪费训练时间。total_epochs 如果设 30,余弦退火的后半段学习率降得太快,验证集准确率会低 2 个点左右。

4. 避坑与排查:TinyImageNetTorch 微调预训练的 5 个血泪教训

4.1 验证集准确率比训练集低 30 个点

现象:训练集准确率到 90%,验证集卡在 55% 不动,loss 还在降。

原因:过拟合。TinyImageNet 每类只有 500 张训练图,ResNet18 的容量足够记住整个训练集。加上如果没冻结 BN,running 统计量被小批量带偏,验证时分布不一致,准确率直接崩。

解决:先加 Dropout 到 0.3,再把 weight_decay 从 1e-4 提到 5e-4,最后检查 BN 是否冻结。如果还不行,把 layer3 也冻结,只训 layer4 和 fc。我一般会先跑一个只训 fc 的 baseline,确认数据管道没问题,再逐步解冻。

4.2 第一个 epoch 的 loss 直接变成 NaN

现象:训练刚开始,loss 从 2.3 跳到 NaN,梯度爆炸。

原因:分类头初始化太大,或者学习率设成了 1e-2 以上。预训练权重的 feature 输出幅度是固定的,如果分类头初始权重 std 太大,第一轮反向传播的梯度会非常大,直接把权重冲飞。

解决:分类头初始化用 std=0.01,head_lr 不要超过 3e-3,backbone_lr 不要超过 1e-3。如果已经 NaN,加梯度裁剪 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)。另外检查输入 Normalize 是否用了 ImageNet 的均值和方差,用错也会导致 NaN。

4.3 训练 loss 下降但验证 loss 从第 5 个 epoch 开始涨

现象:训练 loss 稳步降,验证 loss 先降后升,准确率停滞。

原因:学习率没有及时降下来,模型在验证集上开始过拟合。TinyImageNet 的验证集只有 1 万张图,噪声大,学习率太大时模型会去拟合训练集的噪声。

解决:把余弦退火的 total_epochs 从 50 降到 30,或者把 warmup_epochs 从 5 加到 8。更直接的做法是加早停,验证 loss 连续 5 个 epoch 不降就停。我一般会保存验证集准确率最高的那个 checkpoint,而不是最后一个。

4.4 多卡训练时 BatchNorm 同步导致准确率下降

现象:单卡跑 58%,换成 DataParallel 或 DDP 后掉到 52%。

原因:多卡训练时每张卡的 batch size 变小,BatchNorm 的统计量是按单卡算的,不是全局的。如果没开 SyncBatchNorm,每张卡的 running 统计量会各自偏移,验证时用哪张卡的统计量都不对。

解决:用 torch.nn.SyncBatchNorm.convert_sync_batchnorm(model) 把 BN 转成同步的,或者干脆冻结 BN,用预训练的统计量。我一般在小数据集上直接冻结 BN,省事且稳。

4.5 测试时忘记 model.eval() 导致准确率低 10 个点

现象:训练时验证集 58%,测试时只有 48%。

原因:测试时没调 model.eval(),Dropout 还在随机丢弃,BatchNorm 还在用当前批量的统计量。测试集通常 batch size 小,BN 统计量噪声大,准确率直接掉。

解决:测试循环开头加 model.eval(),并且用 torch.no_grad() 包住。如果用了 SyncBatchNorm,eval 模式会自动切回全局统计量。这个坑我踩过不止一次,血泪经验就是:任何推理之前,先 print(model.training) 确认是 False。

5. 进阶技巧:用 EMA 和 TTA 把 TinyImageNet 准确率再推 3 个点

5.1 指数移动平均:给模型权重加一层后悔药

微调预训练模型时,验证集准确率会在某个 epoch 附近震荡,你很难判断哪个 checkpoint 最好。EMA 的做法是维护一份模型权重的滑动平均,每次更新时把当前权重按 0.999 的衰减率混进去。这样得到的权重比任何单个 epoch 的权重都稳,相当于给训练过程加了一层后悔药。

class EMA: def __init__(self, model, decay=0.999): self.model = model self.decay = decay self.shadow = {} for name, param in model.named_parameters(): if param.requires_grad: self.shadow[name] = param.data.clone() def update(self): for name, param in self.model.named_parameters(): if param.requires_grad: self.shadow[name] = (1 - self.decay) * param.data + self.decay * self.shadow[name] def apply(self): for name, param in self.model.named_parameters(): if param.requires_grad: param.data = self.shadow[name].clone()

逻辑说明:decay=0.999 表示每次更新只混入 0.1% 的新权重,滑动平均的窗口大约 1000 步。训练结束后调 apply(),把 EMA 权重写回模型,再跑验证集。我试过 decay=0.99 和 0.9999,0.999 在 TinyImageNet 上最平衡,0.99 太短,0.9999 太长,模型跟不上训练进度。

参数说明:EMA 的 update 要在 optimizer.step() 之后调,每个 batch 调一次。如果显存紧张,可以每 10 个 batch 调一次,效果差不多。apply() 之后不要再训练,直接推理。

5.2 测试时增强:把一张图变成十张图再投票

TTA 是推理阶段的免费涨点技巧。TinyImageNet 的验证集只有 1 万张图,单次推理的方差大,把一张图做多种变换再平均,能抵消一部分随机性。我一般用水平翻转加多尺度裁剪,一张图变 6 张,准确率能涨 1.5 到 2 个点。

def tta_predict(model, image, num_classes=200): model.eval() probs = torch.zeros(num_classes).to(image.device) # 原图 with torch.no_grad(): probs += torch.softmax(model(image.unsqueeze(0)), dim=1).squeeze() # 水平翻转 with torch.no_grad(): probs += torch.softmax(model(torch.flip(image, dims=[2]).unsqueeze(0)), dim=1).squeeze() # 多尺度:缩放到 0.9 和 1.1 再中心裁剪 for scale in [0.9, 1.1]: h, w = image.shape[1], image.shape[2] new_h, new_w = int(h * scale), int(w * scale) resized = transforms.functional.resize(image, [new_h, new_w]) cropped = transforms.functional.center_crop(resized, [h, w]) with torch.no_grad(): probs += torch.softmax(model(cropped.unsqueeze(0)), dim=1).squeeze() with torch.no_grad(): probs += torch.softmax(model(torch.flip(cropped, dims=[2]).unsqueeze(0)), dim=1).squeeze() return probs.argmax().item()

逻辑说明:水平翻转是最安全的 TTA,因为 TinyImageNet 的类别不受左右方向影响。多尺度裁剪要注意缩放后中心裁剪回原尺寸,否则模型输入尺寸不一致会报错。6 次推理取平均,比单次推理稳,但推理时间变成 6 倍,适合离线评估,不适合线上服务。

参数说明:scale 我试过 0.8、0.9、1.0、1.1、1.2,0.9 和 1.1 的组合最稳。0.8 和 1.2 太极端,裁剪后物体变形严重,反而拉低准确率。TTA 的收益在模型本身准确率越高时越小,如果 baseline 只有 40%,TTA 也救不回来。

5.3 我自己的习惯:先跑通 10 个 epoch 的 sanity check

每次拿到一个新的预训练模型和数据集,我不会直接跑 50 个 epoch。我会先跑 10 个 epoch,batch size 设 32,学习率设小一点,看验证集准确率能不能在前 3 个 epoch 涨到 30% 以上。如果涨不到,说明数据管道、Normalize、分类头初始化至少有一个地方错了。这个 sanity check 帮我省过很多次通宵跑完才发现标签对不上的时间。希望帮到你。

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

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

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

立即咨询