VGG19图像分类实验全流程:迁移学习微调与显存优化
简介:面向智能计算系统课程实验的VGG19图像分类资料包,适合正在学习卷积神经网络、模型量化与异构硬件部署的高校学生及开发者,也可用于课程实验复现与课程设计参考。内容围绕经典VGG19架构展开,可用于理解多层小尺寸卷积堆叠、池化降维与全连接分类的整体流程,并借助已训练好的INT8量化模型完成推理验证。资源共4个文件,以3个Python脚本和1个pb模型文件为主,脚本承担CPU与MLU双平台评估任务,模型文件提供轻量化权重,压缩包约95.86MB。已有2152人学习下载。读者可据此对比CPU与硬件加速器上的推理表现,观察INT8量化对内存占用和运行速度的影响,并接触图像预处理、前向传播、Softmax分类及准确率统计等环节,积累模型部署、量化优化与性能评估的实践思路。资源将深度模型使用、硬件加速与模型量化置于同一实验场景中,便于形成从训练产物到部署验证的完整认知。
1. 从一张 224×224 的图说起:这份 VGG19 图像分类实验包到底装了什么
一张 224×224 的图片喂进去,几秒钟吐出 1000 维的类别概率——VGG19 做图像分类,整个动作就这么朴素。这份智能计算系统实验包围绕的正是这件事:用 16 层 3×3 卷积堆出五个 block,接三层全连接,从数据加载、训练、验证一路走到推理与可视化,把人工智能课程里最常被要求「跑出数字」的那一环完整走通。它适合两类人:一类是刚上手深度学习、需要一份能复现、能写进实验报告的完整脚本的人;另一类是拿 VGG19 当 baseline、准备跟 ResNet 或 MobileNet 做对比的老手。代码不花哨,但每个参数都能改、每个中间张量都能打出来看,跑完一遍会对「深而窄的卷积堆叠」这件事有体感,而不是只记住一个名字。
2. 拆开 VGG19:16 层卷积加 3 层全连接,参数都花在哪
动手之前先把结构账算清楚,后面显存炸了、训练慢了、微调效果反而不如冻结,这些问题才找得到源头。VGG19 的结构非常规整:五个卷积 block,每个 block 内所有卷积都是 3×3、stride 1、padding 1,紧跟一个 2×2、stride 2 的最大池化,每过一次池化特征图边长减半、通道数翻倍。规整的代价是参数量和计算量都压在前面的全连接上,这也是这套实验里最值得改的一处。
2.1 五个卷积块的通道翻倍节奏
把 vgg19 的 features 打出来,37 个模块一目了然。block1 和 block2 各两个卷积,block3、block4、block5 各四个卷积,通道依次是 64、128、256、512、512。输入 224×224 走完全程,空间尺寸的轨迹是 224 → 112 → 56 → 28 → 14 → 7。
| block | 卷积层数 | 输入→输出尺寸 | 输出通道 | features 索引 |
|---|---|---|---|---|
| block1 | 2 | 224 → 112 | 64 | 0–4 |
| block2 | 2 | 112 → 56 | 128 | 5–9 |
| block3 | 4 | 56 → 28 | 256 | 10–18 |
| block4 | 4 | 28 → 14 | 512 | 19–27 |
| block5 | 4 | 14 → 7 | 512 | 28–36 |
这张表在实操里有两个用途。第一,判断该解冻到哪一层:小数据集微调通常只解冻 block4 和 block5,也就是 features[19:] 这 18 个模块,再往前动容易把 ImageNet 学到的通用边缘、纹理特征打散。第二,定位 hook 挂载点:想做特征图可视化,挂在 features[35] 的 ReLU 输出上拿到的是 7×7×512 的最后一层卷积响应,挂在 features[19] 上拿到的是 14×14×512,分辨率更高、语义更弱,看注意力分布通常用后者更清楚。
3×3 卷积反复堆叠的价值在于感受野的等效扩张:两个 3×3 叠起来的感受野等于一个 5×5,三个等于 7×7,但参数量和非线性次数都更优。VGG19 把这个思路推到极致,代价是中间特征图尺寸大、卷积次数多,推理时显存和算力都不便宜。
2.2 全连接层的参数量账本
用三行代码把参数分布算出来,比背任何结论都管用:
import torch from torchvision import models net = models.vgg19(weights=None) total = sum(p.numel() for p in net.parameters()) conv = sum(p.numel() for p in net.features.parameters()) fc = sum(p.numel() for p in net.classifier.parameters()) print(f"total={total/1e6:.1f}M conv={conv/1e6:.1f}M fc={fc/1e6:.1f}M") # 输出:total=143.7M conv=20.0M fc=123.6M这段代码只做一件事:把 features 和 classifier 的参数分开统计,看清 143.7M 总参数里卷积只占 20M,剩下 123.6M 全在分类头。再拆细一点,第一层 fc 的输入是 512×7×7=25088,输出 4096,单这一层就是 25088×4096≈1.03 亿参数;第二层 4096×4096 是 1678 万;第三层 4096×1000 是 410 万。
参数量直接换算成显存:float32 下每参数 4 字节,光是第一层 fc 的权重就占 411MB,三层合计约 494MB。这还只是权重,训练时 Adam 要为每个参数额外存两份动量,等于再翻三倍,一个完整的 VGG19 原始结构在 8GB 卡上跑 batch size 16 基本没有余量放激活值。所以这份实验里我最推荐的第一处改动,就是把分类头换掉。
2.3 把 fc 换成自适应池化:改几行、省多少
torchvision 的 vgg19 把 classifier 定义成一个 Sequential,索引 6 是最后一层 Linear(4096, 1000)。改类别数最省事的写法是直接替换这一层,但那样 1.2 亿参数一个都省不掉。更实用的做法是整块重写:
import torch.nn as nn from torchvision import models def build_vgg19(num_classes, light_head=True): net = models.vgg19(weights=models.VGG19_Weights.IMAGENET1K_V1) if light_head: # 512x7x7 先做全局平均池化压成 512 维,再接一个小 MLP net.classifier = nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), # (B,512,7,7) -> (B,512,1,1) nn.Flatten(), # -> (B,512) nn.Linear(512, 512), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(512, num_classes), # 输出层,num_classes 按数据集改 ) else: net.classifier[6] = nn.Linear(4096, num_classes) return net net = build_vgg19(num_classes=10) print(sum(p.numel() for p in net.classifier.parameters()) / 1e6) # 约 0.52M,对比原始 123.6MAdaptiveAvgPool2d((1,1))放在 classifier 内部是合法的,因为 features 的输出就是 (B, 512, 7, 7),池化后 Flatten 得到 512 维向量,接两层小全连接即可。参数从 1.236 亿降到约 52 万,模型文件从 550MB 降到 80MB 左右,同样的显卡能开更大的 batch、用更高的分辨率。
代价也要说清楚:全连接层的空间聚合能力被池化替代,ImageNet 上的 Top-1 通常会掉 0.3 到 0.8 个点。对实验课的数据集规模来说这点损失无关紧要,但如果目标是复现论文精度,就老老实实用原结构,别图省事。我一般的判断是:分类头参数超过卷积部分 5 倍,就该考虑换掉;不到 2 倍,保持原样。
3. 数据管线:224×224 这条路要铺平
模型改完,接下来最容易出问题的环节是数据。图像分类实验里 80% 的「模型不收敛」最后都追到数据管线上,要么是归一化参数对不上预训练权重,要么是类别顺序在训练和推理之间错位,要么是 train/val 目录里混进了重复图片。
3.1 依赖与版本:别让 torchvision 拖后腿
这份实验对版本不算挑剔,但有两条硬约束:torch 和 torchvision 必须配套,权重枚举models.VGG19_Weights.IMAGENET1K_V1需要 torchvision 0.13 以上;混合精度训练需要 torch 1.10 以上。
| 包 | 建议版本 | 用途 |
|---|---|---|
| torch | ≥ 2.0 | 训练主框架,amp 接口在 2.x 更稳 |
| torchvision | 与 torch 同批次 | 提供 vgg19 与预训练权重枚举 |
| pillow | ≥ 9.0 | 图像解码,老版本对某些 PNG 有色偏 |
| numpy | ≥ 1.23 | 指标计算 |
| scikit-learn | ≥ 1.0 | 混淆矩阵、分类报告 |
| matplotlib | ≥ 3.5 | 曲线与热力图 |
装的时候直接锁版本,别让 pip 自己解析:
pip install torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cu118 pip install pillow numpy scikit-learn matplotlib tqdm--index-url后面是官方提供的 CUDA 11.8 构建源,换 CUDA 版本时对应改 cu121 / cpu。这一步的意义在于避免 pip 装到一个 CPU-only 的 wheel,然后训练时发现torch.cuda.is_available()返回 False,白白排查半小时。
3.2 ImageFolder 目录约定与 Dataset 兜底
标准做法是每个类别一个子目录:
dataset/ ├── train/ │ ├── class_a/ img001.jpg ... │ └── class_b/ ... ├── val/ │ ├── class_a/ ... │ └── class_b/ ... └── test/ └── ...直接用datasets.ImageFolder就能读,类别名按目录名字母序排列并存进class_to_idx。这里有个细节必须记牢:推理脚本不能重新构建 ImageFolder 再取 class_to_idx,因为测试集可能缺类,字母序会变。正确做法是训练结束时把class_to_idx存成 json,推理时按这个映射还原标签名。
import json, os from torchvision import datasets, transforms train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), # 小数据集别用默认 0.08 transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(0.2, 0.2, 0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_set = datasets.ImageFolder("dataset/train", transform=train_tf) os.makedirs("ckpt", exist_ok=True) with open("ckpt/class_to_idx.json", "w") as f: json.dump(train_set.class_to_idx, f, ensure_ascii=False, indent=2)RandomResizedCrop的scale默认是 (0.08, 1.0),意味着可能裁出只占原图 8% 面积的一小块。ImageNet 那种百万级数据扛得住,几千张的实验数据集扛不住,裁出来常常只剩背景。改成 (0.7, 1.0) 是我在中小数据集上试出来比较稳的区间,既保留尺度扰动,又不会把目标裁没。
如果手上数据不是标准目录结构,或者标注在一个 csv 里,就自己写 Dataset 兜底:
from torch.utils.data import Dataset from PIL import Image class CsvImageDataset(Dataset): def __init__(self, csv_path, class_to_idx, transform=None): import csv with open(csv_path, newline="", encoding="utf-8") as f: self.items = [(r["path"], r["label"]) for r in self._rows] self.class_to_idx = class_to_idx # 复用训练集映射,保证顺序一致 self.transform = transform def __len__(self): return len(self.items) def __getitem__(self, idx): path, label = self.items[idx] img = Image.open(path).convert("RGB") # 统一三通道,防止灰度图炸 collate if self.transform: img = self.transform(img) return img, self.class_to_idx[label]convert("RGB")这一行看似多余,实际能省掉一次深夜排查:数据集里混进几张带 alpha 通道的 PNG 或单通道灰度图时,默认的 ToTensor 会产出 4 通道或 1 通道张量,和模型期望的 3 通道不匹配,报错信息还藏在 DataLoader 的 worker 里,堆栈很难读。
3.3 归一化参数与增强强度
mean=[0.485, 0.456, 0.406]、std=[0.229, 0.224, 0.225]这组数是 ImageNet 训练集的统计量,和 torchvision 提供的预训练权重是绑定的关系。用预训练权重就必须用这组值,换成自己数据集算出来的均值方差,相当于给已经学好的卷积核喂了一份分布偏移的输入,前几层特征立刻失真。
验证和测试的 transform 只保留确定性的部分:
val_tf = transforms.Compose([ transforms.Resize(256), # 短边缩到 256 transforms.CenterCrop(224), # 中心裁 224 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])Resize(256)加CenterCrop(224)是官方评估流程,短边留 32 像素余量再中心裁,能保住画面主体的完整。别图省事直接Resize((224,224)),那会把长宽比压扁,尤其对细长目标影响明显。
DataLoader 的参数也值得配一下:
from torch.utils.data import DataLoader train_loader = DataLoader( train_set, batch_size=32, shuffle=True, num_workers=4, pin_memory=True, drop_last=True, persistent_workers=True, )pin_memory=True配合non_blocking=True的.to(device)能让 H2D 拷贝和计算重叠;drop_last=True避免最后一个不满的 batch 把 BatchNorm 的统计量带偏;persistent_workers=True在 epoch 数多的时候省掉反复起进程的开销,但在某些容器环境里反而会挂住,如果训练卡在第一个 epoch 不动,先把它关掉试试。
4. 训练策略:冻结、解冻、学习率三档怎么排
预训练权重加载好之后,直接全量微调几乎必然翻车——VGG19 有 2000 万卷积参数,小数据集几千张图,几个 epoch 就能把 ImageNet 学到的特征冲干净。合理的路径是分三段走:先冻结骨干只训分类头,再解冻高层做差分学习率微调,最后视验证集表现决定要不要再往下放。
4.1 冻结 backbone:只训一个新分类头
第一阶段的思路是把 VGG19 当成一个固定的特征提取器。features 全部requires_grad=False,只让新分类头参与反向传播。因为分类头参数量只有 50 万量级,这一步在单卡上几分钟就能跑完一个 epoch,学习率可以给大一点,1e-3 起步。
def freeze_backbone(net): for p in net.features.parameters(): p.requires_grad = False return net def unfreeze_top_blocks(net, from_index=19): # from_index=19 解冻 block4+block5,from_index=28 只解冻 block5 for m in list(net.features)[from_index:]: for p in m.parameters(): p.requires_grad = True return net这里有个容易忽略的点:冻结不只是把梯度关掉,还要处理 BatchNorm。features 里每个卷积后面都跟着 BN,处于train()模式时即使参数不更新,running_mean 和 running_var 仍会随当前 batch 的统计量滑动。小 batch、数据分布和 ImageNet 差得远的时候,这些统计量会被慢慢带偏,冻结阶段结束时骨干的实际行为和预训练权重已经不一致了。
常见做法是在训练循环里显式把冻结部分设成 eval:
def set_bn_eval_for_frozen(net): for m in net.modules(): if isinstance(m, nn.BatchNorm2d): # 只有参数被冻结的 BN 才切 eval if not any(p.requires_grad for p in m.parameters()): m.eval()在每个 epoch 开头调一次。注意net.train()会把所有子模块重新设成 train,所以这一句必须放在net.train()之后,顺序反了就失效。
4.2 解冻 block4/block5 的差分学习率
第二阶段解冻 features[19:] 这 18 个模块。这里如果给整个网络一个统一学习率,要么分类头学得太慢,要么卷积层被冲垮。用 parameter groups 分层给学习率是标准解法:
import torch.nn as nn from torch.optim import AdamW def build_optimizer(net, lr_head=1e-4, lr_backbone=1e-5, wd=1e-4): head_params = list(net.classifier.parameters()) bb_params = [p for p in net.features.parameters() if p.requires_grad] groups = [ {"params": head_params, "lr": lr_head}, {"params": bb_params, "lr": lr_backbone}, ] return AdamW(groups, weight_decay=wd)backbone 的学习率给到 head 的十分之一,是我在中小数据集上比较稳定的比例。解冻的层越靠前,学习率应该越低,三层结构的话可以设成 1e-4 / 1e-5 / 1e-6。AdamW 的weight_decay建议 1e-4 到 5e-4,别用 0,VGG 这种全连接层多的结构不加正则很容易过拟合;但也别上 1e-2,那会把权重压得过小、训练变慢。
还有一个经常被问到的选择:优化器用 SGD 还是 AdamW。SGD + momentum 0.9 在 ImageNet 上确实能刷出更好的最终精度,但对学习率极其敏感,实验场景下要调好几轮;AdamW 收敛快、对学习率宽容,适合课程实验这种「一次跑通、两次出结果」的节奏。我的默认选择是 AdamW,只有在需要复现某个具体论文数字时才切回 SGD。
4.3 训练循环里必须写对的四件事
训练循环本身不长,但四个细节决定结果可不可信。
import torch from tqdm import tqdm def train_one_epoch(net, loader, crit, opt, scaler, device): net.train() set_bn_eval_for_frozen(net) # 关键点 1:冻结 BN 切 eval loss_sum, correct, total = 0.0, 0, 0 for x, y in tqdm(loader, leave=False): x = x.to(device, non_blocking=True) y = y.to(device, non_blocking=True) opt.zero_grad(set_to_none=True) # 关键点 2:置 None 而非 0 with torch.autocast("cuda", dtype=torch.float16): # 关键点 3 logits = net(x) loss = crit(logits, y) scaler.scale(loss).backward() scaler.step(opt) scaler.update() loss_sum += loss.item() * y.size(0) correct += (logits.argmax(1) == y).sum().item() total += y.size(0) return loss_sum / total, correct / total @torch.no_grad() def evaluate(net, loader, crit, device): net.eval() # 关键点 4:eval + no_grad loss_sum, correct, total = 0.0, 0, 0 for x, y in loader: x, y = x.to(device), y.to(device) logits = net(x) loss_sum += crit(logits, y).item() * y.size(0) correct += (logits.argmax(1) == y).sum().item() total += y.size(0) return loss_sum / total, correct / total四个关键点逐一说明。set_bn_eval_for_frozen保证了冻结部分的 BN 统计量不漂移。zero_grad(set_to_none=True)比默认的填零省一次全参数写内存,在大模型上累积起来不是小数目。autocast加GradScaler是混合精度训练,VGG19 这种参数量大的模型用它能把显存占用砍 30% 到 40%,速度也能提一截;注意 loss 必须由 scaler 缩放后再 backward,否则 fp16 下小梯度直接下溢成 0。评估阶段net.eval()和torch.no_grad()缺一不可,前者切 BN 和 Dropout 的行为,后者省掉激活值存储。
损失函数用标准的交叉熵,类别不平衡的话加权重:
# 按训练集类别频次算权重,频次越低的类权重越大 counts = torch.bincount(torch.tensor([t for _, t in train_set.samples])) weight = (counts.sum() / (len(counts) * counts)).float().to(device) crit = nn.CrossEntropyLoss(weight=weight, label_smoothing=0.1)label_smoothing=0.1是个便宜好用的正则,对 VGG 这种容易过拟合的结构尤其有用,通常能带来 0.5 到 1 个点的验证集提升,几乎不需要额外代价。
4.4 调度、早停与权重保存
学习率调度和模型保存这两件事,写得好能省掉大量重跑时间。
from torch.optim.lr_scheduler import CosineAnnealingLR sched = CosineAnnealingLR(opt, T_max=epochs, eta_min=1e-6) best_acc, patience, bad_epochs = 0.0, 5, 0 for epoch in range(epochs): tr_loss, tr_acc = train_one_epoch(net, train_loader, crit, opt, scaler, device) va_loss, va_acc = evaluate(net, val_loader, crit, device) sched.step() if va_acc > best_acc: best_acc, bad_epochs = va_acc, 0 torch.save({ "model": net.state_dict(), "class_to_idx": train_set.class_to_idx, "epoch": epoch, "val_acc": va_acc, }, "ckpt/best.pt") # 存 dict,别只存 state_dict else: bad_epochs += 1 if bad_epochs >= patience: print(f"early stop at epoch {epoch}") break存 checkpoint 时带上class_to_idx,是最容易被忽略又最省事的一步。很多人的推理脚本莫名其妙把类别标签全对错位,追到最后发现是测试集的 ImageFolder 少了一个类导致字母序变了,而模型输出维度不变,错误静默发生。把映射和权重存在同一个文件里,这个问题从根上消失。
CosineAnnealingLR的T_max要和实际训练轮数对齐,如果依赖早停提前结束,余弦曲线会中途截断,学习率还没降到低点就停了,可以改成按 step 更新或者用ReduceLROnPlateau兜底。
5. 避坑记录:VGG19 实验里五个真实翻车现场
这一章的五条都是我在带实验和帮人调参时反复见到的,有两条我自己也栽过。每条按「现象 → 原因 → 解决」写,照着对照基本能覆盖 90% 的卡壳场景。
5.1 现象:loss 卡在 2.3 附近一动不动
现象很典型:训练日志打出来,第一个 epoch loss 是 2.31,第二个 2.30,第十个还是 2.29,准确率在随机猜测水平上下浮动。
原因通常有两个。第一,预训练权重没真正加载上。load_state_dict默认strict=True,键名不匹配会直接报错,但如果用了strict=False或者weights参数写错成weights=None,就会静默地从一个随机初始化的网络开始训,几百轮都学不出东西。第二,输入没做 Normalize,或者 mean/std 写反了。0 到 1 的原始像素直接喂进预训练卷积层,激活值分布完全偏离,梯度基本消失。
排查顺序是先在加载后立刻检查权重:
net = build_vgg19(num_classes=10) ref = models.vgg19(weights=models.VGG19_Weights.IMAGENET1K_V1) k = "features.0.weight" print(torch.allclose(net.state_dict()[k], ref.state_dict()[k])) # 必须为 True为 True 说明权重正常,问题在数据侧。接着抽查一个 batch 的统计量:
x, y = next(iter(train_loader)) print(x.mean().item(), x.std().item()) # 归一化后应接近 0 和 1如果均值在 0.45 上下、标准差 0.25 左右,说明 Normalize 根本没生效,检查 transform 顺序是不是把 Normalize 写在了 ToTensor 前面——ToTensor 之前的输入是 PIL 图像,Normalize 会静默跳过。还有一种隐蔽情况:训练脚本和验证脚本用了两套 transform,训练侧漏了 Normalize,验证侧有,表现是训练 loss 不降但验证准确率莫名还行,非常迷惑。
5.2 现象:验证集 92%,换测试集掉到 60%
验证集准确率漂亮得不像话,一上测试集就崩,掉三十个点。
最常见的原因是数据泄漏:同一张图或者同一段视频的相邻帧,被随机切分到了 train 和 val 里。图像分类实验的数据集往往是按文件平铺的,random_split一劈就完事,但连拍图和视频抽帧在相邻样本之间相似度极高,模型实质上是记住了样本而不是学到特征。解决方法是按来源分组切分,同一组样本只能落到一个集合里:
import hashlib from collections import defaultdict from sklearn.model_selection import GroupShuffleSplit # 按文件内容哈希或按原始来源 ID 分组 groups = [hashlib.md5(open(p, "rb").read()).hexdigest()[:8] for p, _ in train_set.samples] gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) tr_idx, va_idx = next(gss.split(train_set.samples, groups=groups))第二个原因是重复文件。数据集从多个渠道拼凑起来时,同一张图可能以不同文件名存在多份,一份在 train 一份在 val,验证准确率自然虚高。用内容的 md5 去重是成本最低的做法,跑一遍就能把重复样本清干净。这一步我现在的习惯是数据一进来就跑,不留到结果异常了再回头查。
5.3 现象:batch 调到 8 还是 OOM
显存报错CUDA out of memory,把 batch_size 从 64 一路降到 8 依然炸,甚至 batch=1 都报。
碰到这种情况先别继续降 batch,先看清显存被谁占了。VGG19 原始分类头 1.2 亿参数,Adam 状态下光优化器状态就接近 1.5GB,加上权重、梯度、激活值,一张 8GB 卡留给激活的空间非常有限。这时候 batch 降到 1 也没用,因为固定开销已经吃满了。
print(torch.cuda.memory_allocated() / 1024**3, "GB allocated") print(torch.cuda.memory_reserved() / 1024**3, "GB reserved")allocated远小于reserved说明是碎片问题,加torch.cuda.empty_cache()或者调整分配策略;两者都接近卡的上限,就是真不够。解决顺序按性价比排:先把分类头换成 2.3 节的轻量版本(省 1GB 以上),再开混合精度(再省 30% 到 40%),还不行就上梯度累积——把 batch 拆成几个小批,梯度攒够一次再 step:
accum_steps = 4 opt.zero_grad(set_to_none=True) for i, (x, y) in enumerate(loader): with torch.autocast("cuda", dtype=torch.float16): loss = crit(net(x.to(device)), y.to(device)) / accum_steps scaler.scale(loss).backward() if (i + 1) % accum_steps == 0: scaler.step(opt) scaler.update() opt.zero_grad(set_to_none=True)注意 loss 要除以accum_steps,否则等效学习率翻倍,训练曲线会抖得很难看。
5.4 现象:微调之后准确率反而不如只训分类头
冻结阶段验证准确率 85%,解冻 block4/block5 之后掉了 5 个点,再怎么训也回不去。
原因是学习率给大了,或者解冻的层数太多。VGG19 的 block4/block5 是语义特征最强的部分,用一个和分类头同量级的学习率去更新,几个 step 就能把预训练学到的表示冲乱。判断方法很简单:看解冻后第一个 epoch 的 loss,如果比冻结阶段末尾突然跳高一大截,基本就是学习率过大。
调整顺序是先把 backbone 学习率降到 head 的十分之一以下(比如 head 1e-4、backbone 1e-6),再加几轮 warmup 让优化器状态稳定下来,还不行就只解冻 block5,把 block4 继续冻着。另一个常被忽略的点是解冻后 BN 的处理:如果继续对解冻部分保持 BN eval,等于用 ImageNet 的统计量配新数据,会有偏差;这时候应该让解冻部分的 BN 跟着训练,只对仍冻结的 BN 保持 eval。5.1 里那个set_bn_eval_for_frozen按requires_grad判断,正好能自动适配这个切换。
5.5 现象:同一张图推理两次结果不一样
写个推理脚本,同一张测试图连着跑两次,输出的类别概率居然不同,或者一批图跑下来所有样本都被判成同一个类。
前者的原因是忘了model.eval()。Dropout 在训练模式下随机丢神经元,BN 用当前 batch 的统计量,同一个输入自然出不同结果。后者是忘了torch.no_grad()之外的另一件事——类别映射错位,或者模型输出的维度顺序和标签顺序对不上,argmax 拿到的索引在错误的表里查名字,全落到同一个类上。
推理脚本的骨架我固定成这样,几乎不会再出这类问题:
import json, torch from PIL import Image @torch.no_grad() def predict(image_path, ckpt_path="ckpt/best.pt", device="cuda"): ckpt = torch.load(ckpt_path, map_location=device) net = build_vgg19(num_classes=len(ckpt["class_to_idx"])) net.load_state_dict(ckpt["model"]) net.to(device).eval() # eval 必须在 to 之后 idx_to_class = {v: k for k, v in ckpt["class_to_idx"].items()} x = val_tf(Image.open(image_path).convert("RGB")).unsqueeze(0).to(device) prob = net(x).softmax(1)[0] top = prob.topk(3) return [(idx_to_class[i.item()], round(p.item(), 4)) for p, i in zip(top.values, top.indices)]net.eval()放在.to(device)之后是为了避免某些版本下.to()重新初始化模块状态。idx_to_class从 checkpoint 里反查,不依赖任何目录扫描,从根本上杜绝了标签顺序错位。
6. 验收与进阶:Top-5、混淆矩阵和一张 Grad-CAM 热力图
训练跑完,一个 accuracy 数字说明不了太多问题。要把这份实验做扎实,至少再补三样东西:Top-k 指标、混淆矩阵、可视化热力图。
Top-k 用来判断「错得远不远」。VGG19 在细粒度分类上常常 Top-1 掉点但 Top-5 保持,说明模型知道正确答案在哪一小撮候选里,只是没排第一,这跟完全学错是两回事。混淆矩阵则直接告诉你哪两类在互相打架,通常能反向定位到数据问题。
import numpy as np from sklearn.metrics import confusion_matrix, classification_report @torch.no_grad() def collect_preds(net, loader, device, k=5): net.eval() ys, logits_all = [], [] for x, y in loader: logits_all.append(net(x.to(device)).cpu()) ys.append(y) logits = torch.cat(logits_all) ys = torch.cat(ys) topk = logits.topk(k, dim=1).indices top1 = (topk[:, 0] == ys).float().mean().item() topk_acc = (topk == ys[:, None]).any(1).float().mean().item() return ys.numpy(), topk[:, 0].numpy(), top1, topk_acc y_true, y_pred, t1, t5 = collect_preds(net, test_loader, device) print(f"Top-1={t1:.4f} Top-5={t5:.4f}") print(classification_report(y_true, y_pred, target_names=list(idx_to_class.values()))) cm = confusion_matrix(y_true, y_pred) # 归一化后按行看,找出被误判最多的类别对 cm_norm = cm / cm.sum(1, keepdims=True)classification_report里的 macro-F1 比 accuracy 更适合类别不平衡的场景,如果 macro-F1 明显低于 accuracy,说明小类被牺牲了,回去检查损失函数的类别权重是不是漏配。
Grad-CAM 是判断「模型在看哪」最省事的工具,不需要改模型结构,挂一个 hook 就能拿到梯度加权的特征图:
feats, grads = {}, {} def hook_fwd(m, i, o): feats["v"] = o def hook_bwd(m, gi, go): grads["v"] = go[0] target = net.features[35] # 最后一个 ReLU,输出 512x7x7 target.register_forward_hook(hook_fwd) target.register_full_backward_hook(hook_bwd) logits = net(x.unsqueeze(0)) cls = logits.argmax(1).item() net.zero_grad() logits[0, cls].backward() w = grads["v"].mean(dim=(2, 3), keepdim=True) # 每个通道的权重 cam = torch.relu((w * feats["v"]).sum(1))[0] # 加权求和再 ReLU cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8)7×7 的 CAM 上采样回 224×224 叠到原图上,就能看到模型判断时重点看了哪一块。我拿它抓过一次数据标注问题:某个类的热力图长期落在图片角落的水印上,说明模型学的是水印而不是目标本身,把带水印的样本清掉之后准确率直接涨了六个点。这类问题光看 loss 曲线一辈子发现不了。
最后一个几乎白捡的技巧是 TTA(测试时增强):把测试图原图和水平翻转各推一次,两张概率取平均。VGG 结构对水平翻转不是不变,两次预测有一定互补性,通常能带来 0.5 到 1 个点的提升,代价只是推理时间翻倍。
@torch.no_grad() def predict_tta(net, x, device): net.eval() p1 = net(x.to(device)).softmax(1) p2 = net(torch.flip(x, dims=[3]).to(device)).softmax(1) return (p1 + p2) / 2别在验证集上反复调 TTA 的组合方式,很容易调出一个只在验证集上好看的配置。我的做法是只试「原图 + 水平翻转」这一种,行就加上,不行就放弃,不在这上面耗时间。
从那以后我每次跑完一个分类实验,都会强制走一遍「Top-k → 混淆矩阵 → 抽 20 张错例看图 → Grad-CAM 看一张」这套流程,不管 accuracy 多好看。因为血泪经验是:accuracy 骗人的时候,混淆矩阵和热力图从不骗人。希望帮到你。
本文还有配套的精品资源,点击获取