
简介面向人工智能与智能计算系统方向的学习者这份实验包围绕VGG19图像分类任务提供了完整的可运行代码与预训练模型适合深度学习初学者以及需要了解模型部署优化的开发者实训使用。包内共有4个文件包括一个采用8位整数量化格式的VGG19模型文件以及分别用于中央处理器与专用机器学习加速单元上评估的Python脚本可直接加载模型并完成前向推理便于对比硬件差异。资源包整体约95.86MB结构精简便于快速搭建实验环境。目前已有2139人学习使用。借助这些内容用户既能掌握VGG19卷积层堆叠与全连接分类的运作逻辑也能对比不同硬件环境下的推理速度与能效表现同时熟悉模型压缩与量化部署方法为后续在嵌入式或移动端应用打下扎实基础是一份理论与实践结合的优质实验资料。1. VGG19 图像分类实验先搞清楚要交什么再决定怎么跑第一次拿到“基于 VGG19 实现图像分类”这个实验题的人多数以为难点在搭模型。实际跑起来你会发现模型代码十几行就能写完真正让你熬夜的是数据加载、预处理、显存不够、训练到一半 loss 变成 NaN以及交实验前发现验证集准确率停在及格线附近。这篇笔记按我自己跑这个实验的路径来写先讲清 VGG19 为什么被选作智能计算系统课程的常客再给一套能在本地或服务器上直接复现的最小代码最后把训练参数和五个高频翻车点挨个说清。适合正准备交实验报告、想换掉官方模板的人也适合拿这个模型练完手、下一步要切到小样本图像分类任务的人。2. 为什么是 VGG19结构拆解与选型理由读懂它再动手2.1 VGG19 的网络结构与“19”这个数字从哪来VGG19 的网络结构非常规整就是把“卷积 ReLU 最大池化”这一组操作重复堆叠。整张计算图可以分成五段卷积特征提取层外加最后三段全连接分类层。前两段各包含 2 个卷积层中间三段各包含 4 个卷积层每段结束接一个 2×2 的最大池化把特征图尺寸减半最后接 3 个全连接层前两个是 4096 维最后一个是 1000 维对应 ImageNet 的 1000 个类别。卷积层总数是 2244416全连接层是 3所以“19”指的是这 16 个卷积层加 3 个全连接层。也有的资料把 Softmax 也算进去叫它 20 层但实验里默认按前者理解。每个卷积层都使用 3×3 卷积核stride 为 1padding 为 1通道数从 64 开始逐段翻倍64→128→256→512→512。VGG19 的参数量在 1.4 亿左右其中全连接层占了绝大部分。这一点在实验报告里经常被单独拎出来问为什么 VGG 层数不深参数却比 ResNet50 多不少答案就在最后三个全连接层它们把 7×7×512 的特征图拉平后接了 4096 维全连接参数量非常夸张。实际训练时这些全连接层也是最先需要被替换的部分因为你的分类任务类别数往往不是 1000。这个结构的最大优点是“可预期”。每一层的输入输出尺寸你都能手工算出来推理时间基本跟输入图片分辨率线性相关不像某些带注意力机制的模型那样难预估。对智能计算系统实验来说这正适合做性能分析你可以在每个阶段记录特征图尺寸和耗时写进实验报告老师也容易核对。2.2 为什么课程实验和企业里的图像分类都爱用它现在最新的图像分类模型很多ViT、Swin Transformer、ConvNeXt 效果都更好但 VGG19 仍然是实验课和入门项目的首选。原因有三条。第一结构透明feature map 的尺寸变化一眼能看穿做中间层可视化、剪枝、量化等课程要求都很方便第二预训练权重随处可得torchvision 一行代码就能加载 ImageNet 权重对小样本任务特别友好——哪怕是 1-shot 或 5-shot 这种极端的图像分类设置用 VGG19 做特征提取器也比随机初始化的深层模型靠谱得多第三它不太挑数据森林图像分类、工业瑕疵分类这类背景差异较大的数据用它默认的预处理流程就能跑出可用的基线。ResNet50 和 VGG19 对比时有一个常见现象相同训练轮数下VGG19 收敛后的准确率往往略低一点但它的训练过程更“老实”loss 曲线更平滑不容易出现训练集指标突然掉零的怪事。这对实验报告很有利因为你可以把每个 epoch 的指标变化写得很规整。而 ViT 这类模型对数据量和超参数更敏感课程实验的数据量通常只有几千张直接用 ViT 预训练模型做微调往往还不如 VGG19 稳。所以我的建议是先用 VGG19 把全流程跑通拿到一个可靠基线再决定要不要换更强的模型。2.3 从 torchvision 加载预训练模型命令与三个注意点torchvision 从 0.13 开始改了权重参数写法旧的pretrainedTrue被标记为弃用新写法是显式指定权重枚举。我一般这样加载import torchvision.models as models # 新写法明确下载 ImageNet-1K 的 VGG19 预训练权重 vgg19 models.vgg19(weightsmodels.VGG19_Weights.IMAGENET1K_V1) # 对应旧写法models.vgg19(pretrainedTrue)新版仍兼容但会告警这里第一个注意点权重文件名带IMAGENET1K_V1表示这是 ImageNet-1K 上训练的第一版权重。如果你的实验环境无法联网需要提前下载好权重文件放到~/.cache/torch/hub/checkpoints/目录下否则会卡在下载阶段。第二个注意点预训练模型要求输入按 ImageNet 的统计值做归一化mean 为[0.485, 0.456, 0.406]std 为[0.229, 0.224, 0.225]。很多人在这里翻车自己随便写了归一化参数模型也能跑但准确率低好几个点。后面第 5 章我会专门讲这个坑。第三个注意点默认分类头输出是 1000 类做你自己的图像分类任务时必须替换最后一层。替换代码很简单但要放在加载权重之后做顺序错了会报参数不匹配。import torch.nn as nn num_classes 10 # 换成你自己的类别数 vgg19.classifier[6] nn.Linear(in_features4096, out_featuresnum_classes)这样改完之后整个模型仍然以预训练参数初始化只有最后一层是随机初始化。实验里管这个叫迁移学习后面第 4 章会讲怎么连前面特征层也一起微调。3. 跑通最小实验数据准备、训练循环与三个关键指标3.1 按文件夹组织图像数据用 ImageFolder 一行加载课程实验给的数据集不一定规范有时候是一堆图片放在一个文件夹里文件名带类别前缀有时候是按train/val/test分好的目录。不管哪种我建议先统一整理成 ImageFolder 能识别的目录格式因为 VGG19 训练代码里用torchvision.datasets.ImageFolder最省事。# 目标目录结构一个类一个子文件夹文件夹名就是类别名 datasets/ train/ forest/ # 森林图像 urban/ # 城市图像 water/ # 水域图像 val/ forest/ urban/ water/ test/ forest/ urban/ water/如果你手里是一堆无结构图片可以写个 Python 脚本按文件名或 CSV 标签分组也可以直接用shutil.move移动文件。这里有一个实操经验不要用中文目录名VGG19 训练脚本在部分 Windows 环境下会踩编码问题类别名尽量是简单的英文字母。整理好后用两段代码加载训练集和验证集。这里我直接写完整的数据加载和预处理部分并解释参数from torchvision import datasets, transforms from torch.utils.data import DataLoader # 训练阶段随机裁剪 随机翻转 归一化 train_transform transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) # 验证阶段不用随机增强直接缩放到 224 并归一化 val_transform 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]) ]) train_loader DataLoader( datasets.ImageFolder(datasets/train, transformtrain_transform), batch_size32, shuffleTrue, num_workers4 ) val_loader DataLoader( datasets.ImageFolder(datasets/val, transformval_transform), batch_size32, shuffleFalse, num_workers4 )这段代码里的RandomResizedCrop(224)是 VGG19 预训练模型的标准输入尺寸它会把图片随机裁剪成 224×224模拟不同尺度的目标RandomHorizontalFlip()做水平翻转增强对森林、地貌这类数据效果很好。验证集为什么用Resize(256) CenterCrop(224)而不是直接 Resize 成 224因为预训练模型训练时就是这么处理的输入分布更接近训练时看到的情况验证分数会更可信。num_workers在 Windows 上建议设成 0否则可能报多进程错误Linux 服务器可以设 4 到 8。3.2 训练主循环VGG19 的完整训练脚本骨架模型搭建好、数据加载好之后训练循环本身不长。下面是一个可以直接跑的骨架包含损失函数、优化器、单轮训练和验证。为了便于在课程实验里交差我把每个 epoch 的指标打印都写进去了。import torch import torch.nn as nn import torchvision.models as models device torch.device(cuda if torch.cuda.is_available() else cpu) vgg19 models.vgg19(weightsmodels.VGG19_Weights.IMAGENET1K_V1) vgg19.classifier[6] nn.Linear(4096, num_classes) # 替换分类头 vgg19 vgg19.to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD(vgg19.parameters(), lr1e-3, momentum0.9, weight_decay5e-4) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() # 进入训练模式让 BatchNorm 和 Dropout 生效 total_loss 0.0 correct 0 total 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) correct (predicted labels).sum().item() total labels.size(0) return total_loss / total, correct / total def validate(model, loader, criterion, device): model.eval() # 验证模式关掉 DropoutBatchNorm 用全局统计量 total_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) correct (predicted labels).sum().item() total labels.size(0) return total_loss / total, correct / total for epoch in range(1, 31): train_loss, train_acc train_one_epoch(vgg19, train_loader, optimizer, criterion, device) val_loss, val_acc validate(vgg19, val_loader, criterion, device) print(fEpoch {epoch:02d} train_loss{train_loss:.4f} train_acc{train_acc:.4f} val_acc{val_acc:.4f})逻辑说明训练时model.train()会让 BatchNorm 层根据当前 batch 更新均值方差Dropout 层随机丢弃神经元验证时必须切到model.eval()否则结果会不稳定这是第一个容易忽略的细节。优化器用的是带动量的 SGDmomentum0.9是最常用的值weight_decay5e-4可以抑制过拟合课程实验里也默认用它。这里有一个参数选择问题初始lr1e-3是微调的常见起点。如果你是从头训练 VGG19这个学习率往往太小收敛非常慢但智能计算系统实验几乎没有从头训的场景预训练权重微调用 1e-3 比较合适。想临时看一眼效果可以先跑 3 个 epoch如果训练 loss 从 1.x 往下走再放大训练轮数。3.3 三个必须关心的指标tr_loss、val_acc、过拟合判断跑完几个 epoch 后不要急着看最终 acc先把三个数字拆开看。第一个是训练集平均损失train_loss它反映模型在训练数据上的学习程度第二个是训练集准确率train_acc如果它接近 100% 而 val_acc 明显低说明模型开始背答案了第三个是验证集准确率val_acc这是实验报告最终要写的核心指标。判断是否过拟合我常用一个简单标准当val_acc连续 5 个 epoch 没有上升而训练集 acc 还在涨就说明泛化到顶了。这时候该停下来做三件事加数据增强、调大 weight_decay、减小学习率。反过来如果 val_acc 和 train_acc 都一直很低那就是欠拟合优先检查数据预处理和优化器参数别急着调网络结构。打印指标时建议每个 epoch 都顺手保存一次模型方便回滚。代码里加一行即可torch.save(model.state_dict(), fcheckpoints/vgg19_epoch{epoch:02d}_acc{val_acc:.3f}.pth)这里为什么用state_dict()而不是直接torch.save(model, ...)前者只存参数文件小且不依赖模型定义后者把整个对象序列化换环境后经常因为版本不同加载失败。这就是你实验报告里能写的“后悔药”每个 epoch 存一份最后选验证集分数最高的那份做测试不要用最后一轮的模型。4. 训练参数怎么设学习率、Batch Size、冻结训练与迁移学习4.1 学习率怎么设VGG19 的敏感度区间VGG19 对学习率的敏感度比 ResNet 高。同样是微调ResNet50 在lr1e-3时可能稳稳当当VGG19 却会在前几个 epoch 出现 loss 剧烈震荡。原因在于 VGG19 的 BatchNorm 层少整体只有 5 个 maxpool 之前的卷积块里没有太多 BN对梯度尺度的控制更依赖学习率本身。我的经验是把学习率分成三档全量微调所有参数都训练时用1e-4到3e-4只微调分类头时用1e-3数据量很小比如只有几百张图时直接1e-5。课程实验里如果数据集有几千张先按3e-4跑 10 个 epoch看 val_acc 增长曲线如果前 3 轮没动静立刻减半再重开。学习率调度我一般不加太复杂的东西课程实验用一个固定学习率加一个手动衰减就够了。但如果你想写进报告cosine衰减是最好解释的from torch.optim.lr_scheduler import CosineAnnealingLR scheduler CosineAnnealingLR(optimizer, T_max30, eta_min1e-6) # 每个 epoch 结束执行一次 # scheduler.step()T_max30表示在 30 个 epoch 内把学习率从初始值按余弦曲线降到eta_min1e-6。这种调度的好处是前期大步走、后期小步磨不用你盯着手动改。注意顺序scheduler.step()要在每个 epoch 验证结束后调用不要放在 batch 循环里不然每个 batch 都会衰减一次实际学习率会掉得飞快。4.2 Batch Size 和显存的关系小显存怎么跑 VGG19VGG19 全量训练在单张 8GB 显存的卡上batch_size32加 224×224 输入会直接 OOM。课程实验最常见的是 6GB 到 8GB 的 GPU这就要学会算显存。输入分辨率固定时显存消耗主要由 batch_size 和模型参数量决定VGG19 的中间特征图比 ResNet 大因为卷积层没有降维的 shortcut每层都保留完整通道数。如果显存不足第一选择不是换模型而是把batch_size降到 8 或 16。注意batch_size太小会让 BatchNorm 的统计量抖动VGG19 里卷积层的 BN 不多影响还勉强能接受。更稳妥的做法是开梯度累积用多个小 batch 模拟大 batch 的效果accumulation_steps 4 # 相当于 batch_size * 4 optimizer.zero_grad() for i, (images, labels) in enumerate(train_loader): images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) / accumulation_steps # 先除再累积 loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()这里有一个很多人会忽略的细节loss要除以accumulation_steps否则梯度累积后等效学习率偏大模型容易震荡。公式是等效 batch size 实际 batch_size × accumulation_steps学习率可以保持不变。我个人建议等效 batch size 控制在 64 以内超过 128 时 VGG19 的收敛速度反而变慢。如果连 16×224 都跑不动那就得考虑冻结部分层了。删掉model.features的某些 block 不可取因为预训练权重的分布会乱掉更稳的是下面这种冻结训练方案。4.3 冻结特征层做迁移学习改分类头的最小代码小样本图像分类任务里1-shot 或 5-shot 意味着训练样本极少此时微调全部参数极易过拟合。正确做法是冻结 VGG19 的特征提取层只训练自己替换过的分类头。代码上只是多一个for循环但效果差异非常大。# 冻结所有特征层参数只保留分类头可训练 for param in vgg19.features.parameters(): param.requires_grad False # 也可以把前面 2 个 stage 冻结后面 3 个 stage 继续微调 # for param in vgg19.features[:10].parameters(): # param.requires_grad False # 检查哪些层参与训练 trainable_params sum(p.numel() for p in vgg19.parameters() if p.requires_grad) print(fTrainable parameters: {trainable_params / 1e6:.2f}M)冻结后整张图的 forward 流程不变但反向传播只更新分类头那几层。这时优化器可以直接把学习率调到1e-3因为可训练参数少任务简单大步长收敛更快。如果你想更进一步可以分两阶段训练先冻结特征层训分类头 10 个 epoch再把分类头训练好的参数作为起点解冻最后一段卷积层用1e-5的学习率整体微调。这个做法写进实验报告比一次全量微调更有内容也经常能换来两个点的提升。这里顺带回答一个很多人问的问题用 ViT 这类模型做评估时分类头用调整吗答案是要。任何预训练模型都自带一套分类头输出维度和它的预训练数据集类别数绑定。你用自定义数据集做图像分类必须把分类头替换成自己的类别数否则会直接报维度不匹配。VGG19 要替换classifier[6]ViT 要替换heads层原理一样别只改输入不换输出。5. 避坑手册VGG19 训练里最常见的 5 个翻车点5.1 Loss 不降或直接变成 NaN先查归一化和输入范围现象训练循环能跑通但 loss 始终在 5、6 附近波动或者某个 epoch 突然变成 NaN。原因绝大多数情况是输入图片没有做归一化。ToTensor()之后像素范围会变成 0 到 1但 VGG19 的预训练权重是在经过 ImageNet 均值方差归一化后的数据上训练的。如果你只转 Tensor 不 Normalize输入分布和预训练权重完全错位特征提取器输出的数值范围偏大交叉熵损失不稳定甚至溢出色 NaN。另一种常见原因是 RGB 通道顺序反了比如 OpenCV 读图默认是 BGR直接转成 Tensor 后颜色错位loss 会缓慢下降但准确率永远上不去。解决在 transform 里强制加Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225])如果是用cv2.imread读图先执行cv2.cvtColor(img, cv2.COLOR_BGR2RGB)。校验方法很简单把一张图打印出来看通道均值是否接近 0.5 以下而不是忽高忽低。5.2 训练时间长得离谱先用小模型小数据跑通再上全量现象第一个 epoch 跑了十几分钟按这个速度 30 个 epoch 要跑大半天感觉没法交实验。原因课程实验最容易犯的错就是拿到全量数据直接开训。VGG19 参数量大全量训练一轮就要处理上万张图。如果 CPU 环境连 GPU 都没有光一个 forward 就要好几秒。解决先把datasets.ImageFolder指向一个临时mini目录每个类只放 20 张图batch_size 固定跑 2 个 epoch 验证代码正确性。确认能出结果后再把数据切回全量。如果确实没有 GPU那就把 batch_size 降到 8epoch 改到 10并用预训练权重不要尝试从头训练。实验报告里可以写清楚“在 CPU 环境下只微调分类头”老师反而会觉得你理解了迁移学习的价值。5.3 验证集准确率比训练集还高Transform 顺序和 Dropout 的坑现象训练 5 轮后 train_acc 是 92%val_acc 却有 95%怎么看都不合理。原因训练时模型处于train()模式RandomResizedCrop和RandomHorizontalFlip会让部分图片被裁剪或翻转训练难度更大而验证集的预处理是固定的CenterCrop没有随机扰动。另一个因素是 DropoutVGG19 的 classifier 里有 Dropout 层训练时会随机丢弃部分神经元推理时不丢弃验证自然更顺。这不是 bug只要训练和验证的 transform 长得不一样就是正常的。解决别慌这是预期现象。你应该改关注 val_acc 是否持续高于 train_acc如果是说明你验证集的变换和训练集差异太大或者数据划分有问题比如同一个类的大量相似图被分到了两边。排查方法打印几个批次的训练和验证图片看有没有重复图。5.4 预处理参数不一致自己瞎写 mean 和 std导致模型效果大跌现象同样代码在 A 机器上跑 acc 90%在 B 机器上只有 75%代码没改权重也一致。原因B 机器上可能重写了 transform用了transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))这类默认参数。VGG19 预训练模型对输入分布敏感用错 mean/std 相当于把输入整体平移缩放特征分布全部错位。解决把预处理的 mean/std 写死成 ImageNet 的标准值不要图省事复制网上杂乱的代码。这里给一个自查代码from torchvision import transforms from PIL import Image import torch img Image.open(test.jpg).convert(RGB) t 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]) ]) x t(img).unsqueeze(0) print(x.mean().item(), x.std().item())输出均值应该在 -1.5 到 1.5 之间标准差在 0.5 到 1.2 之间。如果均值整张图都是 0.x 以上说明归一化参数不对。5.5 报错尺寸不匹配分类头维度问题全解现象加载预训练模型后直接训练报错size mismatch for classifier.6.weight或者干脆在 forward 时报矩阵维度对不上。原因你用了官方权重但权重里最后一层是按 1000 类保存的你替换成num_classes后如果你没有替换就去 load 原始 state_dictPyTorch 会拒绝加载。很多人在model.load_state_dict(torch.load(vgg19.pth))时中招。解决按顺序来先替换分类头再加载权重。如果权重是从别人那里来的、不是 torchvision 的标准权重加载时用strictFalse让 PyTorch 跳过不匹配的那一层state_dict torch.load(your_weight.pth) vgg19 models.vgg19(weightsNone) # 不要实例化时自动下载权重 vgg19.classifier[6] nn.Linear(4096, num_classes) vgg19.load_state_dict(state_dict, strictFalse)strictFalse会把缺失或者 shape 不一致的参数跳过只加载能匹配的层。这个参数是双刃剑它会静默跳过所有不匹配的 key你很难发现跳过的是哪层。建议加载后打印一条检查信息确认特征层参数确实被加载了loaded vgg19.load_state_dict(state_dict, strictFalse) print(loaded.missing_keys) # 缺失的 key 列表 print(loaded.unexpected_keys) # 多余的 key 列表正常情况是unexpected_keys里只有classifier.6.weight和classifier.6.bias其他都为空。6. 从跑通到会调特征图可视化与小样本对比验证6.1 用 hook 把 VGG19 中间层变成可视化素材实验报告想加分最直接的方式是展示模型“看到了什么”。VGG19 每层卷积输出都是一个特征图序列取其中某一层的输出按通道求和或取最大再缩放到 0-255 就是一张可视化图。关键代码是注册 forward hookactivation {} def hook_fn(module, input, output): activation[value] output.detach() handle vgg19.features[28].register_forward_hook(hook_fn) # 跑一次 forward _ vgg19(img_tensor.unsqueeze(0).to(device)) # 处理特征图取前 64 个通道每通道做 min-max 归一化 feat activation[value][0] # shape: [C, H, W] feat feat.permute(1, 2, 0).cpu().numpy() feat_min, feat_max feat.min(), feat.max() feat (feat - feat_min) / (feat_max - feat_min 1e-8) handle.remove()vgg19.features[28]对应的位置要看你的需求数字越小画出来越像边缘纹理越靠近后面的卷积层越能看出塔楼、森林边界的轮廓。我建议选两个点比如features[10]和features[28]对比低层边缘特征和高层语义特征图放报告里很有说服力。6.2 用小样本对比验证环境和数据有没有问题课程实验做完后如果你打算把方向延伸到小样本图像分类我有个习惯用 1-shot 和 5-shot 的设定同时跑 VGG19 和 ResNet50用结果来验证环境和预处理是否可靠。做法是每类只取 1 张或 5 张图做训练集其他图做测试集如果 VGG19 在这个设置下准确率明显低于随机比如 10 类任务只有 12%说明你的预处理或数据划分有问题换更好的模型也没有意义。这是最廉价的环境校验手段。最后一件事冻结训练时我吃过亏就是把训练集做增强、验证集也做增强结果 val_acc 一直上不去。后来养成习惯任何模型训练前先打印 4 个批次的 image 形状和 label 分布确认数类别数和通道数再开训。这套流程从 VGG19 换到 ViT、ResNet50 都适用。希望帮到你。本文还有配套的精品资源点击获取