简介:面向深度学习入门、人工智能课程实验与智能计算系统实践的学习者,这份资料围绕VGG19卷积神经网络完成图像分类任务,重点解决模型加载、前向推理、准确率评估与硬件加速对比等实践问题。压缩包共4个文件,约95.86MB,以3个Python脚本和1个INT8量化模型文件为主:前者负责数据预处理、模型前向传播、类别预测及准确率统计,后者提供可直接加载的量化权重,便于在CPU与MLU两种平台上运行评估并观察性能差异。已有2152人学习下载,学习热度较高,适合作为CNN实验课与模型部署练习的参考。读者可据此理解VGG19的卷积堆叠、池化降维与全连接分类流程,掌握图像尺寸调整、像素归一化、Softmax概率输出、INT8量化带来的内存与速度收益,并通过CPU与MLU双平台评估结果形成对推理性能优化的直观认识。
1. 先看这个 VGG19 图像分类实验项目:它解决的不是“训个模型”而是完整链路
人工智能入门实验里,图像分类最容易让人产生“我已经会了”的错觉:模型一调、训练一跑、准确率一出来,好像就结束了。真拿到这份基于 VGG19 的图像分类实验源码,第一反应往往是另一个问题——数据放哪、预训练权重怎么接、训练完的模型怎么喂一张自己的图。这个项目适合刚接触深度学习、想跑通一次完整分类链路的人,也适合已经会写训练脚本、想把实验代码改成可复用服务的人。VGG19 不新,参数量也不小,但它的结构直白,卷积段和全连接段边界清楚,拿来拆迁移学习、数据增强、推理预处理这些环节反而顺手。下面按目录、环境、核心代码、避坑和二次开发几段往下拆。
2. 项目结构与技术栈:从数据目录到推理入口的模块划分
这个实验包如果只看训练脚本,会觉得它就是“加载 VGG19、换掉最后一层、跑几个 epoch”。真正读进去会发现,代码的价值在于把数据组织、模型构建、训练验证、权重保存和单张推理串成了一条能复现的线。目录清不清楚,直接决定后面换数据集时会不会翻车。
2.1 目录层级与各模块职责
一个常见的实验目录会长成下面这样。不同压缩包里的文件命名可能略有差别,但职责划分基本绕不开这几块。
vgg19-classification/ ├── data/ │ ├── train/ │ │ ├── class_a/ │ │ └── class_b/ │ └── val/ │ ├── class_a/ │ └── class_b/ ├── src/ │ ├── dataset.py # Dataset、transform、DataLoader 构建 │ ├── model.py # VGG19 加载、分类头替换、冻结策略 │ ├── train.py # 训练与验证循环、权重保存 │ ├── predict.py # 单张图片/批量图片推理 │ └── utils.py # 随机种子、指标统计、路径检查 ├── checkpoints/ ├── outputs/ ├── requirements.txt └── README.mddata/train和data/val按类别分文件夹,这是图像分类实验里最省事的组织方式。训练脚本通常不需要额外标签文件,而是直接读文件夹名作为类别名。读代码时重点看dataset.py里有没有把class_to_idx固定下来,因为训练和推理的类别顺序一旦不一致,模型输出就会整体错位。
src/model.py负责 VGG19 的加载和分类头替换,常见做法是保留features卷积段,把classifier最后一层换成当前数据集的类别数。train.py负责训练循环、验证、保存 best 权重和 last 权重。predict.py负责把一张图走一遍和验证集一致的预处理,再输出 top-k 类别。checkpoints放权重,outputs放日志或可视化结果,这两个目录不要和源码混在一起。
提示:拿到源码后先别急着执行训练命令,先把
README、requirements.txt和train.py里的参数入口看一遍,确认数据路径是相对路径还是绝对路径。
2.2 技术栈选型与运行环境
这个实验的技术栈不复杂,关键是每一层都别缺。下面这张表按职责拆开看。
| 技术/库 | 在项目里的职责 | 读代码时关注点 |
|---|---|---|
| Python | 训练、推理、脚本入口 | 虚拟环境是否独立,路径是否带空格 |
| PyTorch | 张量计算、模型训练、权重保存 | device是否统一,state_dict是否完整 |
| TorchVision | 加载 VGG19、图像变换、ImageFolder | 预训练权重是否真的加载成功 |
| Pillow | 读取 jpg/png 图片 | 推理时是否convert("RGB") |
| NumPy | 指标统计、数组处理 | 与 Tensor 转换是否多余 |
| tqdm | 训练进度条 | 不影响训练,但方便看每轮耗时 |
VGG19 在这个实验里不是追求 SOTA,而是结构足够规整。它的卷积段由多个Conv2d + ReLU + MaxPool2d堆起来,最后接三层全连接。对新手来说,冻结卷积段、只训练分类头,能明显降低显存压力和训练时间;对熟手来说,这种结构也方便做层冻结、学习率分组、特征提取对比。常见做法是用预训练权重初始化,再替换分类头做迁移学习,而不是从随机初始化开始硬训。
运行环境上,我一般会先确认三件事:Python 能不能正常起虚拟环境,PyTorch 能不能识别到 GPU,TorchVision 能不能加载 VGG19。前两件事决定训练能不能跑,第三件事决定迁移学习是不是真的生效。预训练权重加载失败时,训练脚本有时不会立刻报错,而是静默使用随机权重,这种情况后面损失曲线会很难看。
2.3 数据流:一张图片从磁盘到分类结果
数据流这条线要理顺:磁盘图片先被 Dataset 读成 RGB,再经过验证集 transform 变成固定尺寸张量,送进模型得到 logits,softmax 之后取 top-k。训练时多一步数据增强,验证和推理时不做随机增强。
from torchvision import transforms train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], ), ]) val_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], ), ])RandomResizedCrop(224)把训练图随机裁到 224×224,scale=(0.7, 1.0)控制裁剪面积比例,太小会把主体裁没,太大会削弱增强效果。RandomHorizontalFlip对猫狗、车辆这类左右不敏感的类别通常安全,但如果类别本身有方向性,比如交通标志,要谨慎。Normalize的均值和方差要与预训练权重匹配,训练和推理必须用同一组参数,否则推理会偏。
验证集用Resize(256)再CenterCrop(224),是为了保留主体并稳定评估。推理脚本里不要图省事直接Resize((224, 224)),因为训练验证阶段如果用的是中心裁剪,推理阶段拉伸图片会让输入分布发生变化。这个细节在实验里很常见,单张图测试时看不出来,批量评估时就会掉点。
3. 环境搭建与训练跑通:把实验代码从“能看”变成“能跑”
实验代码跑不起来,多数不是模型写错,而是环境、路径和参数入口没对齐。下面按从零到单张推理的顺序走一遍,命令里的目录名按实际压缩包调整。
3.1 依赖安装与目录初始化
先建独立虚拟环境,避免和本机其他框架版本打架。安装依赖时不要一上来就装一堆用不到的包,先保证训练和推理能跑。
python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate python -m pip install --upgrade pip pip install torch torchvision pillow numpy tqdm # 如果压缩包自带 requirements.txt,再用它补齐 pip install -r requirements.txtpython -m venv .venv创建独立环境,source那行在 Linux/macOS 下激活,Windows 换成注释里的命令。PyTorch 的安装包要按本机是否有 GPU 来选择对应轮子,装完后用下面几行确认环境。
python -c "import torch, torchvision; print(torch.__version__); print(torch.cuda.is_available())"如果输出False,训练脚本里的device会落到 CPU,VGG19 在 CPU 上训练会非常慢。实验阶段可以先用小批量数据确认流程,再切 GPU。torchvision导入失败时,先看是不是装成了同名但不对应的包,不要直接怀疑模型代码。
目录初始化时把data、checkpoints、outputs建好。checkpoints不要放在src里面,训练脚本保存权重时路径会绕。路径里尽量不用中文和空格,尤其是 Windows 下,有些数据加载库对中文路径处理不稳定,这类问题排查起来很费时间。
3.2 数据准备与配置文件修改
数据按类别分文件夹,训练集和验证集的类别文件夹名必须完全一致。常见结构是每个类别一个目录,目录里放图片。类别数不多时,建议先打印一次class_to_idx。
DATA_ROOT = "data" TRAIN_DIR = f"{DATA_ROOT}/train" VAL_DIR = f"{DATA_ROOT}/val" NUM_CLASSES = 2 BATCH_SIZE = 16 EPOCHS = 10 LR = 1e-3 WEIGHT_DECAY = 1e-4 NUM_WORKERS = 4 DEVICE = "cuda" if torch.cuda.is_available() else "cpu"NUM_CLASSES必须和数据目录里的类别数一致,少写会直接维度不匹配,多写不会报错但会多出一个永远学不会的类。BATCH_SIZE在 VGG19 上不要盲目调大,224 输入加未冻结卷积段很吃显存。LR=1e-3适合只训练分类头,如果解冻卷积层,学习率通常要再降。NUM_WORKERS在 Windows 下可以先设 0,确认流程没问题再加。
数据检查这一步别省。训练前先跑一个 batch,看图片张量形状是不是[B, 3, 224, 224],标签范围是不是在[0, NUM_CLASSES-1]。如果形状是[B, 1, 224, 224],说明读进来的是灰度图,需要在 Dataset 里convert("RGB")。如果标签全是 0,检查验证集目录是不是空的,或者类别文件夹名写错。
3.3 训练、验证与断点恢复
训练入口通常用argparse接收参数,下面这条命令是常见形式。
python -m src.train \ --train-dir data/train \ --val-dir data/val \ --num-classes 2 \ --epochs 10 \ --batch-size 16 \ --lr 1e-3 \ --save-dir checkpoints--train-dir和--val-dir指向数据目录,--num-classes决定分类头输出维度,--epochs是训练轮数,--batch-size和--lr按显存与收敛情况调。--save-dir用来保存best.pth和last.pth。如果脚本支持断点恢复,一般会多一个--resume checkpoints/last.pth,恢复时要把优化器状态和 epoch 一起读回来,只读模型权重会导致学习率调度重新开始。
训练日志重点看三个东西:训练损失是否稳定下降,验证准确率是否跟着动,验证损失有没有在训练损失下降时反而上升。第三种情况通常是过拟合或学习率偏大。只训练分类头时,前几个 epoch 指标涨得快是正常的;如果连续几个 epoch 验证准确率不动,先检查标签和预处理,再考虑调学习率。
3.4 推理脚本与单张图片测试
训练完不要只看验证集数字,拿一张自己的图跑一遍推理,能提前暴露预处理和标签映射问题。
python -m src.predict \ --image test.jpg \ --weights checkpoints/best.pth \ --classes class_a class_b--image是待测图片路径,--weights是训练保存的权重,--classes的顺序必须和训练时class_to_idx的顺序一致。推理脚本内部要做三件事:Image.open(...).convert("RGB")、套用验证集 transform、model.eval()后关闭梯度。输出 top-k 时把类别名和概率一起打印,只看最大类容易掩盖置信度异常。
如果推理脚本读的是state_dict,加载时用model.load_state_dict(state);如果保存的是包含model、optimizer、epoch的字典,要先取ckpt["model"]。加载后打印 missing keys 和 unexpected keys,能快速判断分类头有没有对上。
4. 核心代码拆解:VGG19 迁移学习、数据增强与训练循环
这一章把关键代码拆开看。重点不是抄一遍,而是知道每个参数改了之后会影响什么。
4.1 模型构建:冻结卷积层与替换分类头
VGG19 的classifier最后一项是输出 1000 类的全连接层,换数据集时只改这一层。冻结卷积段可以显著减少可训练参数。
import torch.nn as nn from torchvision.models import vgg19, VGG19_Weights def build_model(num_classes: int, freeze_features: bool = True): try: model = vgg19(weights=VGG19_Weights.DEFAULT) except Exception: # 兼容旧版 torchvision 的写法 model = vgg19(pretrained=True) if freeze_features: for p in model.features.parameters(): p.requires_grad = False in_features = model.classifier[6].in_features model.classifier[6] = nn.Linear(in_features, num_classes) return modelweights=VGG19_Weights.DEFAULT表示加载预训练权重,旧版接口是pretrained=True,代码里用try/except兼容两种情况。freeze_features=True会把卷积段参数设为不更新,只训练后面的分类头。in_features从原来的最后一层读出来,通常是 4096,再替换成num_classes。如果数据量和目标域差异都大,可以解冻features的后几层,但学习率要降下来。
优化器只接收需要梯度的参数,避免冻结层被更新:
params = [p for p in model.parameters() if p.requires_grad] optimizer = torch.optim.AdamW(params, lr=1e-3, weight_decay=1e-4)AdamW的weight_decay是解耦权重衰减,实验里比直接加 L2 更稳。lr=1e-3适合只训分类头;如果解冻卷积层,常见做法是给卷积段更小的学习率,分类头保持较大学习率。
4.2 Dataset 与 DataLoader:训练/验证增强差异
数据加载这部分决定训练稳不稳。训练增强可以多一点,验证和推理必须固定。
from torchvision import datasets from torch.utils.data import DataLoader train_ds = datasets.ImageFolder(TRAIN_DIR, transform=train_tf) val_ds = datasets.ImageFolder(VAL_DIR, transform=val_tf) train_loader = DataLoader( train_ds, batch_size=BATCH_SIZE, shuffle=True, num_workers=NUM_WORKERS, pin_memory=(DEVICE == "cuda"), ) val_loader = DataLoader( val_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=NUM_WORKERS, pin_memory=(DEVICE == "cuda"), )ImageFolder会按文件夹名排序生成类别映射,训练集和验证集必须一致。shuffle=True只给训练集,验证集不要打乱,否则日志里的样本顺序会对不上。pin_memory在 GPU 训练时打开,能减少数据拷贝开销。num_workers不是越大越好,Windows 下多进程容易出问题,先设 0 或 2。
训练集和验证集的 transform 要分开写。训练集用随机裁剪、翻转、颜色抖动,验证集用固定尺寸和中心裁剪。验证集如果也加随机增强,指标会抖动,没法判断模型到底有没有变好。
4.3 训练循环:损失、优化器、学习率与指标
训练循环里最容易写错的是设备、梯度清零和指标统计。下面这段是单轮训练的核心。
import torch def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total = 0.0, 0, 0 for images, labels in loader: images = images.to(device, non_blocking=True) labels = labels.to(device, non_blocking=True) optimizer.zero_grad() logits = model(images) loss = criterion(logits, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) pred = logits.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total @torch.no_grad() def validate(model, loader, criterion, device): model.eval() total_loss, correct, total = 0.0, 0, 0 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) total_loss += loss.item() * images.size(0) pred = logits.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) return total_loss / total, correct / totaloptimizer.zero_grad()必须在反向传播前调用,漏掉会累积梯度。loss.item() * images.size(0)是按样本数加权,最后除以总样本数,避免最后一个 batch 不满时指标偏。验证函数用@torch.no_grad()关闭梯度,节省显存。model.train()和model.eval()要切换,VGG19 里虽然有 Dropout,但训练和推理行为不同,别忘了。
损失函数用nn.CrossEntropyLoss(),它内部包含 softmax,模型输出直接是 logits,不要再手动加 softmax。学习率调度可以接余弦退火:
criterion = nn.CrossEntropyLoss() scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS)T_max一般设总 epoch 数,学习率按余弦曲线下降。实验里如果 epoch 很少,调度器作用不明显,可以先不接,等训练稳定后再加。
4.4 推理与模型保存/加载
保存权重时建议同时存model和classes,否则推理时容易把类别顺序搞错。
import torch from PIL import Image def predict(image_path, weights, class_names, device): model = build_model(len(class_names), freeze_features=False) ckpt = torch.load(weights, map_location=device) state = ckpt["model"] if isinstance(ckpt, dict) and "model" in ckpt else ckpt model.load_state_dict(state) model.to(device).eval() img = Image.open(image_path).convert("RGB") x = val_tf(img).unsqueeze(0).to(device) with torch.no_grad(): prob = torch.softmax(model(x), dim=1)[0] k = min(3, len(class_names)) top = torch.topk(prob, k=k) return [(class_names[i], float(prob[i])) for i in top.indices]map_location=device解决训练在 GPU、推理在 CPU 时的加载问题。convert("RGB")防止灰度图或带透明通道的图直接进模型。unsqueeze(0)增加 batch 维度,因为模型期望输入是[B, 3, 224, 224]。torch.softmax(..., dim=1)把 logits 转成概率,topk取前 k 个结果。推理前一定调用eval(),否则 Dropout 和 BatchNorm 的行为会和训练时不一致。
保存时可以把类别映射一起写进去:
torch.save({ "model": model.state_dict(), "classes": train_ds.classes, "class_to_idx": train_ds.class_to_idx, }, "checkpoints/best.pth")这样推理脚本不用手写类别名,直接从权重里读classes,能避免训练和推理类别顺序不一致。
5. 避坑与排查:实验代码跑不通时先看这几处
这一章是血泪经验堆出来的。下面几条按“现象 → 原因 → 解决”写,遇到问题可以逐条对照。
5.1 预训练权重下载失败,训练脚本卡在加载模型
现象:脚本运行到加载 VGG19 时停住,或者过一会儿报网络超时,后面训练仍然开始,但损失下降很慢。原因通常是预训练权重下载失败,代码里又用try/except吞掉了异常,最后模型用了随机初始化。解决:先确认权重是否真的加载成功,可以在build_model后打印model.classifier[6]的参数是否非空;离线环境把权重文件放到框架默认缓存目录,或者手动指定本地权重路径加载。不要看到训练能跑就默认迁移学习生效了,这个坑很隐蔽。
5.2 数据路径不对或标签错位,loss 降不下去
现象:训练准确率很快到很高,验证准确率却像随机猜,或者推理时所有图都输出同一类。原因常见有三种:训练集和验证集类别文件夹名不一致,验证集某个类别目录为空,推理时--classes顺序和训练时class_to_idx不一样。解决:训练前打印train_ds.class_to_idx和val_ds.class_to_idx,确认两个映射完全一致;检查每个类别目录下有没有图片;把类别映射保存进权重文件,推理时直接读,不要手写类别名。标签错位属于那种“代码不报错但结果全错”的问题,越早查越好。
5.3 显存不够,batch size 一调大就崩
现象:训练刚启动就报显存不足,或者跑几个 batch 后崩掉。原因:VGG19 参数量大,224 输入加上未冻结的卷积段,显存占用比想象中高;batch_size调大、num_workers过多、验证集没有关梯度都会加剧。解决:先冻结features,只训练分类头;把batch_size降到 8 或 4;验证函数加@torch.no_grad();num_workers先设 0 或 2;如果还不够,用梯度累积模拟大 batch。不要一上来就换更复杂的模型,先把现有链路跑通。
5.4 设备不一致或半精度踩坑
现象:报Expected all tensors to be on the same device,或者模型在 GPU、数据在 CPU。原因:模型.to(device)了,但某个 batch 的图片或标签忘了.to(device);保存权重时在 GPU,推理时直接torch.load到 CPU 环境。解决:训练循环里统一把images和labels移到device;加载权重用map_location=device;不要在 CPU 上强行用半精度,VGG19 在 CPU 上半精度支持并不稳定。设备问题不复杂,但报错信息有时候指向不明显,先查张量在哪。
5.5 推理结果总是同一类,或置信度异常高
现象:拿几张不同类别的图测试,输出全是同一类,概率还接近 1。原因:推理预处理和验证集不一致,比如训练用Resize(256)+CenterCrop(224),推理直接Resize((224,224));图片读进来是 BGR 或灰度;权重没加载成功;类别顺序错。解决:把验证集 transform 抽成一个函数,训练验证和推理共用;读图统一convert("RGB");加载权重后打印 missing/unexpected keys;从验证集抽一张图回测,确认训练时能分对的图推理也能分对。推理链路最好单独写一个小测试,不要等上线才发现预处理歪了。
6. 二次开发与改造:从实验脚本到可复用图像分类服务
把实验代码改成能复用的小服务,改动不用很大,但边界要清楚。我一般先不改模型结构,先把数据入口、类别映射和推理接口固定下来,再动训练策略。下面这张表是我常见的改造顺序。
| 改造目标 | 改动点 | 验证方式 |
|---|---|---|
| 换自己的数据集 | 改data/train、data/val目录,重设NUM_CLASSES | 打印class_to_idx,跑一个 batch 看形状 |
| 减少过拟合 | 加强训练增强,冻结卷积段,加 weight decay | 对比验证损失和训练损失曲线 |
| 提升推理速度 | 导出 TorchScript 或 ONNX,固定输入尺寸 | 用同一张图对比导出前后输出 |
| 封装服务 | 把predict()包成 HTTP 接口,入参图片,出参 top-k | 用 curl 或脚本批量请求 |
导出模型前先切到推理模式,这一步别省:
model.eval() example = torch.randn(1, 3, 224, 224).to(device) traced = torch.jit.trace(model, example) traced.save("checkpoints/vgg19_traced.pt")torch.jit.trace需要固定输入尺寸,VGG19 这边用 224×224 正好。导出后拿一张验证集图片对比原模型和 traced 模型的输出,概率差在很小范围内才算通过。封装服务时不要把Image.open和模型加载放在每次请求里,模型初始化一次,预处理函数单独抽出来,否则并发一上来就会卡。
改造时最容易忽略的是类别映射。训练时train_ds.classes是按文件夹名排序的,推理服务如果手写类别列表,顺序错了不会报错,只会稳定输出错类。我现在的习惯是保存权重时把classes和class_to_idx一起存,推理入口只认权重里的映射,不认手写参数。换数据集后先跑单张推理,再跑一个小批量验证集,两个结果对得上再继续改模型。从那以后我每次动分类头或换数据目录,都先强制走一遍单张推理回测,确认标签映射没歪。希望帮到你。
本文还有配套的精品资源,点击获取