简介:基于VGG19实现图像分类的智能计算系统实验4-1完整资料包,面向深度学习初学者、智能计算课程学员及需要实践CNN部署的技术人员,围绕经典VGG19网络,完整覆盖图像分类中的模型加载、预处理、前向推理与性能评估链路。压缩包共4个文件,包含3个Python脚本与1个pb预训练模型:其中两个脚本分别面向CPU环境与MLU硬件加速平台,便于对比不同算力环境下的运行差异;vgg19_int8.pb为INT8量化后的权重文件,展示模型轻量化的实际形态。资源整体大小约95.86MB,目前已有2139人学习下载。通过实际运行两个评估脚本,可直观感受CPU与专用MLU加速器在推理速度、能效比上的差别,并掌握图像缩放、归一化、前向传播、类别概率输出及准确率统计等完整实验步骤;INT8量化模型也体现了压缩部署在资源受限设备中的价值。该资料既适合课程实验的自主完成与报告撰写,也可作为入门深度学习分类任务的实践参考。
1. 做实验4-1前,先想清楚三件事
智能计算系统课程里的实验4-1“基于 VGG19 实现图像分类”,几乎所有做视觉方向的学生都会被这道题卡一下。卡住的原因不在于能不能写出一行torchvision.models.vgg19(),而在于三件绕不开的事:一是 VGG19 的参数分布很特殊,143.6M 参数里卷积层只占 32.4M,真正吃显存、背训练集的是最后三个全连接层,这个结构决定了你该冻结哪里、该换掉哪里;二是这个模型是按 ImageNet 的统计分布预训练出来的,你的数据预处理如果不用同一套均值方差做归一化,前面几十层卷积看到的就是陌生输入,再好的权重也发挥不出来;三是显存和 batch 的取舍,全连接层动辄占掉 1GB 以上显存,batch 稍大就 OOM,调小了 loss 曲线又像心电图。这三个问题想清楚,后面的路会顺很多,而且这套思路换到 ResNet、换到 transformer 图像分类模型依然成立。
2. 先把 VGG19 的骨架读懂:层结构、参数数量与迁移学习的最小动作
2.1 为什么实验场景总选 VGG19:纯卷积堆叠的“笨”反而是优势
VGG19 是 2014 年 ILSVRC 上牛津 VGG 组提出的深度卷积网络。它的设计极度简洁:所有卷积核都是 3×3,没有任何分支结构,靠叠加层数把感受野撑大。两个 3×3 卷积级联等价于一个 5×5 卷积的感受野,三个 3×3 级联等价于一个 7×7 卷积,参数量却更少,中间还能多出两次 ReLU 非线性。这种不做花样设计的结构放到今天的图像分类模型里看,计算量偏大,精度也不是顶尖,但它有一个非常适合做实验的特性:特征图尺寸的变化完全规律可循,每一层的输出形状都能提前预测,模型出了问题很容易定位到具体某一层。
智能计算系统实验把 VGG19 作为第一个落地对象,通常还有一层现实原因:torchvision 官方直接提供 ImageNet 预训练权重,不需要自己找权重文件、配复杂环境。实验课的合理投入方式是把这个模型当作一个“预训练特征提取器”来用,把精力放在数据管线和训练策略上,而不是从零训练一个 140M 参数的网络。对现在的工业实践来说,这种做法同样主流——除非你有超大数据集,否则从零训 CNN 已经很少见了,迁移学习是默认起点。所以与其盯着 VGG19“老”这个标签,不如先认可它在迁移学习框架里的教学价值:结构清楚、坑好定位、和后续所有模型的调参套路完全一致。
2.2 层结构和参数量:用一张表看清每个 Block 在做什么
对一张 224×224×3 的输入,VGG19 的特征图变化如下表所示。这张表在实验报告里几乎必现,它同时也是之后理解显存占用和全连接层维度的基准。
| 阶段 | 卷积层配置 | 输出特征图尺寸 | 池化后尺寸 |
|---|---|---|---|
| Block1 | conv3-64 × 2 | 224×224×64 | 112×112×64 |
| Block2 | conv3-128 × 2 | 112×112×128 | 56×56×128 |
| Block3 | conv3-256 × 4 | 56×56×256 | 28×28×256 |
| Block4 | conv3-512 × 4 | 28×28×512 | 14×14×512 |
| Block5 | conv3-512 × 4 | 14×14×512 | 7×7×512 |
| Classifier | Linear(25088,4096) → Linear(4096,4096) → Linear(4096,1000) | 展平 7×7×512 | — |
这里 conv3-64 表示“3×3 卷积、输出 64 个通道”。卷积层总参数量约 32.4M,三个全连接层合计约 102.6M,全模型约 143.6M。注意全连接层权重占了大头,原因有二:25088 维展平向量到 4096 的第一层全连接,权重矩阵本身就是 25088×4096,约 1 亿参数;三个全连接层叠加自然就撑起了规模。这些参数不仅数量大,反向传播时要保留的中间激活也大,所以后面遇到 OOM 不要困惑,它就是这几层吃掉的。
2.3 加载预训练模型并替换分类头:PyTorch 里的最小动作
实验的起点几乎一定是用 ImageNet 预训练权重初始化模型,然后把最后的 1000 类输出改成你自己的类别数。这步要理解vgg19.classifier这个 Sequential 模块的下标结构,才不会改错层。
import torch import torchvision.models as models # 加载 ImageNet 预训练权重;torchvision 首次会自动下载约 530MB 权重 vgg19 = models.vgg19(pretrained=True) # 替换分类头:从 1000 类改成自己的类别数 num_classes = 5 # 例如森林图像分类里的五类林地 vgg19.classifier[6] = torch.nn.Linear(in_features=4096, out_features=num_classes) # 把模型搬到 GPU/CPU 上 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') vgg19 = vgg19.to(device) # 确认改动生效:输出 Linear(in_features=4096, out_features=5) print(vgg19.classifier[6])逻辑说明:vgg19.classifier是一个torch.nn.Sequential,下标 0 是 Linear(25088,4096),下标 1 是 ReLU,下标 2 是 Dropout,下标 3 是 Linear(4096,4096),下标 4 是 ReLU,下标 5 是 Dropout,下标 6 才是输出层。改最后一层就足够,前面的卷积特征提取部分保持预训练原样。device的判断是为了兼容没有 GPU 的机器,也避免在集群上硬编码cuda:0导致多卡环境报错。权重下载会缓存在~/.cache/torch/hub/checkpoints,第二次加载就是本地读取,不会重复下载。
这里有一个容易被忽略的细节:vgg19.features和vgg19.classifier是模型的两大子模块,前者是卷积特征提取器,后者是分类器。后续做冻结或分层学习率时,都是绕着这两个名字做操作,所以先记住这个结构关系比背下网络层数更重要。
2.4 打印每层输出形状:用 forward hook 做一次前向自检
模型替换完成后,我建议不要急着开训练,先拿一张随机噪声图做一次“mock forward”,确认维度全部对得上。这一步能省掉之后至少二十分钟排错,尤其当你想改输入分辨率时,它几乎就是必需动作。
# 构造随机输入,模拟 batch=2 的 224x224 彩色图 dummy_input = torch.randn(2, 3, 224, 224).to(device) # 定义 hook 函数:打印每一层输出张量的形状 def print_shape(name): def hook(module, input, output): print(f"{name}: {output.shape}") return hook # 给特征提取部分的每个子层挂 hook for name, module in vgg19.features.named_children(): module.register_forward_hook(print_shape(f"features.{name}")) vgg19.eval() with torch.no_grad(): vgg19(dummy_input)逻辑说明:register_forward_hook是 PyTorch 的钩子机制,每次前向传播后会自动调用回调函数,这里用来观察中间特征形状。正常输出从features.0: [2, 64, 224, 224]开始,经过五次 MaxPool 缩小到features.29: [2, 512, 7, 7],最后经过 classifier,输出[2, 5]。如果中途某层 shape 与上面表格对不上,说明预处理尺寸不对;如果卡在 classifier 报错,多半是展平后的维度和Linear(25088, 4096)不匹配。这个自检动作对任何模型通用,是我做得最多、也觉得最值得做的前置检查。
3. 数据准备这步决定精度上限:目录组织、Transform 管线与样本均衡
3.1 用 ImageFolder 组织图像分类数据集:目录规划与划分脚本
不管你下载的是公开的图像分类数据集下载包,还是自己收集的照片集,进训练前的第一件事是把它整理成torchvision.datasets.ImageFolder能直接消费的目录结构:根目录下每个子文件夹一个类别,子文件夹名就是类别名。以森林图像分类为例,约定如下:
data/ train/ oak/ # 类别0 0001.jpg 0002.jpg maple/ # 类别1 birch/ # 类别2 val/ oak/ maple/ birch/如果数据是散图加一个标注文件,你需要先写划分脚本。常见做法是读取全部图片路径和标签,按类别做分层抽样,80% 进 train、20% 进 val,再移动到对应子目录。分层抽样的目的是保证 train 和 val 里每个类别的比例一致,否则 val 里某类只有 3 张图,验证指标会抖得没法看。
import os import random import shutil def split_val(source_root, target_root, val_ratio=0.2, seed=42): random.seed(seed) # 固定种子,让划分可复现 for class_name in os.listdir(source_root): class_dir = os.path.join(source_root, class_name) if not os.path.isdir(class_dir): continue images = [f for f in os.listdir(class_dir) if f.lower().endswith(('.jpg', '.jpeg', '.png'))] random.shuffle(images) val_cnt = int(len(images) * val_ratio) for split in ['train', 'val']: os.makedirs(os.path.join(target_root, split, class_name), exist_ok=True) for img in images[:val_cnt]: shutil.copy(os.path.join(class_dir, img), os.path.join(target_root, 'val', class_name, img)) for img in images[val_cnt:]: shutil.copy(os.path.join(class_dir, img), os.path.join(target_root, 'train', class_name, img))逻辑说明:random.seed(seed)是为了让每次运行划分一致,这在实验报告要求“结果可复现”时很关键。用lower()判断扩展名,避免 .JPG 被漏掉。val_ratio=0.2是常用默认值,样本总数特别少时可以改成 0.1,但不要低于 0.1,否则验证集太小,指标统计不可信。
3.2 Transform 管线:Resize、CenterCrop 与 ImageNet 归一化的标准组合
torchvision 预训练模型对输入有一套固定预期:先缩放到 256×256,再中心裁剪出 224×224,然后按 ImageNet 的均值方差归一化。训练集可以加随机增强,验证集必须保持确定性变换。代码写法如下:
from torchvision import transforms train_transforms = transforms.Compose([ transforms.RandomResizedCrop(224), # 随机裁剪并缩放,数据增强 transforms.RandomHorizontalFlip(p=0.5), # 随机水平翻转 transforms.ToTensor(), # HWC uint8 -> CHW float32,值域 0~1 transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transforms = transforms.Compose([ transforms.Resize(256), # 等比缩放到短边 256 transforms.CenterCrop(224), # 再从中心裁 224x224 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这里最讲究的是 Normalize 的均值方差。它们是预训练时在大量自然图像上统计出来的 RGB 均值方差。VGG19 的卷积层权重是在“减均值、除标准差”后的输入上学出来的,你的实验如果不做这一步,或者换成自己算的统计值,相当于把预训练权重拖到一个偏移过的分布上推理,迁移效果会明显打折。ToTensor放在 Normalize 之前也很有讲究:它先把 HWC 的 PIL 图转成 CHW 的 float 张量,像素从 0~255 缩放到 0~1,之后 Normalize 才能按 float 计算。
训练集和验证集不要共用 transform。原因在于验证集要保证每张图进入模型的区域一致,CenterCrop提供的就是这种确定性;如果验证集也用RandomResizedCrop,同一张图每次跑验证进入模型的区域都不同,验证损失曲线会变得不可解释。
3.3 DataLoader 参数:batch、num_workers、pin_memory 各管什么
数据准备最后一步是构造 DataLoader。下面这套配置是实验环境里比较稳妥的起点:
from torch.utils.data import DataLoader train_dataset = ImageFolder(root='data/train', transform=train_transforms) val_dataset = ImageFolder(root='data/val', transform=val_transforms) # batch 先给 32;显存小于 6GB 时改 16 或 8 train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True) print(f"训练集样本数: {len(train_dataset)}") print(f"验证集样本数: {len(val_dataset)}") print(f"类别映射: {train_dataset.class_to_idx}")参数说明:shuffle 只用于训练集,作用是把类别顺序打乱,避免一个 batch 内全是同一类。num_workers 是数据加载子进程数,一般不超过 CPU 核心数的一半;设成 0 表示当前进程加载,慢但在某些受限容器里更稳。pin_memory=True 对 GPU 训练有益,因为页锁定内存到显存的拷贝路径更短。batch_size 是最需要按显存调的参数:VGG19 在 224×224 输入下,训练时一张图大约占用几百 MB 显存(含反向传播保留的中间张量),6GB 显存配 batch 32 是偏紧的,建议跑一个 epoch 看实际占用再决定。
3.4 看一眼每类的样本数:不均衡问题要在训练前处理
进入训练前,我强烈建议把每个类别的训练样本数量打印出来看一眼。类别不均衡在这种实验里几乎必然出现,尤其是数据量不大时。做法如下:
from collections import Counter # 统计完整训练集里的类别分布 counter = Counter() for _, label in train_dataset.samples: counter[label] += 1 for class_idx, count in sorted(counter.items()): print(f"类别 {class_idx}({train_dataset.classes[class_idx]}): {count} 张")如果发现最少的一类只有二三十张、最多的一类上千张,至少有两种处理办法:一是用WeightedRandomSampler让少数类有更高的被采样概率;二是给CrossEntropyLoss传入 class weight。下面以第二种为例,它对训练时间几乎没有影响,实现也直接:
import torch.nn as nn total = len(train_dataset) class_weights = [ total / (num_classes * counter[class_idx]) # 样本越少的类权重越大 for class_idx in range(num_classes) ] loss_fn = nn.CrossEntropyLoss( weight=torch.tensor(class_weights, dtype=torch.float32).to(device) )这里的权重用的是“样本数倒数归一化到和为类别数”的常见方案。注意CrossEntropyLoss的 weight 需要和标签在同一个设备上,所以要.to(device)。加权重之后,整体准确率可能只动一两个点,但少数类的召回率会明显改善。在实验报告里,这种改动用混淆矩阵展示会比单看准确率更有说服力。
4. 训练与推理一条龙:超参数配置、训练循环与导出结果
4.1 优化器选择:SGD 在 VGG19 上仍然比 Adam 稳
很多人接到新任务第一反应是上 Adam,但在 VGG19 这种全连接层占比很大的网络上,我的默认选择是带动量的 SGD。两者在实验里的实际表现差异大致如下:
| 优化器 | 前期收敛速度 | 最终精度 | 收敛稳定性 | 典型学习率 |
|---|---|---|---|---|
| SGD + momentum(0.9) | 较慢 | 更高 | 稳,曲线平滑 | 0.001~0.01 |
| Adam | 快 | 通常低 1~2 个点 | 后期容易震荡 | 1e-4~3e-4 |
换到 ResNet 或 transformer 图像分类模型时,Adam 的竞争力会上升,但在 VGG19 上,SGD 的平滑更新轨迹更有利于在损失面上稳定落点。实验追求可解释性和最终验证指标,所以我优先选稳。
optimizer = torch.optim.SGD(vgg19.parameters(), lr=0.001, # 初始学习率 momentum=0.9, # 动量 weight_decay=5e-4) # L2 正则 scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, # 每 10 个 epoch gamma=0.1) # 学习率乘 0.1参数说明:lr=0.001 是迁移学习微调的常用起点,比从零训练常用的 0.01 小一个数量级,因为预训练权重已经是个局部解,步子太大会把它踢出去。weight_decay=5e-4 对全连接层多的模型几乎必备——100M 参数不加正则,小数据集上几个 epoch 就会背下训练集。StepLR 的 step_size 建议设在总 epoch 数的三分之一量级,让它至少生效一次,不要设成和总 epoch 数相等,那样等于没有调度。
4.2 训练循环:反向传播五步的标准写法
迁移学习的训练循环本身不复杂,但步骤顺序错一个就全乱。损失函数用交叉熵而不是 MSE 的原因也很直接:分类任务里交叉熵对概率分布的梯度更均匀,不会像 MSE 那样在输出饱和区出现梯度消失。
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() # 关键:打开 Dropout,更新 BN 统计 running_loss, correct, total = 0.0, 0, 0 for images, labels in loader: images = images.to(device) labels = labels.to(device) optimizer.zero_grad() # 1. 清空上一轮累计梯度 outputs = model(images) # 2. 前向传播 loss = criterion(outputs, labels) # 3. 计算交叉熵损失 loss.backward() # 4. 反向传播计算梯度 optimizer.step() # 5. 用梯度更新权重 running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) return running_loss / total, correct / total逻辑说明:model.train()必须在循环前调用,它决定 Dropout 是否生效、BN 是否累计统计量。反向传播五部曲里最容易被忽略的是optimizer.zero_grad()的位置——它必须在loss.backward()之前,否则每个 batch 的梯度累加到旧梯度上,参数更新方向完全错乱。loss.item()取的是标量,images.size(0)是当前 batch 的样本数,这样算平均 loss 时不会被最后一个不完整 batch 带偏。
这里有个新手容易疑惑的点:验证时和训练时模型行为不一样。训练模式下 Dropout 会随机抹掉部分神经元,BN 用当前 batch 统计量;验证模式下要恢复完整网络。所以训练函数第一行是model.train(),后面的验证函数第一行必须是model.eval(),两者不能搞反。
4.3 验证、保存与断点续训:别只会存最后一个 epoch
一个 epoch 结束后跑一次验证集,同时按验证准确率保存最优权重。这里的关键是用“验证集最好”而不是“最后一次训练”作为交付物,因为深度学习的最后几个 epoch 往往在局部最优点附近波动。
def validate(model, loader, criterion, device): model.eval() # 关闭 Dropout,固定 BN 统计量 running_loss, correct, total = 0.0, 0, 0 with torch.no_grad(): # 不建计算图,省显存 for images, labels in loader: images = images.to(device) labels = labels.to(device) outputs = model(images) loss = criterion(outputs, labels) running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) return running_loss / total, correct / total best_acc = 0.0 for epoch in range(epochs): train_loss, train_acc = train_one_epoch(...) val_loss, val_acc = validate(...) if val_acc > best_acc: # 只在优于历史最优时保存 best_acc = val_acc torch.save({ 'epoch': epoch, 'model_state_dict': vgg19.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'best_acc': best_acc, }, 'best_model.pth')保存格式用字典而不是单存model.state_dict()的好处是:训练中断后可以torch.load('best_model.pth')拿回优化器状态,从这个 epoch 接着跑,不用重新来前几个 epoch。这个习惯在长训练里省下的时间很可观。还有个细节:保存时顺手把epoch和best_acc写进字典,以后你想画“不同 epoch 下模型精度”的曲线都有据可查,不至于只能靠回忆。
4.4 推理导出:从 checkpoint 到单张图片的分类结果
实验交付通常需要能加载模型、对单张图片输出分类结果的小脚本。下面是最小可用版本:
import torch import torch.nn.functional as F from PIL import Image import torchvision.models as models from torchvision import transforms def load_model(path, num_classes, device): model = models.vgg19(pretrained=False) # 不需要预训练权重,用实验训好的 model.classifier[6] = torch.nn.Linear(4096, num_classes) checkpoint = torch.load(path, map_location=device) model.load_state_dict(checkpoint['model_state_dict']) return model.to(device) def predict(model, image_path, class_names, device): tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) img = Image.open(image_path).convert('RGB') x = tf(img).unsqueeze(0).to(device) model.eval() with torch.no_grad(): logits = model(x) probs = F.softmax(logits, dim=1)[0] top3_idx = torch.topk(probs, k=min(3, len(class_names))).indices.tolist() for idx in top3_idx: print(f"{class_names[idx]}: {probs[idx].item():.3f}")逻辑说明:加载时pretrained=False防止再下载权重,我们只需要网络结构骨架。checkpoint['model_state_dict']是保存时字典里的键,别直接把整个字典塞给load_state_dict。predict里的 transform 必须和训练时的验证集变换完全一致,否则输入分布偏移,输出概率没有可比性。convert('RGB')是为了处理灰度图和带 alpha 通道的 PNG。输出 top-3 而不是只看 top-1 的用意:如果一张图的 top-1 和 top-2 是两个视觉相似的类,说明模型在关键细节上仍有混淆,这个诊断信息比一个孤立的 top-1 数字有用得多。
5. 避坑:VGG19 实验最典型的 5 个翻车现场
5.1 显存不足:第一个 batch 就 OOM
现象:训练脚本刚跑完第一个 batch,控制台直接报CUDA out of memory,进程被杀。
原因:VGG19 的反向传播需要保留每一层中间激活,全连接层的参数量大,中间张量也大。以 batch=32、输入 224×224 计算,显存占用常常超过 8GB;如果机器只有 6GB 显存,OOM 是必然。另一个隐形推手是pin_memory=True配合过高 num_workers,在数据拷贝阶段额外占用显存。
解决:优先把 batch 从 32 降到 16 或 8,一般就能跑起来。降到 8 仍然 OOM 时,检查是不是把优化器、模型、数据全部放到了 CUDA,再考虑model.half()混合精度方案。不建议为了硬撑 batch 32 去调torch.cuda.set_per_process_memory_fraction,那只是把崩溃延后到下一个 batch,没有解决根源。
5.2 验证准确率忽高忽低:Dropout 和 BN 的模式没切对
现象:验证集 loss 在 0.5 到 0.8 之间大幅波动,同样一套数据和模型,两次推理结果不一样。
原因:验证代码缺少model.eval()。VGG19 的 classifier 里有 Dropout,train 模式下它会随机失活部分神经元,导致每次前向输出不同;BN 层在 train 模式下用当前 batch 统计量,进一步加剧波动。
解决:在验证和推理函数进入循环前调用一次model.eval(),训练循环第一行写model.train()。这是一个极容易漏掉、影响却很大的细节。排查这类玄学问题时,先确认这两个调用是否存在,再看with torch.no_grad()是否覆盖了验证,最后才去怀疑数据增强。
5.3 改图像尺寸后全连接层维度报错
现象:为了省显存把输入尺寸改成 128×128,一 forward 就报错,提示size mismatch for classifier.0.weight,报错信息里输入维度是 8192,而权重维度是 25088。
原因:卷积层对输入尺寸不敏感,但 VGG19 的classifier[0]写死了输入维度 25088,它来自 7×7×512。128×128 的输入经过五次 MaxPool 后变成 4×4×512=8192,和 25088 对不上。
解决:要么保持 224×224 输入不变,要么修改全连接层首层的in_features。我的建议是保输入尺寸、降 batch,因为折腾全连接层等于在和预训练结构唱反调,预训练优势会被削弱。如果你确实要改尺寸,先按 2.4 节 hook 方法打印展平后的实际维度,据此修改classifier[0]和后续结构。
5.4 训练 loss 在降、准确率却不动:类别不均衡在欺骗你
现象:训练 loss 从 2.0 平滑下降到 0.3,但验证准确率长时间停留在 80% 附近,怎么调学习率都上不去。
原因:数据集严重不均衡,例如“森林图像分类”里某一类占 85% 的样本。模型把所有输入都判成多数类,loss 依然低,整体准确率也有表面高分。这是典型的黑匣子现象——指标本身没有告诉你模型正在“偷懒”。
解决:训练前打印类别分布,发现不均衡就按 3.4 节的方法处理。改完类别权重后,整体准确率可能只动一两个点,但少数类的召回率会明显上来。实验报告里建议同时给出混淆矩阵或每类的 precision/recall,不然整体准确率这个数很容易掩盖分类器退化成频率估计器的事实。
5.5 小数据集上严重过拟合:全连接层在背答案
现象:训练准确率第 5 个 epoch 冲到 98%,验证准确率停在 72%,之后每多训一个 epoch,验证准确率不升反降。
原因:VGG19 全连接层约 100M 参数,当每类只有几十张图时,这个容量足以完整记住训练集标签。迁移学习里“预训练权重好”和“全连接层容量过大”两个事实并存,小数据集上后者会很快盖过前者。
解决:第一选择是换掉前两层全连接,改成Linear(25088, 512) → ReLU → Dropout(0.5) → Linear(512, num_classes),把分类头参数量砍掉 95%。第二选择是冻结卷积层,只训练分类头,把参与更新的参数量降下来。第三选择才是加数据增强,比如ColorJitter(brightness=0.2, contrast=0.2)、RandomRotation(10)。顺序不能反,因为增强只是让模型看到更多变换,参数量本身过饱和时增强效果很有限。
6. 迁移学习两档调参:从冻结骨干到全量微调,验证策略是否有效
6.1 档位 A:数据量小时冻结全部卷积层
如果每个类的训练样本只有几十到一两百张,我的默认策略是冻结特征提取层,只训练新加的分类头。代码上只需遍历vgg19.features.parameters()把requires_grad设为 False,优化器只接收classifier.parameters()。这个配置下 VGG19 等价于固定特征提取器加线性分类头,5~10 个 epoch 就能收敛,而且基本不会发散。如果跑完档位 A 验证准确率在 90% 以上,就不需要折腾档位 B,直接交付即可。
6.2 档位 B:数据量大时对卷积层做低学习率微调
档位 A 效果不佳,或每类样本超过三百张,就进入微调。常见做法是给features和classifier设置不同学习率,避免大步长破坏预训练卷积核:
optimizer = torch.optim.SGD([ {'params': vgg19.features.parameters(), 'lr': 1e-4}, # 卷积层小步调 {'params': vgg19.classifier.parameters(), 'lr': 1e-3}, # 分类头正常学 ], momentum=0.9, weight_decay=5e-4)参数说明:卷积层用 1e-4、分类头用 1e-3,这个比例在多数微调场景里是稳的。如果两类用同一个学习率,卷积层更新太大会破坏预训练特征提取能力,分类头学得太慢又会拖整体后腿。微调总 epoch 控制在 10 到 20 之间,超过后验证指标基本进入平台期,再跑收益有限。
6.3 验证策略有没有效:只看前三个 epoch 的 loss 形状
判断一个微调方向值不值得继续,我只看前三个 epoch:验证 loss 在 2.0 到 1.2 区间平滑下降,说明方向对、学习率量级合适;第一个 epoch 直接降到 0.01 以下然后反弹,说明学习率太大,预训练特征被冲掉;loss 几乎不动,说明学习率过小或数据管线有问题。止损比精调更值钱,因为一次失败实验的时间成本远比调参动作本身高。
VGG19 这个实验在智能计算系统课程里的位置,本质上是让你把模型结构、数据管线、超参数三元组的排错能力完整过一遍。换到 ResNet,换到 transformer 图像分类模型,流程不变:加载预训练权重、替换分类头、按验证曲线微调、检查类别均衡。我自己的教训是:头三个 epoch 的验证曲线是所有调参决策的前提,先看走势再跑长训,永远别跳过观察直接熬通宵。这篇笔记把最常踩的坑和标准解法都列了出来,希望帮到你从“跑通”进到“跑得好”这一步。
本文还有配套的精品资源,点击获取