
简介智能计算系统实验4-1的图像分类资料包面向正在学习卷积神经网络与模型部署的人工智能学习者聚焦用VGG19完成图像分类评估及CPU/MLU平台性能对比。压缩包共4个文件以3个Python脚本和1个INT8量化模型文件为主整体约95.86MB兼顾模型加载、前向推理与准确率统计等实验环节。已有2154人学习适合作为深度学习实验课或嵌入式推理入门的参考材料。读者可借助其中的量化VGG19模型和双平台评估脚本理解权重与激活量化、Softmax类别预测、硬件加速推理等关键知识点并对照CPU与MLU运行结果分析速度与能效差异为后续模型轻量化和部署优化积累可复用的实验思路。1. 从一组卡在 60% 的验证准确率说起智能计算系统实验里基于 VGG19 的图像分类是绕不开的一题数据集不大、显卡只有一张、交付时间很紧。很多人第一次跑完验证集准确率停在六成出头第一反应是“VGG19 太老了是不是该换 ResNet”于是折腾半天模型指标纹丝不动。真实原因往往不在网络结构而在预处理和预训练权重没对齐、冻结策略和 BatchNorm 状态没处理好、评估口径里混进了增强后的验证集这几件事上。这套方案要解决的就是用单卡消费级显存把 VGG19 的 ImageNet 预训练权重迁移到自己的类别体系上做到训练可复现、指标可解释、实验报告能直接拿去答辩。适合正在做智能计算系统实验、深度学习课程设计或者需要一个能直接改成业务分类基线的读者。2. 需求与总体设计四层结构与三条流水线动手写代码之前先把“这个实验到底要交付什么”定下来。一个合格的图像分类实验交付物通常包含四部分一份能一键跑的工程、一组可复现的训练日志、一张覆盖每类的评估报告、一份讲得清取舍的说明文档。这四部分决定了工程结构而不是反过来先写代码再补文档。2.1 任务边界与数据规格先把数据和任务钉死后面所有参数才有依据。典型课程实验的规模是 5 到 10 个类别、每类 150 到 300 张图片总量在千张量级。这个体量下从零训练 VGG19 一定会过拟合迁移学习不是可选项而是必需项。数据组织用最省事的方式一个类别一个子目录交给 ImageFolder 直接读避免自己写索引文件。目录结构建议这样切分vgg19_cls/ ├── configs/ │ └── base.yaml # 类别数、冻结块数、学习率全部外置改参数不动代码 ├── data/ │ └── all/ # 原始图片每类一个子目录 │ ├── class_a/ │ ├── class_b/ │ └── ... ├── src/ │ ├── dataset.py # 增强、分层划分、DataLoader 构建 │ ├── model.py # VGG19 改造与冻结策略 │ ├── engine.py # 训练/验证循环与指标累计 │ └── metrics.py # 混淆矩阵、每类 P/R/F1 ├── train.py ├── evaluate.py └── infer.py原始图片统一放data/all划分在代码里按索引做而不是手动把文件挪到 train/val/test 三个文件夹。这样做有两个好处划分比例、随机种子写进配置就能复现想换一种划分重跑删掉缓存就行不用整理文件。代价是 ImageFolder 需要被包一层带独立 transform 的 Dataset后面 3.1 会给出实现。提示划分比例建议 7:1.5:1.5验证集和测试集都要按类别分层抽样。直接用random_split在类别不平衡的数据上很容易切出某一类验证集只有两张图的情况指标没有任何统计意义。2.2 VGG19 的参数账与算力预算VGG19 的结构很规整5 个卷积块每块内部全是 3×3 卷积堆叠块尾接 2×2 最大池化最后接三层全连接。规整带来的代价是参数量和显存都集中在后半段先把这笔账算清楚选 batch size 和冻结层数时心里才有数。阶段输出尺寸通道参数量备注block1conv3-64 ×2224→112640.04M激活值最大显存大头block2conv3-128 ×2112→561280.22M冻结后基本不占反传显存block3conv3-256 ×456→282562.07M常作为冻结边界block4conv3-512 ×428→145128.26M语义特征开始明显建议放开block5conv3-512 ×414→75129.44M与任务最相关必须训练classifier4096-4096-10001×1—123.6M占全模型 86%改造重点全模型约 143.7M 参数其中 123.6M 在原始全连接层里。224×224 单张图片前向计算量在 20 GFLOPs 量级训练时还要再乘三倍左右的反向开销。这个数字直接决定了两件事全连接层必须重做否则你训练的是一个 1000 类分类器以及冻结前几个卷积块不只是为了防过拟合也是省显存的实际手段——被冻结的层不保存反向传播所需的中间激活8GB 显存下 batch size 从 16 提到 32 往往就靠这一步。2.3 训练、验证、推理三条流水线的职责划分工程分三个入口各自只做一件事避免出现“训练脚本里顺手做了评估”的耦合。train.py负责读配置、固定随机种子、构建模型与数据、跑训练循环、按验证集指标保存最优权重、把每轮日志写成 JSONL。它不负责最终报告。evaluate.py负责加载指定权重、在测试集上算整体准确率、每类精确率/召回率/F1、混淆矩阵并把分类错误的样本路径导出成 CSV。答辩时最有说服力的材料就来自这个 CSV。infer.py负责单图或目录推理输出 top-k 类别和置信度顺便验证模型在真实输入上的表现。三条流水线共享src/下的模型构建和数据构建函数保证训练时用的预处理和推理时完全一致。这一点听起来是常识但每年都有人栽在这里训练用了归一化推理脚本忘了加同一个模型在两张脚本里给出完全不同的结果最后怀疑是权重保存坏了。3. 数据管线与分类头改造把小数据集接上 ImageNet 权重这一章是整套方案里最影响最终指标的部分。同样的 VGG19 权重预处理对齐与不对齐验证准确率能差出二三十个百分点。3.1 数据增强与分层划分的实现小数据集上的增强策略要克制。RandomResizedCrop 的 scale 下限设到 0.7 就够再往下裁一张图里可能只剩背景模型学到的是噪声。颜色抖动的幅度同样不宜大因为预训练权重的特征是在相对“干净”的自然图像上形成的输入分布偏太多反而拖慢收敛。import random from collections import defaultdict from PIL import Image from torch.utils.data import Subset from torchvision import transforms from torchvision.datasets import ImageFolder # 必须与 torchvision 预训练权重使用的统计量完全一致 MEAN [0.485, 0.456, 0.406] STD [0.229, 0.224, 0.225] train_tf transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(MEAN, STD), ]) val_tf transforms.Compose([ transforms.Resize(256), # 先按短边缩放再中心裁剪保持长宽比 transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(MEAN, STD), ]) class TransformSubset(Subset): 带独立 transform 的子集划分索引与增强策略解耦 def __init__(self, dataset, indices, transform): super().__init__(dataset, indices) self.transform transform def __getitem__(self, i): path, label self.dataset.samples[self.indices[i]] img Image.open(path).convert(RGB) return self.transform(img), label def stratified_split(dataset, ratios(0.7, 0.15, 0.15), seed42): 按类别分层切分返回三组索引小样本下避免某类在验证集里只剩一两张 rng random.Random(seed) by_cls defaultdict(list) for idx, (_, label) in enumerate(dataset.samples): by_cls[label].append(idx) train_idx, val_idx, test_idx [], [], [] for _, idxs in sorted(by_cls.items()): rng.shuffle(idxs) n len(idxs) n_train, n_val int(n * ratios[0]), int(n * ratios[1]) train_idx idxs[:n_train] val_idx idxs[n_train:n_train n_val] test_idx idxs[n_train n_val:] return train_idx, val_idx, test_idxTransformSubset继承Subset只覆写取数方法把 transform 从数据集的属性变成子集的属性。这样同一份原始数据可以派生出训练版和验证版两个视图不用复制文件。stratified_split用固定种子的random.Random而不是全局的random.shuffle目的是让划分结果不受其他地方调用随机数的影响——训练脚本里任何一个随机操作都可能改变全局状态这种耦合在复现时非常难查。划分完成后训练集和验证集的 transform 必须不同训练用增强版验证和测试只用缩放裁剪加归一化。把增强加到验证集上会让指标每轮抖动一两个点看起来像模型不稳定其实是评估本身带了随机性。3.2 分类头替换与冻结策略VGG19 的原始分类器是 4096-4096-1000 三层全连接直接换掉最后一层不够因为前两个 4096 层本身就是按 ImageNet 的粒度训练的对几百张图的小任务来说是巨大的过拟合源。常规做法是整体替换成“降维 Dropout 输出层”的轻量头。同时冻结策略要分层看block1、block2 学的是边缘和纹理与具体类别无关冻结它们几乎不损失精度还能省下可观的显存block3 开始出现物体部件级的特征可以根据数据量决定是否放开block4、block5 的语义特征和任务最相关必须参与训练。import torch.nn as nn from torchvision import models # 每个最大池化层在 vgg19.features 中的索引用于确定冻结边界 POOL_IDX [4, 9, 18, 27, 36] def build_vgg19(num_classes, freeze_blocks3, dropout0.5, global_poolFalse): weights models.VGG19_Weights.IMAGENET1K_V1 model models.vgg19(weightsweights) # 1) 冻结前 freeze_blocks 个卷积块含块尾池化层 if freeze_blocks 0: cut POOL_IDX[freeze_blocks - 1] 1 for p in model.features[:cut].parameters(): p.requires_grad False # 2) 可选用全局平均池化替代固定 7x7换输入尺寸时不用改分类头维度 if global_pool: model.avgpool nn.AdaptiveAvgPool2d(1) in_features 512 else: in_features model.classifier[0].in_features # 512*7*7 25088 # 3) 重做分类头25088 - 512 - num_classes比原始 4096 层轻一个量级 model.classifier nn.Sequential( nn.Linear(in_features, 512), nn.ReLU(inplaceTrue), nn.Dropout(dropout), nn.Linear(512, num_classes), ) return model三个参数各有取舍。freeze_blocks3是千张级数据集的稳妥起点可训练参数约 17.7Mblock4、block5加上新分类头约 12.8M总计 30M 左右Adam 优化器状态再翻一倍8GB 显存跑 batch 32 没有压力。数据量到三千张以上可以降到 2让 block3 也参与微调。dropout0.5是分类头前的标准值如果发现训练集准确率始终上不去比如停在 70%说明正则过强降到 0.3 再试。global_poolTrue这个开关值得单独说。原始 VGG19 的avgpool是AdaptiveAvgPool2d((7,7))卷积部分下采样 32 倍只有输入 224 才能正好得到 7×7。如果你为了省显存把输入改成 128卷积输出是 4×4自适应池化会把它“放大”到 7×7插值出来的特征全是冗余同时分类头的 25088 维输入完全对不上语义。开启全局池化后输入维度固定为 512输入尺寸可以自由调代价是丢失了空间位置信息——对细粒度分类可能掉点对普通物体分类通常无损。3.3 损失函数与优化器的初始配置损失用标准的交叉熵。如果类别不均衡明显最多类和最少类差三倍以上加label_smoothing0.1比直接上类别权重更稳后者容易让模型在少数类上过拟合到噪声样本。优化器分两种情况冻结了卷积层时用 Adam学习率 1e-4全部放开微调时换 SGDmomentum 0.9学习率 1e-3 起步配合余弦退火。这个搭配不是玄学Adam 的自适应步长在只训练分类头时收敛快但一旦解冻卷积层各层梯度尺度差异变大Adam 容易把预训练好的浅层特征打乱SGD 的更保守更新反而保住了这些特征。# configs/base.yaml seed: 42 num_classes: 6 freeze_blocks: 3 dropout: 0.5 epochs: 30 batch_size: 32 lr: 1.0e-4 weight_decay: 1.0e-4 label_smoothing: 0.1 scheduler: cosine early_stop_patience: 8配置外置的意义在于答辩时被问“学习率换成 1e-3 会怎样”你可以直接改一行重跑而不是翻代码找那一行写死的数字。weight_decay在只训练分类头时给 1e-4全量微调时提到 5e-4抑制解冻后大量参数同时偏移。4. 训练循环与实验记录让每一轮都留下证据训练脚本的价值不只是把 loss 降下去更要让“为什么这一轮指标高”这个问题有答案。所以循环里要同时处理三件事前向反向、指标累计、状态落盘。4.1 训练与验证循环的实现细节冻结卷积层时有一个非常容易忽略的点BatchNorm 的 running mean/var。参数设了requires_gradFalse只代表不更新权重BN 层在model.train()模式下依然会用当前 batch 的统计量去更新 running stats。小 batch 下这些统计量噪声很大几轮之后整个冻结部分的归一化基准就偏了表现为验证指标先升后突然崩塌。处理办法是在每个 epoch 开始时把整个模型切到 train 模式再把冻结部分单独切回 eval 模式。import torch import torch.nn as nn from tqdm import tqdm def freeze_bn(module): 把 BatchNorm 层切到 eval冻结 running stats 的更新 if isinstance(module, nn.BatchNorm2d): module.eval() def apply_freeze_mode(model): 冻结参数所在的模块整体走 eval其余保持 train model.train() model.features[:POOL_IDX[2] 1].apply(freeze_bn) def train_one_epoch(model, loader, criterion, optimizer, device, scalerNone): model.train() apply_freeze_mode(model) # 顺序不能反先 train 再定点 eval total_loss, correct, total 0.0, 0, 0 for imgs, labels in tqdm(loader, leaveFalse): imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad(set_to_noneTrue) with torch.autocast(cuda, enabledscaler is not None): logits model(imgs) loss criterion(logits, labels) if scaler is not None: # 混合精度时用 scaler 反传避免梯度下溢 scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() else: loss.backward() optimizer.step() total_loss loss.item() * imgs.size(0) correct (logits.argmax(1) labels).sum().item() total imgs.size(0) return total_loss / total, correct / total torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() # 评估必须整体 eval关掉 Dropout 和 BN 更新 total_loss, correct, total 0.0, 0, 0 for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) logits model(imgs) total_loss criterion(logits, labels).item() * imgs.size(0) correct (logits.argmax(1) labels).sum().item() total imgs.size(0) return total_loss / total, correct / total几个参数的作用需要说清楚。optimizer.zero_grad(set_to_noneTrue)比默认写法省一次写显存在 8GB 卡上做小 batch 训练时是实打实的收益。混合精度torch.amp的 GradScaler能把显存占用压掉三成左右代价是要注意某些算子在 fp16 下溢出VGG19 这种纯卷积 全连接的结构基本不会踩到。freeze_bn只处理BatchNorm2dVGG19 原版没有 BN但如果你为了稳定训练给卷积层加了 BN这段就必须保留。apply_freeze_mode里硬编码了POOL_IDX[2] 1实际使用时应该从配置里读冻结块数再算边界否则改配置时这里会静默失效——脚本照跑但冻结实际上没生效指标会莫名其妙地差一截。4.2 学习率调度与早停30 个 epoch 的小实验余弦退火比步进衰减更平滑不需要手工设定在第几轮降学习率。早停的 patience 设 6 到 8 轮监控指标用验证集的宏平均 F1 而不是准确率——类别不均衡时准确率会被大类主导早停可能停在“大类全对、小类全错”的轮次上。保存权重的逻辑要写对只在验证指标创新高时保存并且同时保存state_dict、类别映射表、预处理参数三样。只存权重是最常见的坑换台机器推理时不知道第 0 类对应哪个文件夹整个模型就废了。ckpt { model: model.state_dict(), classes: train_ds.dataset.classes, # 顺序即输出维度顺序必须落盘 mean: MEAN, std: STD, input_size: 224, epoch: epoch, val_f1: best_f1, } torch.save(ckpt, outputs/best.pt)4.3 指标、日志与错误样本导出整体准确率只是门面真正能写进报告的是每类指标和混淆矩阵。用 sklearn 几行就能算出来重点是把错误样本的路径导出来答辩时能指着具体图片说“这一类被错分成那一类因为背景里都有相似的纹理”。from sklearn.metrics import classification_report, confusion_matrix, f1_score import pandas as pd import numpy as np def report(model, loader, device, classes, out_diroutputs): model.eval() y_true, y_pred, paths [], [], [] with torch.no_grad(): for idx, (imgs, labels) in enumerate(loader): logits model(imgs.to(device)) pred logits.argmax(1).cpu().numpy() y_true labels.numpy().tolist() y_pred pred.tolist() print(classification_report(y_true, y_pred, target_namesclasses, digits4)) macro_f1 f1_score(y_true, y_pred, averagemacro) cm confusion_matrix(y_true, y_pred) np.savetxt(f{out_dir}/confusion_matrix.csv, cm, fmt%d, delimiter,) # 错误样本落盘文件名 真实类 预测类 置信度用于答辩时的错例分析 df pd.DataFrame({path: paths, true: y_true, pred: y_pred}) df[df[true] ! df[pred]].to_csv(f{out_dir}/errors.csv, indexFalse) return macro_f1macro_f1对每类一视同仁是判断模型是否真的学到了所有类别的关键指标。classification_report输出的每类召回率如果有一类明显低于其他类先别调模型回去看该类样本数量和增强是否过强——小类样本本来就少再被 RandomResizedCrop 裁掉主体模型等于在学背景。5. 避坑与排查VGG19 迁移学习里最容易翻车的五个点这一章列的是我在实验报告和答辩里反复见到的失败模式每一条都给出定位思路而不是笼统的“调调参”。5.1 现象loss 正常下降验证准确率卡在随机水平附近原因通常是输出维度与标签对不上。换分类头时如果只改了最后一层的out_features却保留了原始 4096 维的中间层模型仍然在拟合 ImageNet 的特征尺度小数据集上根本收敛不到有意义的解另一种情况是标签从 0 开始、类别数配置少了 1训练能跑但某一类永远预测不出来。解决方式训练前打印一次logits.shape和labels.max()确认前者等于num_classes、后者小于num_classes。分类头的输入维度也要打印确认是 25088 还是 512与是否开启全局池化对应。这两行断言放在训练脚本最前面能省掉大半天的无效训练。5.2 现象前几轮指标上升第十轮左右验证准确率突然断崖式下跌这是冻结层 BN 统计量漂移的典型表现。冻结了参数但没冻结 BN每个 batch 都在用不具代表性的小批统计量覆盖 running stats累积几轮后冻结部分的特征分布彻底偏离预训练状态。解决方式按 4.1 的apply_freeze_mode处理冻结部分的 BN 层强制 eval。如果没有 BN 层检查是不是把整个模型误设成了model.train()覆盖了之前的eval()调用——这种顺序错误在代码里几乎看不出来加一条打印model.features[0].training的断言最省事。5.3 现象第一个 batch 就 CUDA out of memoryVGG19 的显存占用大头不在参数而在 block1 的激活值224×224×64 的单样本特征图约 12.8MBbatch 32 就是 400MB 量级加上后续各层和反向缓存8GB 卡跑全量反传很容易顶到上限。解决顺序建议这样先把 batch size 降到 16再把冻结块数提到 3冻结层不保存反传激活然后开启混合精度最后才考虑降输入尺寸同时开启全局池化。直接降输入尺寸是最容易引入新问题的做法因为 224 是预训练权重的原生尺寸改了之后浅层特征的响应会变弱指标下降时很难判断是显存策略的锅还是模型本身的锅。5.4 现象整体准确率 85%但某一类召回率只有 0.2类别不平衡加上只看总准确率会掩盖模型完全没学会某个类的事实。混淆矩阵里这一类往往被大量错分到样本最多的邻类。解决方式先算每类样本数差距三倍以上就在损失里加label_smoothing或对少数类做重采样增强上对少数类适当放宽比如 scale 下限从 0.7 提到 0.85避免裁掉主体评估一律用宏平均 F1 作为选模型的依据。答辩时主动展示每类指标比等评委问出来要好得多。5.5 现象换成本地图片推理结果全乱训练时的预处理和推理时的预处理不一致这是最冤的一类错误。常见变体有三种推理忘了 Normalize、推理用了Resize(224)而不是先缩放短边再中心裁剪导致长宽比失真、推理读图没有convert(RGB)而图片带 alpha 通道变成四通道输入。解决方式把预处理管道写成src/dataset.py里的一个工厂函数训练、评估、推理三处都调它不给手写第二份的机会。同时在 checkpoint 里存下 mean/std 和 input_size推理脚本从 checkpoint 读而不是从代码里读常量。6. 验收与答辩用一张表证明你的实验是可信的实验做完不等于能交付。答辩现场最容易被问的三个问题是你的 baseline 是什么、提升来自哪一步、错误集中在哪。这三问其实对应同一件事——你有没有留下对照组和证据链。验收建议按下面这张表自查每一项都能在工程里找到对应文件或日志验收项合格线证据位置整体准确率测试集 ≥ 85%6 类、千张级数据outputs/report.txt宏平均 F1与准确率差距 ≤ 5 个点同上每类召回率最低类 ≥ 0.7classification_report训练曲线验证指标单调性可解释无断崖outputs/train.jsonl权重完整性含 classes、mean/std、input_sizebest.pt的 key 列表推理一致性同一张图 evaluate 与 infer 结果一致随机抽 10 张比对错误分析有错例 CSV 且能说出主要混淆对outputs/errors.csv对照组的做法很简单但很多人省掉了先跑一个freeze_blocks5只训练分类头的版本作为 baseline再跑freeze_blocks3最后跑freeze_blocks0全量微调。三个数字放在一起就能直接回答“提升来自哪一步”。我一般还会额外跑一组关掉数据增强的实验验证增强到底有没有用——有时候在小数据集上增强反而拖慢收敛这个结论本身就是报告里的亮点。答辩陈述控制在五分钟内结构按“任务与数据 → 结构改造的两处关键决策分类头重做、分层冻结→ 三组对照结果 → 错例分析”推进不要从卷积核讲起。被问到“为什么不用 ResNet”时答案不是“VGG19 更经典”而是“本实验的计算预算下VGG19 的结构规整、特征可视化直观便于分析冻结层对特征的影响同时它的全连接层参数量大正好构成一个值得讨论的过拟合案例”——把限制讲成设计取舍比硬夸模型有说服力。这套实验的完整工程含数据管线、模型改造、训练脚本、评估与错例导出、验收表模板我整理成了一份可直接运行的资源包需要的话按文末方式获取改一下configs/base.yaml里的类别数就能套到自己的数据集上。踩过最疼的一次坑是论文式的报告写得很漂亮但评委问“你这个 87% 是在哪份权重上跑的”时我发现 outputs 目录里有七个best.pt没有一个带类别映射。从那以后我每次保存 checkpoint 都强制把 classes、mean/std、input_size 三样一起塞进去训练脚本里加一条加载回读的断言跑通才允许开始正式训练。希望帮到你。本文还有配套的精品资源点击获取