PyTorch实战:VGG16自定义数据集图像分类从入门到部署 1. 为什么选VGG16做自定义数据集的入门网络很多人第一次接触深度学习图像分类脑子里冒出来的第一个念头就是我要用ResNet或者直接上EfficientNet。我当年也是这个心态觉得网络越新越好越深越强。结果第一次跑自定义数据集的时候用了一个残差网络训练loss震荡得跟心电图一样调了三天没调明白最后换回VGG16两个epoch就看到loss稳稳往下走。这件事给我的教训就是入门阶段网络结构的可解释性比先进性重要得多。VGG16这个网络是2014年提出来的结构极其规整——13个卷积层加3个全连接层全部用3×3的小卷积核堆叠每经过一次池化特征图尺寸减半、通道数翻倍。这种模块化的设计让它在调试时特别友好你能清楚地知道每一个block在干什么出了问题也容易定位是哪个阶段出的岔子。相比之下残差网络里的shortcut连接、瓶颈结构对新手来说就是黑盒套黑盒一旦不收敛你根本不知道从哪下手。再说说自定义数据集这个场景。大部分人手头的数据集规模都不大可能就几千张图片、几个到几十个类别。这种规模下VGG16的参数量约1.38亿虽然偏大但配合迁移学习或者适当的数据增强完全能跑出不错的效果。而且因为它的结构简单你可以在小数据集上快速验证整个训练流程是否跑通——数据加载对不对、标签映射有没有错、学习率设置合不合理——这些流程性的东西跑通了再换更复杂的网络就是水到渠成的事。这篇文章我会从数据准备开始一步步带你走完整个流程自定义Dataset怎么写、数据增强怎么做、VGG16怎么改造适配自己的类别数、训练循环怎么设计、学习率怎么调、模型怎么保存和推理。代码全部基于PyTorch每一行都有注释你直接复制就能跑。中间我会穿插一些自己踩过的坑比如图片尺寸不统一导致collate报错、类别不平衡导致模型只预测多数类、验证集loss突然爆炸这些问题都会给出具体的排查思路和解决方案。提示这篇文章假设你有基本的Python和PyTorch基础知道张量是什么、会写简单的类。如果你连nn.Module都没用过建议先花半天时间补一下PyTorch的基础教程不然直接上手VGG16会有点吃力。2. 自定义数据集的准备工作与Dataset类实现2.1 数据集的目录结构设计在写任何代码之前先把数据整理清楚。我见过太多人把图片全部堆在一个文件夹里然后用一个CSV文件记录标签结果路径拼接出错、标签对不上、训练到一半发现有几张图是坏的。正确的做法是按照类别分文件夹每个类别一个子目录目录名就是类别名。这种结构PyTorch的ImageFolder可以直接读但为了让你理解底层逻辑我们手写Dataset类。假设你要做一个花卉分类任务目录结构应该是这样的dataset/ ├── train/ │ ├── daisy/ │ │ ├── 001.jpg │ │ ├── 002.jpg │ │ └── ... │ ├── rose/ │ │ ├── 001.jpg │ │ └── ... │ ├── tulip/ │ └── sunflower/ ├── val/ │ ├── daisy/ │ ├── rose/ │ ├── tulip/ │ └── sunflower/训练集和验证集分开存放比例大概是8:2或者7:3。如果数据量特别少比如每个类别只有几十张可以考虑用交叉验证但那是另一个话题了。这里我们先用最简单的留出法。注意验证集的目录结构必须和训练集完全一致类别文件夹一个都不能少。我遇到过有人验证集少了一个类别结果训练时模型输出维度对不上报了个莫名其妙的维度错误排查了半天才发现是数据的问题。2.2 手写Dataset类的完整实现PyTorch的Dataset类是一个抽象类你只需要实现两个方法__len__返回数据集大小__getitem__返回一条数据。听起来简单但里面有很多细节需要注意。import os import torch from torch.utils.data import Dataset from PIL import Image from torchvision import transforms class CustomImageDataset(Dataset): 自定义图像分类数据集 目录结构: root/class_name/image.jpg def __init__(self, root_dir, transformNone): Args: root_dir: 数据集根目录下面直接是类别文件夹 transform: 数据增强/预处理管道 self.root_dir root_dir self.transform transform # 获取所有类别名称排序保证每次运行顺序一致 self.classes sorted([ d for d in os.listdir(root_dir) if os.path.isdir(os.path.join(root_dir, d)) ]) # 类别名到索引的映射 self.class_to_idx {cls_name: idx for idx, cls_name in enumerate(self.classes)} # 收集所有图片路径和对应标签 self.samples [] for cls_name in self.classes: cls_dir os.path.join(root_dir, cls_name) for img_name in os.listdir(cls_dir): # 只保留常见的图片格式过滤掉隐藏文件 if img_name.lower().endswith((.jpg, .jpeg, .png, .bmp)): img_path os.path.join(cls_dir, img_name) self.samples.append((img_path, self.class_to_idx[cls_name])) print(f数据集加载完成: {len(self.samples)} 张图片, {len(self.classes)} 个类别) print(f类别列表: {self.classes}) def __len__(self): return len(self.samples) def __getitem__(self, idx): img_path, label self.samples[idx] # 打开图片并转为RGB防止灰度图或RGBA图导致通道数不对 try: image Image.open(img_path).convert(RGB) except Exception as e: print(f图片加载失败: {img_path}, 错误: {e}) # 返回一张黑色图片作为占位避免训练中断 image Image.new(RGB, (224, 224), (0, 0, 0)) if self.transform: image self.transform(image) return image, label这段代码有几个关键点值得展开说。第一sorted()排序类别名很重要。如果你不排序os.listdir返回的顺序在不同操作系统、不同文件系统上可能不一样导致你训练时标签0对应的是daisy推理时标签0变成了rose结果全错。第二convert(RGB)是必须的因为有些图片是灰度图单通道或者带透明通道的PNG四通道直接送进网络会报通道数不匹配的错误。第三异常处理不能省数据集里总会有那么几张损坏的图片如果不处理训练到一半崩了前面的时间全白费。2.3 数据增强管道的设计逻辑数据增强是防止过拟合最有效的手段之一尤其是小数据集。但增强不是越多越好有些增强方式会破坏图像的语义信息。比如对于花卉分类水平翻转是合理的花朝左朝右都是花但垂直翻转就不太合理花不会倒着长。对于数字识别旋转180度可能把6变成9这就是有害增强。# 训练集增强管道 train_transform transforms.Compose([ transforms.Resize((256, 256)), # 先缩放到稍大尺寸 transforms.RandomResizedCrop(224), # 随机裁剪到224模拟不同距离拍摄 transforms.RandomHorizontalFlip(p0.5), # 50%概率水平翻转 transforms.RandomRotation(15), # 小角度旋转±15度 transforms.ColorJitter( # 颜色抖动 brightness0.2, contrast0.2, saturation0.2, hue0.1 ), transforms.ToTensor(), # 转为张量值域[0,1] transforms.Normalize( # 标准化 mean[0.485, 0.456, 0.406], # ImageNet统计均值 std[0.229, 0.224, 0.225] # ImageNet统计标准差 ) ]) # 验证集只做必要的预处理不做随机增强 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)的组合比直接Resize((224,224))效果更好因为随机裁剪能让模型看到同一张图片的不同局部相当于免费扩充了数据。Normalize用的均值和标准差是ImageNet的统计值即使你的数据集不是ImageNet用这组值也没问题因为VGG16的预训练权重就是在这个标准化下训练的保持一致能让预训练特征更好地迁移。提示如果你打算从零训练不用预训练权重Normalize的均值和标准差可以改成你自己数据集的统计值。但如果你用预训练权重做微调就必须用ImageNet的统计值否则预训练学到的特征分布会对不上。3. VGG16网络结构的改造与迁移学习策略3.1 VGG16的原始结构与分类头替换PyTorch的torchvision里自带VGG16的实现我们可以直接加载。但原始的VGG16最后一层全连接层输出是1000维对应ImageNet的1000个类别我们需要把它改成自己数据集的类别数。import torch.nn as nn from torchvision import models def build_vgg16(num_classes, pretrainedTrue, freeze_featuresFalse): 构建适配自定义数据集的VGG16 Args: num_classes: 自己的类别数 pretrained: 是否加载ImageNet预训练权重 freeze_features: 是否冻结卷积层参数 # 加载预训练模型 if pretrained: weights models.VGG16_Weights.IMAGENET1K_V1 model models.vgg16(weightsweights) print(已加载ImageNet预训练权重) else: model models.vgg16(weightsNone) print(从零开始训练) # 冻结卷积层可选 if freeze_features: for param in model.features.parameters(): param.requires_grad False print(卷积层已冻结) # 替换分类头 # 原始: Linear(25088, 4096) - ReLU - Dropout - Linear(4096, 4096) - ReLU - Dropout - Linear(4096, 1000) # 我们保留前两层全连接只替换最后一层 in_features model.classifier[6].in_features # 4096 model.classifier[6] nn.Linear(in_features, num_classes) # 如果冻结了卷积层分类头的前两层也可以考虑冻结 # 但通常我们只冻结卷积层全连接层还是要训练的 if freeze_features: for i in range(6): # classifier的前6层索引0-5 for param in model.classifier[i].parameters(): param.requires_grad False return model这里有个细节很多人会忽略VGG16的classifier是一个Sequential索引6才是最后一层全连接。如果你直接写model.classifier[-1]也能拿到但用索引6更明确。另外替换分类头之后新加的Linear层默认requires_gradTrue这是对的因为我们要训练它。3.2 冻结卷积层的判断依据要不要冻结卷积层取决于你的数据集大小和与ImageNet的相似度。我总结了一个简单的判断表数据集规模与ImageNet相似度推荐策略理由1000张高如自然图像冻结卷积层只训练分类头数据太少微调卷积层容易过拟合1000张低如医学图像冻结前几个block微调后几个block浅层特征通用深层特征需要适配1000-10000张高微调所有层但用较小学习率数据量够可以端到端微调1000-10000张低微调所有层卷积层学习率设为分类头的1/10需要较大调整但防止破坏预训练特征10000张任意可以从零训练或用预训练权重微调数据量足够支撑从零训练我自己的经验是对于大多数个人项目几千张图片微调所有层但给卷积层设置较小的学习率是最稳妥的方案。具体来说卷积层的学习率设为1e-4分类头设为1e-3这样既能利用预训练特征又能让网络适应新数据。3.3 参数分组与差异化学习率设置PyTorch的优化器支持参数分组我们可以给不同层设置不同的学习率def get_optimizer(model, lr_head1e-3, lr_features1e-4, weight_decay1e-4): 参数分组优化器卷积层和分类头使用不同学习率 # 分组卷积层参数 feature_params list(model.features.parameters()) # 分组分类头参数排除最后一层最后一层用更大的学习率 classifier_params list(model.classifier[:-1].parameters()) # 分组最后一层全连接 fc_params list(model.classifier[-1].parameters()) optimizer torch.optim.AdamW([ {params: feature_params, lr: lr_features}, {params: classifier_params, lr: lr_head}, {params: fc_params, lr: lr_head * 2} # 新加的层学习率再大一点 ], weight_decayweight_decay) return optimizer用AdamW而不是Adam是因为AdamW把权重衰减和梯度更新解耦了泛化性能通常更好。weight_decay1e-4是一个比较安全的默认值如果你发现模型过拟合严重可以调到1e-3。注意如果你冻结了卷积层就不要把冻结的参数传给优化器否则虽然它们不更新但会占用优化器的状态内存。用filter(lambda p: p.requires_grad, model.parameters())过滤一下。4. 训练循环的设计与关键参数调优4.1 完整的训练循环代码训练循环是整篇文章的核心我会把每个部分拆开讲。先看完整代码import time import copy import torch from torch.utils.data import DataLoader def train_model(model, train_dataset, val_dataset, num_epochs25, batch_size32, devicecuda, save_pathbest_model.pth): 完整的训练循环包含验证、学习率调度、模型保存 # 创建DataLoader train_loader DataLoader( train_dataset, batch_sizebatch_size, shuffleTrue, num_workers4, pin_memoryTrue ) val_loader DataLoader( val_dataset, batch_sizebatch_size, shuffleFalse, num_workers4, pin_memoryTrue ) # 优化器和损失函数 optimizer get_optimizer(model) criterion nn.CrossEntropyLoss() # 学习率调度器余弦退火 scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_maxnum_epochs, eta_min1e-6 ) model model.to(device) # 记录最佳模型和最佳准确率 best_model_wts copy.deepcopy(model.state_dict()) best_acc 0.0 # 训练历史记录 history {train_loss: [], train_acc: [], val_loss: [], val_acc: [], lr: []} for epoch in range(num_epochs): print(f\nEpoch {epoch1}/{num_epochs}) print(- * 50) # 每个epoch分训练和验证两个阶段 for phase in [train, val]: if phase train: model.train() # 训练模式启用Dropout和BatchNorm更新 dataloader train_loader else: model.eval() # 验证模式关闭Dropout固定BatchNorm dataloader val_loader running_loss 0.0 running_corrects 0 total_samples 0 # 遍历一个epoch的所有batch for inputs, labels in dataloader: inputs inputs.to(device) labels labels.to(device) # 梯度清零 optimizer.zero_grad() # 前向传播 with torch.set_grad_enabled(phase train): outputs model(inputs) _, preds torch.max(outputs, 1) loss criterion(outputs, labels) # 只在训练阶段反向传播和更新参数 if phase train: loss.backward() # 梯度裁剪防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() # 统计 running_loss loss.item() * inputs.size(0) running_corrects torch.sum(preds labels.data) total_samples inputs.size(0) # 计算epoch级别的指标 epoch_loss running_loss / total_samples epoch_acc running_corrects.double() / total_samples history[f{phase}_loss].append(epoch_loss) history[f{phase}_acc].append(epoch_acc.item()) print(f{phase} Loss: {epoch_loss:.4f} Acc: {epoch_acc:.4f}) # 验证阶段保存最佳模型 if phase val and epoch_acc best_acc: best_acc epoch_acc best_model_wts copy.deepcopy(model.state_dict()) torch.save(model.state_dict(), save_path) print(f最佳模型已保存准确率: {best_acc:.4f}) # 更新学习率 scheduler.step() current_lr optimizer.param_groups[0][lr] history[lr].append(current_lr) print(f当前学习率: {current_lr:.6f}) print(f\n训练完成最佳验证准确率: {best_acc:.4f}) # 加载最佳模型权重 model.load_state_dict(best_model_wts) return model, history4.2 训练循环中几个容易出错的细节第一个坑忘记切换train/eval模式。这个错误太常见了后果也很严重。如果你在验证时忘了model.eval()Dropout层会继续随机丢弃神经元BatchNorm会继续用当前batch的统计量更新running_mean和running_var导致验证结果波动很大而且会污染训练时学到的统计量。我见过有人验证准确率比训练准确率还高就是因为验证时Dropout还在起作用相当于做了集成。第二个坑梯度累积和梯度裁剪的顺序。梯度裁剪必须在loss.backward()之后、optimizer.step()之前。如果你在zero_grad()之前裁剪裁的是上一个batch的梯度完全没意义。max_norm1.0是一个比较保守的值如果你发现训练loss震荡厉害可以降到0.5如果训练很稳定可以放宽到5.0。第三个坑DataLoader的num_workers设置。在Windows上num_workers0可能会报错需要把训练代码放在if __name__ __main__:里面。在Linux上num_workers4通常没问题但如果你的CPU核心少设成2也行。pin_memoryTrue在GPU训练时能加速数据传输但如果你的内存紧张可以关掉。第四个坑学习率调度器的step时机。CosineAnnealingLR是按epoch更新的所以scheduler.step()应该放在epoch循环的末尾而不是batch循环里面。如果你放错了位置学习率会下降得飞快模型还没学到东西就进入极低学习率了。4.3 学习率与batch size的配合关系学习率和batch size是联动的。经验法则是batch size翻倍学习率也翻倍。这是因为更大的batch size意味着更准确的梯度估计可以用更大的步长。但这不是绝对的还要看你的优化器和具体任务。我常用的配置是Batch Size初始学习率分类头初始学习率卷积层优化器165e-45e-5AdamW321e-31e-4AdamW642e-32e-4AdamW1284e-34e-4AdamW如果你用SGD学习率通常要比AdamW大10倍左右比如batch size32时用0.01。但SGD对学习率更敏感需要配合warmup和更精细的调度。提示如果你不确定学习率设多少可以用学习率扫描LR Finder先跑几百个batch看看loss下降最快的区间在哪里。PyTorch没有内置的LR Finder但可以用torch-lr-finder这个库或者自己写一个简单的版本。5. 训练过程中的问题排查与调优经验5.1 Loss不下降的排查链路Loss不下降是最常见的问题原因可能有很多。我一般按照以下顺序排查第一步检查数据标签是否正确。这是最容易被忽略的。你可以随机取几个batch把图片和标签打印出来人眼看一下标签对不对。我遇到过有人把类别文件夹的名字写错了导致所有图片的标签都是0模型学了半天只能预测一个类别。第二步检查学习率是否过大或过小。学习率过大的典型表现是loss震荡甚至变成NaN学习率过小的表现是loss下降极其缓慢像蜗牛爬。你可以先设一个很小的学习率比如1e-5跑几个epoch如果loss能稳定下降说明模型结构没问题然后逐步增大学习率找到合适的值。第三步检查模型是否真的在更新。打印一下第一层卷积的权重看看训练前后有没有变化。如果没有变化可能是优化器没有正确接收参数或者梯度被意外清零了。第四步检查输入数据的归一化。如果你忘了Normalize或者Normalize的均值和标准差不对模型很难收敛。尤其是用了预训练权重的情况下输入分布必须和预训练时一致。第五步检查损失函数和输出维度。CrossEntropyLoss期望的输入是未经过Softmax的logits输出维度是[batch_size, num_classes]标签是[batch_size]的整数。如果你在模型最后加了Softmax再传给CrossEntropyLoss就会导致loss计算错误。5.2 过拟合与欠拟合的判断和处理过拟合的表现是训练准确率很高但验证准确率很低两者差距大。欠拟合的表现是训练和验证准确率都低。处理过拟合的手段按优先级排序增加数据增强强度这是最根本的解决办法。可以加RandAugment、MixUp、CutMix等更强的增强策略。增加DropoutVGG16的classifier里本来就有Dropout你可以把dropout率从0.5提高到0.6或0.7。增加权重衰减把weight_decay从1e-4提高到1e-3。早停验证loss连续几个epoch不下降就停止训练。减小模型容量冻结部分卷积层或者减少全连接层的维度。处理欠拟合的手段提高学习率可能当前学习率太小模型没学到东西。减少正则化降低Dropout率、减小weight_decay。增加训练轮数可能只是训练不够。解冻更多层如果冻结了卷积层尝试解冻后几个block。5.3 类别不平衡的处理方案自定义数据集经常遇到类别不平衡的问题比如某个类别有1000张图另一个类别只有50张。这会导致模型偏向多数类少数类的召回率极低。最简单的处理方法是加权损失函数# 计算类别权重反比于类别频率 class_counts [len(os.listdir(os.path.join(train_dir, cls))) for cls in classes] total sum(class_counts) class_weights [total / (len(classes) * count) for count in class_counts] class_weights torch.FloatTensor(class_weights).to(device) criterion nn.CrossEntropyLoss(weightclass_weights)另一个方法是重采样用WeightedRandomSampler让少数类被采样的概率更高。from torch.utils.data import WeightedRandomSampler # 每个样本的采样权重 sample_weights [class_weights[label] for _, label in train_dataset.samples] sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue) train_loader DataLoader(train_dataset, batch_size32, samplersampler)我个人的经验是加权损失比重采样更稳定因为重采样会改变数据的分布可能导致模型对少数类过拟合。如果类别极度不平衡比如1:100可以两种方法结合使用。6. 模型推理与部署的实操细节6.1 单张图片推理的完整流程训练完模型下一步就是用它做推理。推理流程和验证阶段类似但有几个额外的注意点def predict_single_image(model, image_path, class_names, devicecuda): 对单张图片进行预测 model.eval() model model.to(device) # 预处理必须和验证集完全一致 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] ) ]) # 加载图片 image Image.open(image_path).convert(RGB) input_tensor transform(image).unsqueeze(0).to(device) # 增加batch维度 # 推理 with torch.no_grad(): outputs model(input_tensor) # 计算Softmax概率 probabilities torch.nn.functional.softmax(outputs, dim1) # 获取最高概率的类别 confidence, predicted torch.max(probabilities, 1) predicted_class class_names[predicted.item()] confidence_score confidence.item() # 打印所有类别的概率 print(f预测结果: {predicted_class}) print(f置信度: {confidence_score:.4f}) print(\n各类别概率:) for cls_name, prob in zip(class_names, probabilities[0].cpu().numpy()): print(f {cls_name}: {prob:.4f}) return predicted_class, confidence_score这里的关键是预处理必须和验证集完全一致。如果你验证时用了CenterCrop(224)推理时用了Resize((224,224))结果会有偏差。因为CenterCrop只取图片中心部分而Resize会压缩整张图片两者的视野不同。6.2 批量推理与结果导出实际项目中你通常需要对整个测试集做批量推理然后把结果导出成CSV或者JSON。这里给一个批量推理的模板import pandas as pd from tqdm import tqdm def batch_predict(model, test_dir, class_names, output_csvpredictions.csv, devicecuda): 对测试集所有图片进行批量推理结果保存为CSV model.eval() model model.to(device) 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] ) ]) results [] image_files [f for f in os.listdir(test_dir) if f.lower().endswith((.jpg, .jpeg, .png, .bmp))] with torch.no_grad(): for img_name in tqdm(image_files, desc推理中): img_path os.path.join(test_dir, img_name) try: image Image.open(img_path).convert(RGB) input_tensor transform(image).unsqueeze(0).to(device) outputs model(input_tensor) probabilities torch.nn.functional.softmax(outputs, dim1) confidence, predicted torch.max(probabilities, 1) results.append({ filename: img_name, predicted_class: class_names[predicted.item()], confidence: round(confidence.item(), 4) }) except Exception as e: print(f处理 {img_name} 时出错: {e}) results.append({ filename: img_name, predicted_class: ERROR, confidence: 0.0 }) df pd.DataFrame(results) df.to_csv(output_csv, indexFalse, encodingutf-8-sig) print(f结果已保存到 {output_csv}) return dfencodingutf-8-sig是为了让Excel能正确打开中文CSV文件不加这个参数的话Excel打开会乱码。tqdm进度条在批量推理时很有用能让你知道大概还要多久。6.3 模型保存与加载的注意事项保存模型有两种方式保存整个模型torch.save(model, path)和只保存状态字典torch.save(model.state_dict(), path)。推荐只保存状态字典因为保存整个模型会把模型类的定义也序列化进去如果以后你改了模型类的代码加载旧模型就会报错。加载模型时需要先实例化一个相同结构的模型然后加载状态字典# 保存 torch.save(model.state_dict(), vgg16_custom.pth) # 加载 model build_vgg16(num_classes5, pretrainedFalse) # 不需要预训练权重 model.load_state_dict(torch.load(vgg16_custom.pth, map_locationcpu)) model.eval()map_locationcpu很重要如果你在GPU上训练保存的模型在CPU机器上加载会报错。加上这个参数后模型会先加载到CPU然后你可以根据需要再移到GPU。提示如果你修改了模型结构比如改了分类头的维度加载旧的状态字典会报维度不匹配的错误。这时候可以用strictFalse参数忽略不匹配的层但只建议在明确知道自己在做什么的情况下使用。7. 从VGG16出发的进阶方向VGG16跑通之后你可能会想尝试更复杂的网络。我的建议是不要急着换模型先把VGG16的性能压榨到极限。具体来说可以尝试以下几个方向第一个方向更精细的学习率调度。除了余弦退火还可以试试OneCycleLR、带warmup的余弦退火、ReduceLROnPlateau等。不同的调度策略对最终精度的影响可能有1-2个百分点。第二个方向更强的数据增强。试试RandAugment、TrivialAugmentWide、MixUp、CutMix这些策略。在PyTorch里可以用torchvision.transforms.v2或者timm库来实现。第三个方向模型集成。训练多个VGG16模型不同的随机种子、不同的数据增强推理时取平均。这是提升精度最稳定但最费算力的方法。第四个方向知识蒸馏。用一个更大的模型比如VGG19或者ResNet152作为教师模型指导VGG16训练。这样可以在不增加推理成本的情况下提升精度。等你把VGG16的潜力挖掘得差不多了再换ResNet、EfficientNet或者Vision Transformer你会发现之前积累的训练技巧、调参经验都是通用的迁移过去非常快。反过来如果VGG16都没跑明白就急着上复杂模型遇到问题你根本不知道是模型的问题还是训练流程的问题。我个人在实际操作中的体会是入门阶段最大的敌人不是模型不够先进而是流程不够规范。数据整理、增强管道、训练循环、验证策略、模型保存这些环节每一个都有坑把这些坑都踩一遍、填一遍你对深度学习的理解会比单纯跑通一个SOTA模型深刻得多。VGG16虽然老但它是理解卷积神经网络最好的教材没有之一。