
不少同学看完VGGNet论文跑通了官方demo等到想拿它训练自己拍的照片时就卡住了。卡住的地方往往不是模型代码而是那句“准备你自己的数据集”——图片往哪儿放、文件名怎么起、图片损坏了怎么办、加载出来怎么是反的、训练完准确率怎么这么低……这篇就是把上一篇跳过的细节全部摊开从自己图像数据集准备开始一步步走到VGGNet在PyTorch里跑完训练和评估。适合有一定PyTorch基础、但没独立做过完整图像分类项目的读者照着复现。1. 自己攒数据集目录、数量、图片质量的三道坎1.1 目录结构先定好train/val/test与类别子文件夹很多教程默认你会用现成的MNIST或者CIFAR下载解压就完事。但换成自己的数据集第一步不是写代码而是把文件夹结构搭对。PyTorch里的torchvision.datasets.ImageFolder约定了一个非常简单的目录规则主目录下每个类别一个子文件夹子文件夹里直接放图片。建议的结构是这样dataset/ ├── train/ │ ├── cat/ │ │ ├── cat_001.jpg │ │ ├── cat_002.jpg │ │ └── ... │ ├── dog/ │ │ ├── dog_001.jpg │ │ └── ... │ └── ... ├── val/ │ ├── cat/ │ └── dog/ └── test/ ├── cat/ └── dog/注意三个细节。第一val和test不能省。val用来在训练过程中实时看模型有没有过拟合决定什么时候保存模型test只在全部训练结束后跑一次模拟真实场景。有些同学图省事只分train和test结果test被反复用到调参里测试指标就失去了参考意义。第二类别文件夹名字用英文小写加下划线比如industrial_part_01不要用中文也不要用带空格的目录名。虽然没有空格也能跑但后面三 trouble shooting 时控制台里看着舒服很多。第三图片文件名建议按“类别_序号”方式命名重复文件名在不同目录下没问题但如果要把图片挪来挪去重名很容易覆盖。1.2 每类多少张才算够数量下限与平衡性这是被问得最多的一个问题我每类只有30张图能训吗我的经验是如果你打算从零训练VGGNet每类至少要有100张以上否则很容易过拟合。VGG16的全连接层有上亿参数几十张图根本压不住。但如果你用迁移学习——加载ImageNet预训练权重只微调后面的分类头那么每类30到50张也勉强能做前提是类别特征和ImageNet里的它认识的东西比较接近。数据平衡性是第二道坎。假设你做了四类工业零件其中一类拍了500张另一类只有40张训练出来的模型会对样本多的类别特别偏袒小类别的召回率通常惨不忍睹。这时候要么去补拍要么做数据增强时对小类别多增强几倍要么在损失函数里给类别加权重。我在实际项目中一般这样做每类样本量处理策略500张以上正常训练随机划分即可100~500张建议用预训练权重微调训练时增强强一点30~100张必须迁移学习慎用复杂模型必要时候用类别加权损失30张以下先考虑收集更多数据或者换轻量网络搭配更强的增强策略1.3 图片质量筛选与损坏文件排查图片质量是整个流程里最容易被低估的环节。手机拍的照片、爬下来的图、相机拍出来的原始图混在一起什么情况都有。我最常遇到三类问题图片本身损坏打不开图片后缀是.jpg其实是PNG或其他格式照片模糊、过曝、拍歪了导致标注失真。如果图片损坏训练到一半会报PIL.UnidentifiedImageError或者DataLoader直接崩掉。建议在建数据集时就做一次全量体检脚本可以这样写import os from PIL import Image def check_images(root_dir): bad_imgs [] for cls_name in os.listdir(root_dir): cls_path os.path.join(root_dir, cls_name) if not os.path.isdir(cls_path): continue for fname in os.listdir(cls_path): fpath os.path.join(cls_path, fname) try: with Image.open(fpath) as img: img.verify() # 只验证头部速度快 except Exception: bad_imgs.append(fpath) return bad_imgs bad check_images(dataset/train) print(f损坏图片数量: {len(bad)}) for p in bad: print(p)这个脚本跑一遍把所有坏图片找出来删掉或者重新导出。另一个经验是如果是从视频里抽帧做数据集抽出来的帧会有大量高度相似的连续画面这类样本会让验证集虚高。一定要先做去重或者抽帧后按帧号隔几秒抽一张。2. 数据加载链路文件夹怎么变成Tensor张量2.1 ImageFolder的映射逻辑与中文目录名陷阱ImageFolder会自动扫描目录层级把每个子文件夹名称映射成一个整数标签并且把类别名和标签的对应关系保存在dataset.classes和dataset.class_to_idx里。也就是说你不需要手工做任何label的转换目录名本身就是标签。这里有个非常容易忽略的坑打印dataset.class_to_idx出来看顺序会发现它是按字典序排的。比如你有cat和dog两类cat会是0dog是1但如果你类别加了chicken顺序就变了。所以训练结束后保存模型时建议顺手把classes列表也存下来后面部署推理时要靠它把预测的index翻译回类别名。如果目录名是中文推荐先把整个数据集目录复制一份把中文类别改名成拼音或英文。原因是很多图像增强库和调试工具对中文路径在不同操作系统下有兼容问题排查起来非常浪费时间问题跟模型本身一点关系都没有。2.2 预处理策略Resize、增强、Normalize分别解决什么问题VGGNet要求输入是224x224的RGB图像所以所有图片在进网络之前都要统一尺寸。预处理三件套通常是先Resize再做数据增强最后ToTensor加Normalize。训练集和验证/测试集的变换是有讲究的不能一样。训练集为了增加多样性要做随机裁剪、随机翻转、颜色抖动验证集和测试集为了稳定评估只用统一的缩放和中心裁剪。我常用的方案是from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.08, 1.0), ratio(3/4, 4/3)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])解释一下每个环节在干什么。Resize((256, 256))先把图缩放到略大一点的尺寸然后RandomResizedCrop(224)在训练时随机裁剪出一块224x224的区域模拟物体在画面中出现位置、大小不同的情况。RandomHorizontalFlip对左右对称的类别比如猫狗非常有效但如果你做的是手写数字识别翻转会把“6”变“9”这种类别的数据集就绝对不能用这个增强。Normalize里的mean和std用的是ImageNet的统计值谁来都推荐用这套因为它已经经过大规模验证。好处是梯度更稳定、收敛更快。需要注意的是它会把像素值从0~1的区间变换到约-2~2后面的训练代码里不用做任何处理模型自己会适应。2.3 DataLoader参数从batch_size到num_workers数据集类准备好以后交给DataLoader批量取数。一般情况下我会做二次划分把train目录再分成训练集和验证集而不是手动复制两份数据。用torch.utils.data.random_split就行from torch.utils.data import DataLoader, random_split from torchvision.datasets import ImageFolder full_train ImageFolder(dataset/train, transformtrain_transform) train_size int(0.85 * len(full_train)) val_size len(full_train) - train_size train_ds, val_ds random_split(full_train, [train_size, val_size])这里有个问题要注意random_split默认不保证验证集的类别分布和训练集一致。当你的数据类别不均衡时验证集里某类可能一个样本都没有。我的做法是先读一遍所有样本的标签然后按类别做分层划分或者直接把数据按类别的文件分组划分到不同目录再分别用ImageFolder加载。DataLoader里的几个参数直接影响训练体验batch_size根据显存定224x224输入下VGG16通常用16或32shuffleTrue只在训练集用验证集和测试集保持False否则每次验证的批次顺序都变结果不直观num_workers在Linux上可以设4到8在Windows上如果设了大于0的worker又没放在if __name__ __main__:里会碰到常见启动报错新手建议Windows下直接设num_workers0先跑通再说。3. VGGNet手写实现结构逻辑与PyTorch代码3.1 VGG为什么用小卷积核堆叠VGGNet的设计逻辑值得先说清楚因为它决定了后面代码怎么写。这篇网络的核心观点是用三个3x3卷积堆叠等价于一个7x7卷积的感受野用两个3x3卷积堆叠等价于一个5x5卷积的感受野但参数量少得多而且中间多了激活函数的非线性变换表达能力更强。3x3卷积是目前性价比最高的卷积核它是最小的能捕捉左右上下信息的尺寸两个3x3叠加的参数量是18倍通道数而一个5x5卷积是25倍通道数明显更省。所以VGG把所有大卷积核全部换成了3x3一层层堆起来。模型深度增加后需要用最大池化逐步降低分辨率所以特征图的宽高从224降到112、56、28、14、7五档每一档卷积层数不同这就形成了VGG11到VGG19的区别。3.2 从cfg配置到完整VGG16实现与其贴一个写死的VGG16类不如从配置文件出发。VGG原版代码就是用列表配置网络层这样想改成VGG11或者VGG19只换配置就行。下面是可复现的VGG16import torch import torch.nn as nn def make_layers(cfg, in_channels3, batch_normTrue): layers [] for v in cfg: if v M: layers.append(nn.MaxPool2d(kernel_size2, stride2)) else: conv2d nn.Conv2d(in_channels, v, kernel_size3, padding1) if batch_norm: layers [conv2d, nn.BatchNorm2d(v), nn.ReLU(inplaceTrue)] else: layers [conv2d, nn.ReLU(inplaceTrue)] in_channels v return nn.Sequential(*layers) vgg16_cfg [64, 64, M, 128, 128, M, 256, 256, 256, M, 512, 512, 512, M, 512, 512, 512, M] class VGG16(nn.Module): def __init__(self, num_classes4, batch_normTrue): super(VGG16, self).__init__() self.features make_layers(vgg16_cfg, in_channels3, batch_normbatch_norm) self.avgpool nn.AdaptiveAvgPool2d((7, 7)) self.classifier nn.Sequential( nn.Linear(512 * 7 * 7, 4096), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(4096, 4096), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(4096, num_classes), ) self._init_weights() def _init_weights(self): for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) if m.bias is not None: nn.init.constant_(m.bias, 0) elif isinstance(m, nn.BatchNorm2d): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) elif isinstance(m, nn.Linear): nn.init.normal_(m.weight, 0, 0.01) nn.init.constant_(m.bias, 0) def forward(self, x): x self.features(x) x self.avgpool(x) x torch.flatten(x, 1) x self.classifier(x) return x代码里两个容易解释不清的地方补充一下。AdaptiveAvgPool2d((7, 7))的作用是让特征图无论进来时多大都能池化成7x7这样全连接层的输入维度就固定了。classifier里两次Dropout(0.5)是很关键的防过拟合手段VGG原论文靠这个在全连接层中间随机丢弃一半神经元实际使用下来没有它训练集准确率会快速逼近100%验证集却上不去。3.3 迁移学习版本什么时候直接用预训练权重从零训练VGG16在小数据集上很容易过拟合所以另一个主流做法是直接用torchvision.models里预训练好的VGG16把最后全连接层换成自己类别数。import torchvision.models as models model models.vgg16(weightsmodels.VGG16_Weights.IMAGENET1K_V1) num_ftrs model.classifier[6].in_features model.classifier[6] nn.Linear(num_ftrs, num_classes)什么时候用它当你的数据集中图片和ImageNet里的自然图像比较接近比如猫狗、花鸟、车辆、日常物品迁移学习的效果会非常直观。但如果你的数据是完全不一样的领域比如医学影像、卫星图、工程图纸预训练特征用处会降低通常只把前面几层当作通用的边缘纹理提取器用。这里有个常见的误区很多人加载预训练权重之后全部参数都去训练这种做法在小数据集上反而容易过拟合。更稳妥的方案是把features部分整体冻结只训练新换的classifier层等过几个epoch之后再解冻最后几个卷积块做微调。这一步能省下大量显存和训练时间。4. 训练配置与迭代优化器、学习率、收敛判断4.1 损失函数与优化器选型分类任务损失函数没有悬念用nn.CrossEntropyLoss()。它内部已经把softmax和交叉熵合并了所以网络输出层不需要手动加softmax直接输出原始logits就可以。优化器选型值得多说几句。现在很多新手默认Adam但VGG这种网络我倾向于SGD加momentum和weight_decay。SGD配momentum的好处是收敛曲线更稳配合weight_decay做L2正则能明显压制过拟合Adam收敛虽快但在某些数据集上验证集准确率波动会比较大。下面是常用的配置import torch.optim as optim criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay5e-4)learning rate初始值推荐0.01batch size如果是16或32的话这个初始学习率是安全的。batch size翻倍、学习率也可以适当放大不过新手没必要做那么细。权重衰减weight_decay5e-4是原论文使用的值数据量特别小时可以改成1e-4。4.2 完整的train/val循环与模型保存训练循环本身不难但每一步的目的要说清楚import torch device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) best_acc 0.0 num_epochs 50 for epoch in range(num_epochs): model.train() running_loss 0.0 correct 0 total 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) _, preds torch.max(outputs, 1) total labels.size(0) correct (preds labels).sum().item() train_loss running_loss / total train_acc correct / total # 验证阶段 model.eval() val_correct 0 val_total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, preds torch.max(outputs, 1) val_total labels.size(0) val_correct (preds labels).sum().item() val_acc val_correct / val_total print(fEpoch {epoch1}/{num_epochs} | train_loss: {train_loss:.4f} | train_acc: {train_acc:.4f} | val_acc: {val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), vgg16_best.pth)这段代码里最重要的两行是model.train()和model.eval()。train()开启Dropout和BatchNorm的训练行为eval()关闭Dropout并切换到BatchNorm的滚动统计值。很多同学忘记切换导致验证集指标时高时低特别是用BatchNorm的模型这个坑极其常见。保存模型我强烈建议保存state_dict()而不是整个模型。state_dict()是纯参数体积小、兼容性好加载时只需要先实例化一个结构完全一样的模型再load_state_dict进去。4.3 学习率调整与loss曲线读法训练过程中我最常用的是ReduceLROnPlateau它会在验证集loss不再下降时自动把学习率乘以0.1scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.1, patience3, verboseTrue )注意这里的modemin表示监控指标越小越好所以传入的应该是验证集loss而不是accuracy。每个epoch验证结束后调用scheduler.step(val_loss)。如果你看到训练集loss一直降、验证集准确率迟迟不动通常在降学习率之后会有一次明显的跳升。读曲线是排查问题的核心技能。我总结过三种情况训练loss和验证loss都高说明模型还没收敛加epoch或者调大学习率训练loss降下来了验证loss反而升高这是过拟合加dropout、加数据增强、减模型复杂度训练loss一直震荡不降先看是不是学习率太大再看数据有没有问题。最后那种情况我遇到过最多次最后查出来是有几张图标注反了。5. 评估、误判分析与现场常见坑5.1 测试集指标混淆矩阵与单类精度训练结束不代表完事测试集上的真实成绩才是项目能不能交差的依据。测试集评估和验证集基本一样加载test数据集跑一遍前向但不要只看总准确率。我每次都会生成混淆矩阵和分类报告from sklearn.metrics import confusion_matrix, classification_report import numpy as np all_preds [] all_labels [] model.eval() with torch.no_grad(): for images, labels in test_loader: images images.to(device) outputs model(images) _, preds torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) print(confusion_matrix(all_labels, all_preds)) print(classification_report(all_labels, all_preds, target_namestest_ds.classes))混淆矩阵能告诉你哪些类别之间经常被混淆。比如做两类工业螺丝和垫圈如果垫圈经常被当成螺丝去看矩阵里对应的行和列就能定位到是哪一类拖了后腿。分类报告里每个类别的precision、recall、f1-score比总准确率更有指导意义尤其数据不平衡时准确率会掩盖小类别的惨状。5.2 误判样例排查标注问题还是模型问题光看数字还不够我会把预测错误的图片全部挑出来保存人眼扫一遍。这一步能发现许多指标看不出的问题import os from PIL import Image os.makedirs(wrong_cases, exist_okTrue) wrong_count 0 for images, labels, paths in wrong_case_list: img Image.open(paths) img.save(fwrong_cases/wrong_{wrong_count}_true{labels.item()}_pred{preds.item()}.jpg) wrong_count 1扫完这些图后误判通常分两种。第一种是图片本身拍得不好或者类别特征不典型这是数据问题解决办法是补样本第二种是标注本来就有错比如某张螺丝的图像被标成了垫圈这类脏数据一定要修掉否则模型会学到错误边界。用上了混淆矩阵和误判图以后我几乎每次都能发现几张三成以上概率是标错的图。5.3 显存不足与数据Bias的几条应对最后把训练时最常遇到的现场问题列一遍。显存不足是VGG的老大难。VGG16在224x224输入下显存占用不小遇到CUDA out of memory先别急着换显卡按优先级试试缩小batch size到8甚至4全连接层的4096改小成2048训练时关闭部分不需要的中间变量保存如果用了迁移学习先冻结features只训练classifier。还有一种做法是梯度累积用多个小batch累积梯度再更新一次参数相当于等效放大batch sizeaccumulation_steps 4 optimizer.zero_grad() for i, (images, labels) in enumerate(train_loader): outputs model(images) loss criterion(outputs, labels) / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()数据Bias是另一个容易被忽略的问题。假设你的图片全从实验室固定角度拍摄模型可能记住了背景光源而不是物体本身。我的经验是一定要留出独立的test目录用和训练完全不同的时段、角度、光线条件下采集的图片来评估。没有这一条训练集上90%以上的准确率也可能是幻觉。我自己做项目最后打回重做的情况十次里有六次都是栽在这里。整个流程走下来我最大的体会是模型代码反而是整个项目里最简单的一环真正花时间的是数据集准备和评估。第一次跑完整流程时别急着上全部数据先抽出每个类别十几张图把数据加载、训练、评估、保存、加载这一整条链路跑通再全量训练。这样排查问题时你会知道是哪一环出错而不是对着几千张图片和一个没完没了的训练循环发呆。最后说一个配合迁移学习的小技巧加载预训练权重后先以小学习率训练几个epoch确认分类头部分收敛再解冻前面卷积层做全局微调这条路径对中小型数据集几乎是最稳的。