VGG19图像分类迁移学习实验全流程与避坑指南

发布时间:2026/11/2 2:22:06
VGG19图像分类迁移学习实验全流程与避坑指南

简介:智能计算系统实验4-1配套的VGG19图像分类实践包,面向正在学习深度学习模型部署与推理优化的学生和开发者。内容围绕经典19层卷积网络展开:从连续3×3卷积与池化逐层提取特征,到全连接层与Softmax输出类别概率;同时给出CPU与MLU两种硬件平台上的评估脚本,便于对比通用处理器与机器学习单元的推理性能差异,并附INT8量化模型文件,直观呈现权重量化对内存占用与推理速度的影响。压缩包共4个文件、约95.86MB,以3个Python脚本和1个pb模型文件为主:脚本分别承担评估入口与模块初始化职责,pb文件为可直接加载的量化模型,整体结构精简、依赖清晰。目前已有2154人学习下载。读者可据此跑通完整的前向推理与准确率统计流程,理解量化模型的加载方式,并借鉴跨平台评估的组织思路,为嵌入式与移动端部署打下基础。

1. 从一张认错的照片说起

课程群里有人贴图求助:10 类场景照片,VGG19 图像分类模型训练了 30 轮,训练集准确率 98%,验证集死活卡在 12% 上下——刚好是瞎猜的水平。这种场面在课程实验里相当常见,问题往往不在网络本身,而在数据管线、预训练权重加载、冻结策略这几个环节里悄悄漏了一步。这份实验资源做的事很朴素:用 torchvision 里的 VGG19 做迁移学习,把 ImageNet 上 1000 类的预训练骨干搬过来,替换最后那层分类头,接到自己的小数据集上跑通训练、验证、保存、推理这条完整链路。适合正在做智能计算系统课程实验的人,也适合想用现成代码摸清迁移学习整套流程的开发者——不用从卷积核推导开始,直接看到能跑的东西。

2. 工程骨架与数据管线:VGG19 实验第一次跑通需要哪些文件

拿到一份实验代码,很多人第一反应是找train.py直接python train.py,然后被ModuleNotFoundError和FileNotFoundError轮流教育。这个项目的组织方式是把环境、路径、超参、数据、模型、训练拆成互相不干扰的几块,好处是后面改数据集时只需要动配置,不用去训练脚本里翻常量。按我踩过的坑,工程骨架立对了,后面能省掉一大半调试时间。

2.1 依赖版本与运行环境核对

VGG19 本身不挑框架版本,但torchvision.models的预训练权重接口在新旧版本之间换过一次写法:老版本是pretrained=True,0.13 之后推荐weights=VGG19_Weights.IMAGENET1K_V1。这两种写法混用,轻则警告,重则直接报参数不存在。所以工程的第一步不是写模型,而是把版本钉住,再用一段小脚本确认显卡能不能用、当前解释器是不是你以为的那个。

# check_env.py —— 跑训练前先执行一遍,确认环境和显卡状态 import torch import torchvision print("python torch :", torch.__version__) print("torchvision :", torchvision.__version__) print("cuda available :", torch.cuda.is_available()) print("cuda version :", torch.version.cuda) if torch.cuda.is_available(): # 多卡机器上确认到底能看到几张卡,避免只用到了 0 号卡 print("device count :", torch.cuda.device_count()) print("device name :", torch.cuda.get_device_name(0)) print("capability :", torch.cuda.get_device_capability(0))

这段脚本输出四类信息:框架版本、CUDA 是否可用、驱动对应的 CUDA 版本、显卡型号和算力。cuda available为False时不要急着改代码,先确认装的是不是 CPU 版 wheel——pip install torch默认在某些源上会给 CPU 包,装完torch.version.cuda是None,这时候改多少行model.to("cuda")都没用。算力那一行也值得看一眼,太老的卡跑混合精度会直接报不支持。

版本组合上,我的习惯是 Python 3.8 到 3.10、torch 1.13 到 2.x、torchvision 与之配套的大版本,三者版本号在官方兼容表里能对上就行。requirements.txt里写死主版本号而不是torch>=1.0这种宽范围,是为了避免队友 clone 下来装出另一套行为。实验代码尤其要这样,因为评分和复现都依赖同一套数值行为。

提示:虚拟环境用 conda 还是 venv 无所谓,但一定要独立。系统 Python 里同时装着两个版本的 torchvision 是ImportError: cannot import name 'VGG19_Weights'的高发原因。

2.2 目录结构与集中式配置

项目目录按职责分,不看代码也能猜到每个文件干什么:

vgg19-classify/ ├── config.py # 超参、路径、类别数全部集中在这里 ├── check_env.py # 环境自检 ├── dataset.py # Dataset 封装与 transform 定义 ├── model.py # VGG19 构建、分类头替换、冻结策略 ├── engine.py # 训练一个 epoch、验证、指标统计 ├── train.py # 主入口:组装各模块,跑完整训练流程 ├── predict.py # 单图/批量推理,加载 ckpt 输出类别 ├── data/ │ ├── train/<类别名>/*.jpg │ └── val/<类别名>/*.jpg └── checkpoints/ # best.pth、last.pth、labels.json

data/train下按类别名分子文件夹,这是torchvision.datasets.ImageFolder的约定,dataset.py里直接调它就行,不用自己写解析逻辑。checkpoints/labels.json是个容易被忽略但很关键的文件——它把ImageFolder内部按文件夹名排序生成的整数索引和真实类别名对上,推理时不做这层映射,输出就是一串没意义的数字。

超参集中放在config.py里,用类属性而不是全局变量,这样训练脚本里from config import Config as C之后,C.batch_size一眼能看出是配置项:

# config.py —— 所有可变参数集中在这里,改实验只改这个文件 from pathlib import Path class Config: data_root = Path("./data") # 数据根目录,下面有 train/ 和 val/ ckpt_dir = Path("./checkpoints") # 权重保存目录 num_classes = 10 # 类别数,必须和子文件夹数量一致 image_size = 224 # VGG19 的标准输入尺寸 batch_size = 32 # 显存不够就降到 16 或 8 epochs = 20 lr_head = 1e-3 # 新分类头的学习率 lr_backbone = 1e-5 # 骨干微调时的学习率,比头部小两个量级 weight_decay = 5e-4 # VGG 全连接层参数量大,正则要给足 num_workers = 4 # Windows 上改成 0,否则容易卡死 seed = 42 device = "cuda"

几个值的由来值得说清楚。image_size=224不是随便定的,VGG19 在 ImageNet 上就是 224×224 训练出来的,输入尺寸偏差太大会让浅层特征的统计分布跑偏。batch_size=32是 8GB 显存下的稳妥值,VGG19 参数量 1.4 亿左右,224 输入、32 批量时前向激活占的显存不小。lr_head比lr_backbone大两个数量级,是因为分类头是随机初始化的,需要大步走;骨干已经收敛到不错的特征空间,只能小步微调,否则一次大梯度就把预训练学到的东西冲垮了。

weight_decay=5e-4这个值偏大,是针对 VGG 的。它后面三个全连接层分别有 1 亿、1700 万、1700 万参数量级,小数据集上不加正则几乎必然过拟合。换成 ResNet 系列这个值可以降到 1e-4。

2.3 训练与验证的 transform 必须分开写

数据增强这一块,血泪经验是:训练集和验证集共用一个Compose,验证指标会开始玄学抖动。训练集需要随机裁剪、翻转、颜色扰动来扩充样本分布;验证集需要确定性变换,同一张图每次评估必须得到同样的结果,否则你没法判断指标变化是模型变好了还是这回随机裁到了关键区域。

# dataset.py —— 训练/验证两套 transform,千万不要复用 from torchvision import transforms # ImageNet 的均值和方差,用预训练权重就必须用这套归一化 IMAGENET_MEAN = [0.485, 0.456, 0.406] IMAGENET_STD = [0.229, 0.224, 0.225] train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), # 随机尺度裁剪,增强尺度鲁棒性 transforms.RandomHorizontalFlip(p=0.5), # 水平翻转,注意别用在字符类数据上 transforms.ColorJitter(0.2, 0.2, 0.2), # 亮度/对比度/饱和度扰动 transforms.ToTensor(), # HWC uint8 -> CHW float[0,1] transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD), ]) val_tf = transforms.Compose([ transforms.Resize(256), # 先短边缩到 256 transforms.CenterCrop(224), # 再中心裁 224,和训练输入尺寸对齐 transforms.ToTensor(), transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD), ])

RandomResizedCrop的scale=(0.7, 1.0)表示每次从原图里裁出 70% 到 100% 面积的区域再缩放,这个下限别设太低,0.3 以下在小数据集上会把目标裁没。Normalize那两行是硬约束:加载了 ImageNet 预训练权重,输入就必须按 ImageNet 的统计量归一化,否则骨干看到的分布和它训练时完全不同,效果还不如随机初始化。

验证集那条Resize(256) → CenterCrop(224)是标准做法,先保比例缩放再中心裁剪,避免直接Resize((224,224))把长宽比拉变形。如果你的数据集目标偏小或者集中在边角,可以换成Resize(224)加不裁剪,但要在实验记录里注明,别和标准流程的结果直接比。

构建DataLoader的时候还有几个参数值得单独拎出来:

# dataset.py 续 —— DataLoader 参数 from torch.utils.data import DataLoader from torchvision import datasets train_ds = datasets.ImageFolder(C.data_root / "train", transform=train_tf) val_ds = datasets.ImageFolder(C.data_root / "val", transform=val_tf) train_loader = DataLoader( train_ds, batch_size=C.batch_size, shuffle=True, # 训练必须打乱,否则同类别连续进网络,梯度方向偏 num_workers=C.num_workers, pin_memory=True, # 锁页内存,CPU 到 GPU 拷贝更快 drop_last=True, # 丢掉最后不足一个批次的样本,避免 BN 统计量抖动 ) val_loader = DataLoader( val_ds, batch_size=C.batch_size, shuffle=False, # 验证不能打乱,混淆矩阵的行列要和类别索引对齐 num_workers=C.num_workers, pin_memory=True, )

pin_memory=True只有在用 GPU 时才有意义,纯 CPU 训练开着反而占内存。drop_last=True在样本数不是 batch_size 整数倍时,最后一个小批次只有几张图,BN 层用这几张图算出来的均值和方差噪声很大,容易让训练末段出现 loss 抖动,丢掉更稳。num_workers在 Linux 上给 4 到 8 都行,Windows 上非 0 值如果没有if __name__ == "__main__":保护,会陷入反复起进程的死循环,这是新手最常撞的一堵墙。

最后确认类别数:len(train_ds.classes)和C.num_classes对不上就直接抛异常,比训练到一半发现分类头维度错了要省事得多。

3. VGG19 结构拆解:分类头怎么换、哪些层该冻

VGG19 的结构简单到有点复古——没有残差连接、没有注意力、没有分组卷积,就是 3×3 卷积堆叠加池化,最后三个全连接。这种"朴素"恰恰是它适合做教学实验的原因:每一层的输入输出都能手算出来,出问题时定位也直接。但简单不等于好训,1.4 亿参数量摆在那,小数据集上不做迁移学习基本没戏。

3.1 把网络拆成 features 和 classifier 两段看

torchvision 的实现把 VGG19 明确切成两段:features是 16 个卷积层加 5 个最大池化,负责提特征;classifier是三层全连接,负责分类。卷积部分按池化位置分成 5 个 block:

Block配置输出通道输出尺寸(输入 224)
block1conv3-64 ×2 + maxpool64112×112
block2conv3-128 ×2 + maxpool12856×56
block3conv3-256 ×4 + maxpool25628×28
block4conv3-512 ×4 + maxpool51214×14
block5conv3-512 ×4 + maxpool5127×7
classifierFC-4096 → FC-4096 → FC-1000——

每次池化把空间尺寸减半,通道数翻倍,到 block5 输出是512×7×7 = 25088维,这正是第一个全连接层的输入维度。这个 25088 是后面替换分类头时唯一需要记的数字。

# model.py —— 拆开看结构,确认分类头位置 import torch from torchvision import models m = models.vgg19(weights=models.VGG19_Weights.IMAGENET1K_V1) print(m.features) # 22 层(含池化),输出 512x7x7 print(m.classifier) # Sequential(Linear(25088,4096), ReLU, Dropout, Linear(4096,4096), ReLU, Dropout, Linear(4096,1000)) # 统计各段参数量,心里有个数 n_feat = sum(p.numel() for p in m.features.parameters()) n_cls = sum(p.numel() for p in m.classifier.parameters()) print(f"features: {n_feat/1e6:.1f}M, classifier: {n_cls/1e6:.1f}M") # 约 20M / 124M

跑一遍会发现一个反直觉的事实:卷积部分只有约 2000 万参数,三个全连接层占了 1.24 亿,是整个网络的绝对大头。所以"冻结骨干"省下来的参数量其实很有限,真正需要重新学的是分类头。这也解释了为什么小数据集上 VGG19 容易过拟合——你换掉的那个 1000 类输出层背后,是两个 4096 维的巨型全连接。

3.2 分类头替换的三种写法与冻结策略

分类头替换的本质是把classifier[6](第三个 Linear)的输出维度从 1000 改成自己的类别数,输入维度 4096 保持不变。classifier是个nn.Sequential,索引 6 正好是最后一层,改法有三种:

# model.py —— 构建模型:加载预训练 + 替换分类头 + 可选冻结 import torch.nn as nn from torchvision import models def build_model(num_classes: int, pretrained: bool = True, freeze_backbone: bool = True): # 1. 加载骨干。pretrained=False 时随机初始化,用于对照实验 weights = models.VGG19_Weights.IMAGENET1K_V1 if pretrained else None model = models.vgg19(weights=weights) # 2. 替换最后一层全连接,输入维度沿用原来的 4096 in_features = model.classifier[6].in_features # 4096 model.classifier[6] = nn.Linear(in_features, num_classes) # 3. 冻结特征提取部分,只训练分类头 if freeze_backbone: for p in model.features.parameters(): p.requires_grad = False return model model = build_model(num_classes=10, pretrained=True, freeze_backbone=True) model = model.to("cuda") # 确认可训练参数,应该只剩分类头的约 4 万个(4096*10 + 10) trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) print(f"trainable params: {trainable/1e4:.1f} 万")

三种替换写法分别是直接改classifier[6]、整个classifier重建成新的Sequential、以及把features的输出接一个自定义头。第一种改动最小,推荐;第二种适合你想顺便把中间那两个 4096 缩到 512 以减少过拟合,但要注意改了之后Dropout的保留比例要重新调;第三种是把 VGG19 当纯骨干用(model.features加AdaptiveAvgPool2d再加一层 Linear),参数量能降到 2000 万出头,训练快很多,代价是丢掉原 classifier 里那部分已经学好的高层语义。

冻结策略不是非黑即白。常见的三档:

  • 全冻结骨干,只训分类头。适合数据集几百到几千张、和 ImageNet 域接近的场景,训练快、最不容易翻车,10 轮左右就能收敛。
  • 冻结前 3 个 block,放开 block4、block5。适合数据量上万、域差异明显(比如医学图像、工业缺陷)的情况,让高层语义跟着你的数据走。
  • 全部放开,用分层学习率。效果上限最高,但显存和时间成本都上去了,小数据集上很容易过拟合。
# model.py —— 部分解冻:从指定 block 往后放开 def unfreeze_from(model, block_idx: int): """block_idx=4 表示放开 block4 和 block5,前面的保持冻结""" # features 里每个 block 以 MaxPool 结束,按顺序收集边界 children = list(model.features.children()) for i, layer in enumerate(children): if i >= block_idx * 5: # 每个 block 5 层(conv,relu,conv,relu,pool) for p in layer.parameters(): p.requires_grad = True return model

这里有个隐蔽的坑:冻结了卷积层,但模型在train()模式下,被冻结的 BN 层仍会用当前 batch 的统计量更新running_mean和running_var。batch_size 小的时候,这些统计量会被你的小数据集带偏,等于悄悄破坏了预训练特征。稳妥做法是训练时把所有 BN 层单独切到eval()模式:

# engine.py 片段 —— 冻结骨干时,BN 层必须保持在 eval 模式 def set_bn_eval(model): for m in model.modules(): if isinstance(m, nn.BatchNorm2d): m.eval() # 在每个 epoch 开头的 model.train() 之后立刻调用

注意 VGG19 的原始实现里卷积后面没有 BN(BN 是 VGG19-BN 才有的),所以如果你用的是vgg19而不是vgg19_bn,这一条暂时不影响;一旦换成带 BN 的版本,这段就是必须的。

4. 训练与评估闭环:一个 epoch 里究竟发生了什么

模型能建起来只是第一步,真正的调试成本在训练循环里。这个项目的engine.py把"训练一个 epoch"和"评估一次"封装成两个函数,主脚本只负责按 epoch 调度,好处是加早停、加日志、加混合精度时不用动主流程。

4.1 训练循环:loss、优化器与学习率调度

训练循环的骨架就六步:取 batch、搬设备、清梯度、前向、反传、更新。写错任何一步都会表现为"loss 不降"或者"loss 降了但准确率不涨",而这两种现象的原因完全不同。

# engine.py —— 训练一个 epoch import torch from tqdm import tqdm def train_one_epoch(model, loader, criterion, optimizer, device, epoch): model.train() # 切训练模式:Dropout 生效 total_loss, total_correct, total_num = 0.0, 0, 0 pbar = tqdm(loader, desc=f"train epoch {epoch}") for images, labels in pbar: images = images.to(device, non_blocking=True) labels = labels.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) # 清梯度,set_to_none 比置零省一次写 logits = model(images) # 前向,输出 [B, num_classes] loss = criterion(logits, labels) # 交叉熵内部含 softmax,不要再手动加 loss.backward() # 反传 optimizer.step() # 更新参数 # 统计:loss 按样本数加权,避免最后一个小 batch 拉偏均值 bs = labels.size(0) total_loss += loss.item() * bs total_correct += (logits.argmax(dim=1) == labels).sum().item() total_num += bs pbar.set_postfix(loss=f"{loss.item():.4f}") return total_loss / total_num, total_correct / total_num

逐行看几个容易写错的地方。zero_grad(set_to_none=True)比默认的置零快一点,因为省掉了把整块显存写 0 的操作,第二次反传时会直接覆盖。criterion必须是nn.CrossEntropyLoss(),它内部包含log_softmax + nll_loss,如果你在模型最后又加了softmax,等于做了两次,梯度会被压平,表现为 loss 降得极慢。统计 loss 时乘bs再除总数,是因为最后一个小批次样本少,直接对 batch loss 求算术平均会让它的权重被高估。

优化器和调度器的配置直接决定收敛质量:

# train.py 片段 —— 优化器与调度器 import torch.nn as nn from torch.optim import SGD from torch.optim.lr_scheduler import CosineAnnealingLR criterion = nn.CrossEntropyLoss(label_smoothing=0.1) # 标签平滑,抑制过自信 # 只把 requires_grad=True 的参数交给优化器,冻结的层不参与 params = [p for p in model.parameters() if p.requires_grad] optimizer = SGD(params, lr=C.lr_head, momentum=0.9, weight_decay=C.weight_decay) scheduler = CosineAnnealingLR(optimizer, T_max=C.epochs, eta_min=1e-6)

优化器选 SGD 而不是 Adam,在迁移学习场景下是个经验判断:SGD 加动量收敛更稳,最终泛化往往更好;Adam 前期降得快,但在冻结骨干、只训小分类头的情况下容易把学习率"吃"得太猛,验证准确率波动大。momentum=0.9是标配。label_smoothing=0.1把目标从 one-hot 的 1.0 摊到 0.9,剩下 0.1 均分给其他类,能缓解模型对训练集噪声的过度自信,小数据集上通常能涨一两个点。

学习率调度用余弦退火,从lr_head平滑降到eta_min。比StepLR好在不需要猜在哪个 epoch 衰减,曲线本身是连续的。注意scheduler.step()要放在 epoch 循环里、优化器更新之后调用,放错位置(比如放在 batch 循环里)会让学习率掉得比预期快几十倍。

4.2 验证与指标:准确率之外至少看一张混淆矩阵

验证函数和训练函数最大的区别是torch.no_grad()和model.eval(),两个都不能少。少了no_grad显存会一路涨到 OOM,少了eval则 Dropout 还在随机丢神经元,同一张图两次评估结果都不一样。

# engine.py —— 验证并累计混淆矩阵 import numpy as np import torch @torch.no_grad() def evaluate(model, loader, criterion, device, num_classes): model.eval() # Dropout 关闭,BN 用 running stats total_loss, total_correct, total_num = 0.0, 0, 0 cm = np.zeros((num_classes, num_classes), dtype=np.int64) # 行=真实,列=预测 for images, labels in loader: images = images.to(device, non_blocking=True) labels = labels.to(device, non_blocking=True) logits = model(images) loss = criterion(logits, labels) preds = logits.argmax(dim=1) bs = labels.size(0) total_loss += loss.item() * bs total_correct += (preds == labels).sum().item() total_num += bs # 逐样本累加混淆矩阵,比 sklearn 的 confusion_matrix 省一次全量拼接 for t, p in zip(labels.view(-1).cpu().numpy(), preds.view(-1).cpu().numpy()): cm[t, p] += 1 acc = total_correct / total_num return total_loss / total_num, acc, cm

混淆矩阵的价值在于它告诉你"错在哪"。总体准确率 85% 听起来不错,但矩阵一拉出来可能发现第 3 类和第 7 类互相错了一半——这两类可能视觉上确实像,也可能是标注本身有歧义。只看准确率你永远不会知道这件事。矩阵的对角线之和除以总数就是准确率,非对角线的分布才是下一步改进的依据:某一列特别亮说明模型倾向于把这列对应的类判给所有东西(类别不平衡),某一行特别暗说明这个类的样本太少或者特征太弱。

训练主循环把这两块串起来,顺带处理保存和早停:

# train.py —— 主循环骨架 best_acc = 0.0 for epoch in range(1, C.epochs + 1): tr_loss, tr_acc = train_one_epoch(model, train_loader, criterion, optimizer, device, epoch) va_loss, va_acc, cm = evaluate(model, val_loader, criterion, device, C.num_classes) scheduler.step() print(f"[{epoch:02d}] train loss={tr_loss:.4f} acc={tr_acc:.4f} | " f"val loss={va_loss:.4f} acc={va_acc:.4f}") # 只保存验证集最好的那一版,不要用最后一版 if va_acc > best_acc: best_acc = va_acc torch.save({"model": model.state_dict(), "acc": best_acc, "epoch": epoch}, C.ckpt_dir / "best.pth")

判断该不该继续训练,我一般看两条曲线的关系:训练 loss 还在降、验证 loss 已经开始升,就是过拟合的明确信号,该停或者加正则了;两条都平了但准确率不高,那是欠拟合或者学习率太小,继续加轮数没意义。

4.3 checkpoint 里该存什么

只存model.state_dict()是个常见但不够用的做法。想复现结果或者中断后接着训,优化器状态和学习率调度器的状态也得存,否则恢复之后动量缓冲区全是零,前几个 batch 的更新方向会突变。

# train.py —— 完整的 checkpoint 保存与恢复 def save_checkpoint(path, model, optimizer, scheduler, epoch, best_acc): torch.save({ "model": model.state_dict(), "optimizer": optimizer.state_dict(), # 含动量缓冲,恢复后不会突变 "scheduler": scheduler.state_dict(), # 含当前学习率进度 "epoch": epoch, "best_acc": best_acc, }, path) def load_checkpoint(path, model, optimizer=None, scheduler=None): ckpt = torch.load(path, map_location="cpu") # 先落 CPU,避免显存峰值 model.load_state_dict(ckpt["model"]) if optimizer is not None: optimizer.load_state_dict(ckpt["optimizer"]) if scheduler is not None: scheduler.load_state_dict(ckpt["scheduler"]) return ckpt["epoch"], ckpt["best_acc"]

map_location="cpu"这一行在多卡机器上救过我几次:权重是在 1 号卡上存的,加载时默认往 0 号卡塞,卡不存在就直接报错。先统一落到 CPU 再model.to(device),路径就干净了。另外文件体积方面,VGG19 的 state_dict 约 550MB,一个目录里堆十几个 checkpoint 很快就能把磁盘吃满,我一般在项目里只保留best.pth和last.pth两个。

5. 避坑与排查:VGG19 实验里最常见的五类翻车

下面这几条都是我在跑这类实验时真实撞过的,按"现象 → 原因 → 解决"记下来,遇到问题可以直接对号入座。

5.1 数据与预处理类问题

现象一:训练 loss 一路降到 0.05,验证准确率始终在随机水平附近(10 类就是 10%)。

原因通常有两个叠加。一是预训练权重根本没生效——离线环境里weights=VGG19_Weights.IMAGENET1K_V1下载失败,torchvision 在某些版本会静默回退到随机初始化,只打一行警告,一屏日志刷过去就看不见了。二是骨干冻结 + 学习率太小,随机初始化的骨干加上 1e-3 的头部学习率,等于让模型用一个没训练过的特征提取器。

解决:构建模型后打印一次参数统计,确认骨干的权重不是随机的(model.features[0].weight.std()应该在 0.01 量级,随机初始化会明显不同)。离线环境提前把权重文件放到~/.cache/torch/hub/checkpoints/,文件名必须和 torchvision 期望的一致,或者干脆本地加载:model.load_state_dict(torch.load("vgg19-dcbb9e9d.pth"), strict=False)。确认权重加载正常之前,不要开始调其他超参。

现象二:验证准确率每轮抖动三四个点,重复跑两次结果差很多。

原因是用同一个RandomResizedCrop处理了验证集,或者验证时忘了shuffle=False。验证集上做随机增强,每次评估面对的输入分布都不一样,指标自然不稳定,混淆矩阵也会失真。

解决:物理隔离两套 transform,训练用train_tf,验证用val_tf(Resize+CenterCrop)。验证的DataLoader显式写shuffle=False。改完之后同一份权重连跑三次评估,准确率应该一模一样,这本身就是一个很好的自检。

现象三:每个类别都有图,但某个类别的召回率始终接近 0。

原因是ImageFolder按文件夹名字典序生成标签索引,和你在别处(比如标注文件、之前的实验)用的编号顺序不一致。训练时一切正常,推理时把标签映射搞反,输出就全乱。

解决:训练前把train_ds.classes打印出来并存成labels.json,推理脚本读同一份文件做反查。两个数据集的类别顺序必须完全一致,前后不一致时ImageFolder不会报错,只会默默地给你错的结果——这类问题最难查,因为代码一行都没错。

5.2 显存与训练过程类问题

现象四:CUDA out of memory,但 batch_size 已经降到 8 了。

原因是 VGG19 的内存开销主要不在参数上,而在激活值上。224 输入、32 批量时,前向过程中 block1 的输出就有32×64×224×224个元素,几个 block 的中间激活累加起来轻松超过 2GB。此外如果images和labels用了non_blocking=True却没有配合pin_memory,或者循环里某个张量忘了 detach,显存会一轮一轮往上爬,直到撑爆。

解决:优先降 batch 到能跑为止(VGG19 在 8GB 卡上建议 16),其次开混合精度torch.cuda.amp.autocast(),激活值占用能砍掉近一半。用torch.cuda.memory_allocated()在训练循环里打点,判断是"一上来就爆"(模型太大)还是"跑着跑着爆"(有张量泄漏)。torch.no_grad()漏写是后者的头号原因。

现象五:训练中途 loss 突然变成nan,之后再也恢复不了。

原因多半是学习率过大导致的梯度爆炸,或者输入数据没做归一化。VGG19 的卷积堆得深,没有残差连接,梯度一旦失控会迅速传播到所有层。用ToTensor()但漏了Normalize时,输入像素在[0,1],和预训练时假设的均值 0、方差 1 相差很大,第一层的激活值会非常大。

解决:先确认 transform 里Normalize在ToTensor()之后,这个顺序不能反。然后把lr_head降一个数量级重跑。加梯度裁剪兜底:torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0),放在loss.backward()之后、optimizer.step()之前。如果加了裁剪还出 nan,去检查数据里有没有损坏的图片文件——解码失败的图会产生全 0 或者异常值,这种脏数据在训练集里混进一张就够毁掉整轮训练。

提示:排查顺序建议固定为「数据 → 权重加载 → 冻结策略 → 学习率 → 显存」,从上往下查,别一上来就改网络结构。九成的训练问题不在模型里。

6. 二次开发:把这个实验改成你自己的分类器

这套代码真正跑通之后,下一步基本都是换成自己的数据。改动点其实很集中,我把它们列成一张表,按这张表走一遍,十分钟就能完成迁移:

改动位置改什么注意事项
config.pynum_classes、data_root、epochs类别数必须等于子文件夹数
data/按类别建子文件夹,train/val 各一套类别名保持两套一致
dataset.py增强策略、image_size目标偏小时可加大scale下限
model.py微调深度:全冻结 / 部分解冻 / 全放开数据少于 2000 张就全冻结
train.py优化器学习率组骨干和头部用不同学习率

数据量是决定微调深度的主要变量。几百到一两千张,全冻结骨干只训分类头,20 轮以内收敛,最省事。上万张且分布和 ImageNet 差得远,就解冻 block4、block5,并改用分层学习率——这是我在实践里最常用的方案,代码不长但收益明显:

# train.py —— 分层学习率:骨干小步,头部大步 def build_param_groups(model, lr_backbone, lr_head, weight_decay): backbone, head = [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue # 冻结的层不交给优化器 if name.startswith("classifier"): head.append(p) else: backbone.append(p) # 卷积层不加 weight_decay,全连接层加,这是迁移学习的常见组合 return [ {"params": backbone, "lr": lr_backbone, "weight_decay": 0.0}, {"params": head, "lr": lr_head, "weight_decay": weight_decay}, ] optimizer = SGD(build_param_groups(model, C.lr_backbone, C.lr_head, C.weight_decay), momentum=0.9)

backbone组关掉 weight_decay 是个细节:卷积核数量相对少,正则化收益不大,反而会拖慢微调收敛;分类头参数量巨大,必须靠 weight_decay 压住。分组之后scheduler仍然可以照常用,余弦退火会按各自的基础学习率等比缩放,两个组的比例保持不变。

改完之后不要直接跑全量训练,先做一个 sanity check:取每个类别各 20 张图、关掉所有随机增强,拿模型去拟合这 200 张,能不能在 30 个 epoch 内做到训练准确率 100%。做得到,说明数据管线和标签映射是对的,问题只可能出在泛化上;做不到,说明代码里有 bug,别浪费时间跑全量。这个检查我现在的习惯是每次换数据集必走一遍,比训练两小时之后发现标签错位要划算太多。

最后是验证方法。除了看准确率,我会把混淆矩阵导出成图片,逐类看一眼哪些类别在互相混。如果某一类和另外两三类都有明显混淆,那多半是标注边界不清,不是模型的问题——这时候加数据或者合并类别比调参有效得多。至于推理脚本,记得加载best.pth而不是last.pth,两者指标可能差好几个点,这个后悔药没必要吃。

从那以后我每次改完数据管线,都强制先跑一遍 200 张的小样本过拟合测试,确认管线通了再开全量训练。希望这套流程能帮到你,少走几个弯路。

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