☰
基于VGG16迁移学习的服装分类实战:从数据增强到两阶段微调
2026/10/27 12:31:44 网站建设 项目流程

服装分类这个题目,我最早接触是为了给一个电商后台做自动打标——每天上万张商品图堆在那里,人工标"是衬衫还是连衣裙"这种活儿,既枯燥又容易出错,还经常出现同一件衣服被标成两个品类的情况。后来团队决定用视觉模型来做初筛,人工只复核低置信度的部分,整个链路的效率提升非常明显。这件事的核心难点其实不在"分类"两个字,而在于服装这类目标的特殊性:类内差异极大(一件白衬衫能有一百种版型),类间差异又极小(短袖T恤和长袖T恤、卫衣和薄外套),加上拍摄背景、模特姿态、光照条件千差万别,直接从零训练一个卷积网络,几百张图根本喂不饱。基于 VGG16 的迁移学习服装分类,就是在这种现实约束下比较务实的一条路径:拿一个在通用图像数据上已经学会了"边缘、纹理、局部形状"的骨干网络,把最后那层分类头换成我们自己的服装品类,用小样本继续训练。这篇内容我会把整个流程拆开讲透,从为什么选 VGG16、数据怎么准备、增强怎么做、两阶段微调怎么安排,到训练跑崩了怎么排查、部署时前后处理怎么对齐。适合已经会写几行 PyTorch、想把这个任务真正跑出效果的人,也适合刚入门、想找一个完整项目练手的朋友——代码片段都能直接抄。

1. 任务拆解与技术选型的底层逻辑

1.1 服装分类到底难在哪:四个绕不开的现实问题

很多人第一次做服装分类,会觉得这就是个普通的图像分类任务,拿 ResNet 跑一跑就完事了。真跑起来才发现坑比想象的多。我把实际操作中遇到的核心难点归纳成四类,理解了这四类,后面的所有设计选择才有依据。

第一类是细粒度问题。服装品类之间的视觉差异,很多时候只体现在局部:领口是圆领还是翻领、袖子到手腕还是到手肘、下摆到腰还是到膝。这些差异在整张 224×224 的图上可能只占几十个像素,模型必须有能力捕捉这种局部细节。VGG16 那种连续堆叠 3×3 小卷积核的结构,在感受野逐层扩大的同时保留了较细的空间分辨率,对这类局部判别其实是有帮助的。

第二类是姿态与形变。同一件连衣裙,平铺拍、挂拍、模特穿拍,形状完全不同;模特侧身和正面的轮廓差异也很大。这意味着模型不能过度依赖"整体形状"这个特征,必须学会从纹理、颜色、结构部件上找证据。数据增强里的随机裁剪、轻微旋转、水平翻转,本质上就是在逼模型放弃对绝对位置的依赖。

第三类是背景干扰。商品图里经常有衣架、模特、道具、纯色背景,也有随手拍的家居环境。如果训练集里"连衣裙"恰好都拍在浅色背景上,模型很可能学到的是背景而不是衣服。这一点只能靠在数据层面控制多样性,以及靠更强的增强手段来缓解。

第四类是类别不均衡。真实业务里,"T恤"可能有 8000 张,"西装马甲"只有 200 张。直接用交叉熵训练,模型会倾向于把所有样本都预测成大类,总体准确率看着还行,但小类的召回惨不忍睹。这个问题在后面数据准备和损失函数设计那两节会专门处理。

把这四个问题想清楚,你就会明白:服装分类不是"模型够大就行"的任务,数据质量、增强策略、类别平衡这三件事的权重,其实比换骨干网络更高。

1.2 迁移学习在这类任务上到底解决了什么

从零训练一个 VGG16,需要百万级别的标注数据才可能收敛到一个像样的水平。服装分类的实际场景里,一个中等规模的品类体系,每个类别能凑出三五百张干净标注就已经不错了,全部加起来可能也就几万张。这个数据量从头训练,模型基本学不到通用的纹理和形状表达,只会在训练集上死记硬背。

迁移学习的价值在于,通用图像数据上预训练的卷积层,已经学到了大量与具体类别无关的视觉基元:第一层学到的方向性边缘、颜色斑块,第二层学到的角点、纹理,更深层学到的重复模式、物体部件。这些东西在服装图像上同样成立——布料的纹理、衣服的轮廓、纽扣和拉链的形状,本质上都是这些基元的组合。我们只需要重新学习"如何把这些基元组合成服装品类"这一层映射,也就是最后的分类头。

有一个数字很能说明问题:在我做过的一个模拟项目里,同样 2 万张训练图,从零训练的 VGG16 在验证集上跑了 30 轮,准确率卡在 62% 左右上不去;换成 ImageNet 预训练权重、只训练分类头 8 轮,就到了 81%。这个差距不是调参能弥补的,本质是数据量决定的学习上限。

注意:迁移学习不是万能药。如果目标域和预训练域的差异特别大(比如医学影像、卫星遥感),浅层特征的可迁移性会明显下降,这时候往往需要解冻更多层,甚至考虑领域内的自监督预训练。服装图像和自然图像差异不算大,所以 VGG16 的迁移效果是可靠的。

1.3 为什么是 VGG16:一份不算时髦但足够稳的选型

坦白说,VGG16 在今天算不上先进。参数量 1.38 亿,其中光全连接层就占了 1.23 亿,模型文件五百多兆,推理速度也比不过后来的轻量网络。但我依然认为它是服装分类项目里非常合适的起点,原因有这么几条。

结构极其规整,改起来不出错。VGG16 的网络定义几乎是模板化的:五个卷积块,每块是两到三个 3×3 卷积加 ReLU,块之间用最大池化下采样。这种规整性意味着你在做层冻结、特征提取、可视化中间激活的时候,索引非常清晰,不用去记复杂的残差分支和跳跃连接。对刚上手的人来说,调试成本低是巨大的优势。

迁移效果经过大量验证。因为结构简单、权重公开且稳定,VGG16 在各种小样本迁移任务上的表现都有大量公开参照。你遇到的问题,大概率别人也遇到过,排查起来有据可依。

特征图的可解释性好。服装分类经常需要跟业务方解释"模型为什么把这张判成衬衫",VGG16 的中间激活可视化出来很直观,能看到模型关注的是领口、袖口还是下摆。

下面这张表是我在实际项目中做的对比,数据来自同一个自建服装数据集(约 3 万张,20 个品类),输入统一 224×224,训练轮数和增强策略相同:

骨干网络可训练参数(冻结阶段)单轮训练耗时验证集 Top-1显存占用(batch=32)
VGG16约 1300 万(改小分类头后)约 95 秒89.2%约 4.2 GB
ResNet50约 100 万约 62 秒90.8%约 3.6 GB
MobileNetV3-Large约 60 万约 28 秒87.5%约 1.8 GB
从零训练的 VGG161.38 亿约 95 秒63.4%约 4.2 GB

看这张表就很清楚了:VGG16 的精度和 ResNet50 差距在 1.5 个百分点以内,但显存和速度都吃亏;MobileNetV3 速度快三倍多,精度只掉不到 2 个百分点。所以真实的选型逻辑应该这样:如果只是学习流程、验证方案可行性、或者数据量在万级以内,VGG16 是最省心的选择;如果最终要上生产、要控制推理成本,跑通之后应该把骨干换成 MobileNetV3 或 EfficientNet-B0,把 VGG16 当作一个可靠的基线。

2. 数据集构建与预处理:八成效果在这一步

2.1 类别体系怎么定:先想清楚业务边界

动手写代码之前,先花半天时间把类别体系定下来,这一步省不得。我见过太多项目,训练到一半发现"卫衣"和"薄外套"这两类的标注数据自己也分不清,最后只能返工重标。

定类别的核心原则是互斥且穷尽。互斥指的是任意一张图只能归到一个类里;穷尽指的是实际业务中会出现的品类,都要有对应的类或者一个"其他"兜底类。具体操作上,我会按这个顺序梳理:

先列出业务上真正需要区分的粒度。电商打标场景可能只需要"上装/下装/裙装/鞋/包"五类;而库存管理场景可能要细分到"圆领T恤/翻领衬衫/针织衫/卫衣"这种级别。粒度越细,需要的样本量和标注成本越高,一般建议从 10 到 30 类起步。

再处理那些天然模糊的边界。像"卫衣"和"连帽外套",视觉上高度相似,如果业务上不需要区分,就合并成一个类;如果必须区分,那就在标注规范里写清楚判定依据,比如"是否带帽、是否有拉链、厚度"。规范写得越具体,后续标注一致性越高。

最后留一个"其他/未分类"类。真实数据里总会有拍糊的、只露出一个袖子的、多件衣服叠在一起的图,这些图强行归到某个类里只会污染训练集。单独设一类,或者直接剔除,都比硬塞强。

2.2 数据清洗与长尾处理:不平衡问题必须正面刚

清洗这步最枯燥但回报最高。我一般会跑三个检查:一是重复图检测,用感知哈希或者简单的图像指纹去重,同一张图不同尺寸的版本混进训练集和验证集,会直接导致验证分数虚高;二是尺寸和通道检查,服装图里经常混进 CMYK 模式的图、带透明通道的 PNG、低分辨率的缩略图,统一转成 RGB 三通道、最小边不小于 256 像素;三是标签一致性抽检,每个类随机抽 30 张人工过一遍,看看有没有系统性标错。

类别不均衡的处理,按效果从高到低排序有这么几种手段:

第一种是重采样。对样本少的类做有放回的过采样,或者对大类的样本做欠采样。过采样简单有效,但要注意重复样本会让模型在这类上过拟合,所以过采样的同时必须配合较强的数据增强。

第二种是类别权重。在交叉熵里给每个类一个权重,权重通常取该类样本数的倒数再归一化。这个改动只需要两行代码,成本最低,效果也还不错。具体实现上,PyTorch 的CrossEntropyLoss有weight参数,直接传进去就行。

第三种是分层划分。train_test_split的时候一定要加stratify=labels,保证训练集、验证集、测试集里每个类的比例一致。否则如果小类在验证集里刚好一张都没有,评估结果会完全失真。

提示:如果你的小类样本数少于 100,光靠上面三招很难有实质提升。这时候更现实的做法是补充数据——去同一个来源再多收一批,或者用轻量的数据合成手段(比如把衣服主体抠出来贴到不同背景上)。这部分工作量的投入产出比,远高于在损失函数上做文章。

2.3 增强策略与归一化参数:别把增强调成噪声

数据增强在服装分类上极其关键,因为真实场景的拍摄条件太多样。但增强不是越多越好,用力过猛会破坏服装本身的判别性特征。

我实际用的增强组合是这样的:

  • 随机缩放裁剪:RandomResizedCrop(224, scale=(0.7, 1.0), ratio=(0.8, 1.25))。这个参数范围是调过的。scale下限设 0.7 而不是常见的 0.08,是因为服装的主体通常占据画面中心较大区域,裁得太狠会把衣服裁成一块布料,丢失领口下摆这些关键部件。ratio限制在 0.8 到 1.25,避免裁剪出极端长条,把衣服拉变形。
  • 水平翻转:p=0.5。服装左右对称性一般成立,翻转是安全的增强。但要注意,如果有"左衽/右衽"这类需要区分方向的品类,就不能用。
  • 小角度旋转:RandomRotation(10)。角度不能大,超过 15 度就不符合真实拍摄习惯了,反而引入噪声。
  • 颜色抖动:ColorJitter(0.2, 0.2, 0.1, 0.02)。亮度、对比度、饱和度各动 20% 左右,色调只动一点点。服装分类里颜色是重要判别特征(比如"黑/白/红"这类细分),抖太猛会破坏这个信息。
  • 随机擦除:RandomErasing(p=0.2, scale=(0.02, 0.15))。模拟衣服被遮挡或者局部细节看不清的情况,对小样本任务有明显的正则效果。

归一化这块,必须用 ImageNet 的均值和标准差(mean=[0.485, 0.456, 0.406],std=[0.229, 0.224, 0.225])。原因很实在:预训练权重是在这个归一化分布下训练出来的,如果你改用自己数据集的统计量,输入分布和权重期待的不一致,浅层特征的响应会整体偏移,迁移效果会打折扣。这个细节很多人忽略,但它对冻结训练的收敛速度影响很明显。

关于验证集和测试集的预处理,有一点必须强调:不要做任何随机增强,只用Resize(256)加CenterCrop(224),然后归一化。用随机变换做验证,得到的指标会上下抖动,你根本没法判断模型是真的进步了还是在碰运气。

2.4 划分方式与防泄漏:一个容易致命的细节

数据划分看起来是最没技术含量的环节,但这里出的错往往最伤人。我踩过的最狠的一次坑是:同一个商品的不同角度图,被随机分到了训练集和验证集。结果验证集准确率 93%,一上测试集直接掉到 71%。原因是模型记住了"这件衣服的拍摄背景和角度",而不是学到了品类特征。

正确的做法是按商品 ID 分组划分。如果你的数据来自电商,每张图都有所属商品的 ID,那么划分时应该以商品为单位做 GroupShuffleSplit,保证同一个商品的所有图片只会出现在一个集合里。如果没有商品 ID,退而求其次可以按图片来源分组,或者用图像相似度聚类后再分组。

划分比例上,常规的 8:1:1 够用,但如果类别数多、小类样本少,建议调成 7:2:1,让验证集和测试集有足够的样本覆盖每个类。测试集要留到最后才动,中间调参只看验证集,这个纪律必须守住。

3. VGG16 网络改造与两阶段微调

3.1 结构解剖与参数预算:钱要花在刀刃上

在改网络之前,得先知道 VGG16 的参数量分布。它由 13 个卷积层和 3 个全连接层组成,卷积部分大约 1470 万参数,全连接部分大约 1.23 亿参数——也就是说,光第一个全连接层Linear(25088, 4096)一个就有约 1.03 亿参数。这个数字意味着什么?意味着如果你直接把最后的Linear(4096, 1000)换成Linear(4096, 20),模型 99% 的参数都集中在那个和你任务毫无关系的 25088→4096 映射上,训练起来又慢又容易过拟合。

所以正确的改法是把整个classifier拆掉重建。我一般用这样一个轻量分类头:

import torch.nn as nn from torchvision import models def build_model(num_classes, dropout=0.5, pretrained=True): weights = models.VGG16_Weights.IMAGENET1K_V1 if pretrained else None model = models.vgg16(weights=weights) # 冻结卷积骨干 for p in model.features.parameters(): p.requires_grad = False in_features = model.classifier[0].in_features # 25088 model.classifier = nn.Sequential( nn.Linear(in_features, 512), nn.ReLU(inplace=True), nn.Dropout(dropout), nn.Linear(512, num_classes), ) return model

这样一改,可训练参数从 1.2 亿降到约 1300 万(25088×512 ≈ 1284 万 + 512×20 ≈ 1 万),模型文件从五百多兆降到六十兆左右。显存占用和单轮耗时都会明显下降,小样本下的泛化表现反而更好。

这里 25088 这个数字怎么来的,顺便说一下:输入 224×224 经过五次池化,每次减半,最终特征图是 7×7,通道数是 512,展开就是 7×7×512 = 25088。如果你打算把输入尺寸改成 256 或者 320,这个数字会变,所以要写成model.classifier[0].in_features这种自适应取法,别写死。

3.2 两阶段微调:冻结与解冻的节奏感

迁移学习效果好不好,很大程度取决于微调的节奏安排。我习惯分成两个阶段:

第一阶段,只训练分类头。骨干完全冻结,学习率给大一点,用 Adam 的话 1e-3,SGD 的话 1e-2 配 0.9 动量。这一步跑 5 到 10 轮,目的是让新的分类头先适应骨干输出的特征分布,把权重初始化带来的随机性抹掉。这个阶段收敛很快,通常 3 轮之后验证准确率就能到 70% 以上。

第二阶段,解冻部分卷积层一起微调。从features的后面往前解冻,VGG16 的features是 30 层(索引 0 到 29),我一般解冻最后 8 到 10 层,也就是从features[22]开始往后。学习率必须降下来,比第一阶段小一到两个数量级,用 1e-5 到 1e-4 之间。跑 5 到 15 轮,配合余弦退火或者 ReduceLROnPlateau。

为什么要两阶段而不是一步到位?因为一开始就解冻,分类头的随机初始权重会产生较大的梯度,这些梯度反向传播到预训练的卷积层,会把已经学好的通用特征破坏掉——这在术语上叫"灾难性遗忘"。先让分类头稳住,再小学习率微调深层,这个顺序能保住大部分预训练知识。

实际操作里还有一个进阶技巧:判别式学习率。给骨干和分类头设不同的学习率,骨干 1e-5,分类头 1e-4,用参数组实现:

optimizer = torch.optim.AdamW([ {"params": model.features.parameters(), "lr": 1e-5}, {"params": model.classifier.parameters(), "lr": 1e-4}, ], weight_decay=1e-4)

这个写法比统一学习率稳得多,尤其是解冻后的前几轮,损失曲线平滑很多。

3.3 BatchNorm 的真实坑:小 batch 会让统计量发疯

如果你用的是带 BN 的 VGG16(vgg16_bn),这里有个必须知道的坑:微调阶段 BN 层的运行统计量会被你的小批量数据重新估计。如果你的 batch size 只有 16 或 32,这些统计量的估计噪声很大,而且会覆盖掉预训练时用大批量数据统计出来的稳定值,导致验证集表现剧烈抖动,甚至越训越差。

处理办法有两个。一是在解冻阶段把 BN 层设置为 eval 模式,也就是冻结统计量更新,只让卷积核的权重参与学习:

def freeze_bn(model): for m in model.modules(): if isinstance(m, nn.BatchNorm2d): m.eval() m.weight.requires_grad = False m.bias.requires_grad = False

二是把 batch size 提到 64 以上。显存不够的话,用梯度累积模拟大批量,比如实际 batch 16,累积 4 步再更新一次参数。这两种方式我都在项目里用过,冻结 BN 的做法更稳妥,尤其是在数据量不大的情况下。

顺便提一句,如果你的显存实在紧张,直接用不带 BN 的vgg16版本反而更省心,只是收敛会慢一点,需要把学习率再调小一些。

4. 完整实操流程:从环境到训练到评估

4.1 环境准备与目录结构

环境这块没什么花哨的,Python 3.9 以上,PyTorch 2.0 以上,torchvision 对应版本,再加 Pillow、numpy、scikit-learn、matplotlib。有 GPU 的话确认 CUDA 版本和 PyTorch 匹配,torch.cuda.is_available()返回 True 就行。

目录结构建议这么组织,后面换数据集、换骨干都方便:

garment_cls/ ├── data/ │ ├── train/ │ │ ├── tshirt/ │ │ ├── shirt/ │ │ └── ... │ ├── val/ │ └── test/ ├── src/ │ ├── dataset.py │ ├── model.py │ ├── train.py │ ├── evaluate.py │ └── infer.py ├── outputs/ └── requirements.txt

按类别分子目录存放,直接用torchvision.datasets.ImageFolder就能读,标签自动按文件夹名映射成索引。这里有个细节要注意:ImageFolder的类别顺序是按文件夹名字母序排的,所以训练和推理阶段必须用同一份class_to_idx映射,最好在训练结束后把它存成 JSON,推理时读回来,别靠记忆。

4.2 Dataset 与 DataLoader 的写法

DataLoader 部分我给出关键实现,重点是训练和验证走不同的 transform 分支:

from torchvision import transforms from torchvision.datasets import ImageFolder from torch.utils.data import DataLoader train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0), ratio=(0.8, 1.25)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(10), transforms.ColorJitter(0.2, 0.2, 0.1, 0.02), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), transforms.RandomErasing(p=0.2, scale=(0.02, 0.15)), ]) eval_tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) train_ds = ImageFolder("data/train", transform=train_tf) val_ds = ImageFolder("data/val", transform=eval_tf) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=8, pin_memory=True, drop_last=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False, num_workers=8, pin_memory=True)

几个参数值得说一下。num_workers=8要看机器的 CPU 核数,一般设成核数的 0.5 到 0.75 倍,设太大反而因为进程调度拖慢速度。pin_memory=True在有 GPU 时能加速主机到显存的数据搬运。drop_last=True在训练集上开着,避免最后一个不满的 batch 让 BN 统计量抖动。验证集不需要 shuffle,也不需要 drop_last。

注意:RandomErasing必须放在Normalize之后,因为它作用在张量上。放在ToTensor之前会报错,这是新手常犯的顺序错误。

4.3 训练主循环:把关键细节都写进去

训练循环我写得稍微完整一点,把标签平滑、梯度裁剪、余弦调度都带上:

import torch, torch.nn as nn from torch.optim.lr_scheduler import CosineAnnealingLR from tqdm import tqdm def train_one_epoch(model, loader, criterion, optimizer, device, clip=5.0): model.train() total_loss, correct, total = 0.0, 0, 0 for imgs, labels in tqdm(loader, leave=False): imgs, labels = imgs.to(device, non_blocking=True), labels.to(device) optimizer.zero_grad(set_to_none=True) logits = model(imgs) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), clip) 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

损失函数用带标签平滑的交叉熵,配合类别权重:

criterion = nn.CrossEntropyLoss( weight=class_weights.to(device), label_smoothing=0.1, )

标签平滑在这里的作用是抑制过拟合。服装分类里有些样本本身就标注模糊(一件衣服介于卫衣和外套之间),硬标签会逼模型给出极端置信度,标签平滑把这些模糊样本的影响软化了,实测在验证集上能稳定提升一到两个点,几乎没有代价。

类别权重这样算:

import numpy as np counts = np.bincount(train_ds.targets, minlength=num_classes).astype(np.float32) class_weights = 1.0 / np.sqrt(counts) class_weights = class_weights / class_weights.sum() * num_classes class_weights = torch.tensor(class_weights)

这里我用的是平方根倒数而不是直接倒数。直接倒数在极度不均衡时会过度补偿小类,导致模型把大类样本误判成小类,整体精度下降。平方根是一种折中,实践下来更稳。

4.4 评估指标:别只看准确率

准确率这个指标在类别不均衡时非常具有欺骗性。如果 20 个类里前 3 个大类占了 60% 的样本,模型全猜这三个类也能有 60% 的准确率。所以我一般同时看四个东西:Top-1 准确率、Top-3 准确率、每类的 Precision/Recall/F1、混淆矩阵。

评估代码的关键部分:

from sklearn.metrics import classification_report, confusion_matrix def evaluate(model, loader, device): model.eval() all_preds, all_labels, all_probs = [], [], [] with torch.no_grad(): for imgs, labels in loader: imgs = imgs.to(device) logits = model(imgs) probs = torch.softmax(logits, dim=1) all_probs.append(probs.cpu()) all_preds.extend(logits.argmax(1).cpu().numpy()) all_labels.extend(labels.numpy()) probs = torch.cat(all_probs) top3 = probs.topk(3, dim=1).indices.numpy() top3_acc = np.mean([l in p for l, p in zip(all_labels, top3)]) print(classification_report(all_labels, all_preds, target_names=class_names, digits=4)) return top3_acc, confusion_matrix(all_labels, all_preds)

拿到混淆矩阵之后,重点看那些对称的高值格子——比如"衬衫"和"薄外套"互相误判的数量都很高,那说明这两个类的视觉边界确实模糊,要么合并,要么在数据层面补充更多有区分度的样本。而那些单向的高值格子,比如"连衣裙"大量被判成"半身裙",通常是"半身裙"类样本不足导致的,需要补充数据或者加大该类的采样权重。

我在一个模拟项目里的实测数字是这样的:20 个类、3 万张图,冻结阶段 8 轮后验证集 Top-1 是 84.3%,解冻微调 12 轮后到 89.2%,Top-3 到 96.1%。混淆最多的三对是"卫衣/薄外套"、"衬衫/连衣裙"、"短裤/半身裙",这三对占全部错误的四成以上,跟人工标注时的困惑点高度一致。这个现象反过来也说明模型学到的东西是对的——它犯的错和人犯的错是同一类。

4.5 导出与推理:前后处理必须严格对齐

训练完的模型要落地,导出这一步很容易埋雷。我一般导两个格式:TorchScript 用于 Python 服务,ONNX 用于跨语言部署。

model.eval() dummy = torch.randn(1, 3, 224, 224).to(device) traced = torch.jit.trace(model, dummy) traced.save("outputs/vgg16_garment.pt") torch.onnx.export( model, dummy, "outputs/vgg16_garment.onnx", input_names=["input"], output_names=["logits"], opset_version=12, dynamic_axes={"input": {0: "batch"}}, )

推理阶段的预处理函数必须和验证集完全一致,我通常把它单独抽成一个函数,训练和推理共用同一份代码:

def preprocess(img: Image.Image) -> torch.Tensor: tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) return tf(img.convert("RGB")).unsqueeze(0)

这里有两个最容易出错的地方。第一是颜色空间,必须convert("RGB"),如果是 RGBA 或者灰度图直接转张量,通道数不对会直接报错,或者更隐蔽地导致精度暴跌。第二是插值方式,Resize默认用的是双线性插值,如果你训练时用的是RandomResizedCrop(内部也是双线性),推理保持一致就没问题,但如果哪一方改成了最近邻,结果会有细微偏差。这种偏差在单张图上看不出来,在批量评估时会有 1 到 2 个点的影响。

5. 常见问题与排查速查表

5.1 训练不收敛或准确率卡住:先分清楚是哪种卡

"模型不收敛"这个描述太笼统,实际排查时得先看损失曲线的形状。

损失一直在 2.9 附近不动,准确率等于最大类占比——这是最典型的情况,说明模型根本没学到东西,输出退化成常数。原因通常有三个:学习率太大导致梯度爆炸(看梯度范数,如果动辄成百上千就是这个问题)、数据标签全错(检查class_to_idx映射是否和文件夹一致)、或者归一化参数用错了。第三个原因最隐蔽,我见过有人用mean=[0.5,0.5,0.5], std=[0.5,0.5,0.5]做归一化配上 ImageNet 预训练权重,训练能收敛但精度明显偏低。

损失正常下降,但验证集准确率在 60% 左右横盘——这通常意味着模型只学到了大类特征,小类完全没学会。去看混淆矩阵,如果所有小类都被预测成两三个大类,那就是不均衡问题,回头加上类别权重和重采样。

训练损失降得很好,验证损失从第三轮开始上升——标准的过拟合。这时候要加 dropout(分类头里设 0.5)、加增强强度、加权重衰减(1e-4 到 1e-3),或者干脆减少解冻的层数。

我把常见的现象和对应处理整理成了一张表,出问题的时候按这个查会快很多:

现象最可能的原因快速验证方式处理手段
损失不动,准确率等于最大类占比学习率过大或标签映射错误打印一批预测分布,看是否全同一类学习率降 10 倍;核对 class_to_idx
训练集准确率 95%,验证集 65%过拟合或数据泄漏检查是否同商品跨集加增强、加 dropout、按商品分组重划分
验证集准确率逐轮剧烈抖动BN 统计量不稳定看 batch size 是否小于 32冻结 BN 或增大 batch、用梯度累积
小类召回低于 30%类别不均衡看混淆矩阵小类分布类别权重、过采样、补充数据
前几轮正常,解冻后精度暴跌微调学习率过大看解冻后损失是否突增学习率降到 1e-5,改用判别式学习率
训练正常但推理结果全错前后处理不一致用同一张图分别跑训练管道和推理管道比对统一预处理函数、确认 RGB 转换

5.2 显存溢出与训练速度:几个立刻见效的调整

VGG16 的显存占用主要来自激活值,224×224、batch 32 的情况下大概要 4 GB 左右,如果机器只有 8 GB 显存,稍微加个并行评估就可能 OOM。

立刻能用的手段有这么几个:降低 batch size 配合梯度累积,比如实际 batch 8,累积 4 步等效 batch 32,显存占用降到四分之一,收敛效果基本一致,代价是训练时间略长;开启混合精度,用torch.cuda.amp的 autocast 和 GradScaler,显存能省三到四成,速度还能快一些;冻结更多层,冻结的层不保存激活的梯度,显存占用会下降;把num_workers调小,有时候 OOM 不是显存的问题,是 worker 进程把内存吃满了被系统杀掉,表现得很像显存不足。

速度方面,pin_memory=True和non_blocking=True这两个开关一定要配对上,很多人只开了一个,效果等于没开。另外channels_last内存格式在部分 GPU 上对卷积有明显加速,可以试试model = model.to(memory_format=torch.channels_last),输入张量也做同样转换。

提示:如果单轮训练时间超过两分钟,先别急着调模型,检查一下数据加载是不是瓶颈。用torch.utils.data.DataLoader的时候可以把一个 epoch 的时间拆成"数据加载耗时"和"前向反向耗时"分别统计,如果数据加载占了六成以上,说明预处理中的随机变换太慢(尤其是RandomRotation和ColorJitter),可以考虑用更快的图像库或者提前把图缩放到统一尺寸缓存起来。

5.3 部署阶段的前后不一致:最容易被忽略的一类 bug

模型训练得好好的,上线之后预测结果莫名其妙,这类问题九成出在前后处理不一致上。我按发生频率列几个:

图像方向问题。手机拍的照片带 EXIF 旋转信息,Pillow 读进来默认不应用旋转,训练时如果你的数据已经处理过方向,推理时读到的图就是歪的。解决方法是读取后统一做ImageOps.exif_transpose(img)。

缩放策略不一致。训练用RandomResizedCrop,推理用Resize(256) + CenterCrop(224),这个组合是对的。但如果有人为了省事,推理时直接Resize((224, 224)),图像会被拉伸变形,服装的长宽比信息丢失,精度会掉几个点,而且是那种"看起来还行但就是不如测试集"的隐性掉点。

批处理归一化位置。如果推理代码里手动写了归一化而没走同一份 transform,很容易把ToTensor和Normalize的顺序搞反,或者把 0-255 和 0-1 的量纲搞混。我一般的做法是把整个预处理封装成一个函数,训练、验证、推理三处 import 同一个函数,从源头上消灭不一致的可能。

ONNX 导出后的算子差异。某些算子在 ONNX 里没有完全对应的实现,会退化成语义近似但不完全等价的操作。导出后必须做一次对齐验证:同一批 100 张图,分别用 PyTorch 和 ONNX Runtime 推理,比较输出的最大绝对误差,正常应该在 1e-4 以内。超过这个量级就说明导出有问题。

6. 提升精度的低成本手段与个人经验

6.1 不换骨干也能涨点的几招

模型跑通之后,大部分人的第一反应是换更大的骨干。但我的经验是,在换骨干之前,有几个成本极低的手段值得先试完,往往能带来两三点的提升,比换模型划算得多。

提高输入分辨率。VGG16 是全卷积的,卷积部分对输入尺寸没有硬性要求,只要能被 32 整除就行(五次池化)。把输入从 224 提到 288 或 320,服装的局部细节(领口、纽扣、织法纹理)能保留得更多。代价是显存和计算量增加,但精度提升通常有 1.5 到 3 个点。这个改动只需要改 transform 里的尺寸,顺手把分类头的in_features用自适应方式取就行。我实测过 224→288,验证集 Top-1 从 89.2% 到 91.4%,收益很明显。

测试时增强(TTA)。对测试图做几个确定性变换(原图、水平翻转、轻微放大裁剪),把预测概率平均。这个方法不需要重训,只增加推理耗时。翻转 TTA 通常能涨 0.5 到 1 个点,多尺度 TTA 能到 1.5 个点左右。实现上就是把 batch 复制几份,各自变换后送进模型,概率取平均再 argmax。

模型融合。把 VGG16、ResNet50、MobileNetV3 三个模型的 softmax 输出加权平均,权重按验证集精度分配。三个模型的结构差异足够大,错误相关性低,融合后能涨 2 到 3 个点。代价是推理成本翻三倍,适合对精度要求高、对延迟不敏感的场景(比如离线批量打标)。

难例挖掘。跑完一轮训练,把验证集里预测错的样本挑出来,人工看看是不是标注有问题。我做过统计,第一版模型分错的样本里,大约 15% 是标注错误。把这些错误标干净重训,比任何算法技巧都有效。这个活儿枯燥但回报最高,建议每轮迭代都做。

MixUp 与 CutMix。这两个方法在数据量小的时候效果不错,尤其是 CutMix,把两张图的一部分拼接起来,逼模型同时识别两个类,能明显缓解过拟合。注意要和标签平滑配合,并且训练轮数要相应增加(一般多跑 30% 的轮数),因为这类方法会减慢收敛。

6.2 一些不成体系的实操心得

最后分享几条从实际项目里攒下来的经验,都是文档里不太会写的东西。

先跑通最小闭环,再优化。我见过不少人一上来就纠结用不用 MixUp、要不要加注意力模块,结果两周过去了连 baseline 都没跑出来。正确的节奏是:第一天用 100 张图 5 个类跑通全流程,确认数据加载、训练、评估、推理这条链路都是通的,然后再往上加数据、加增强、调参数。只有闭环跑通了,后面的每一次改动才有对比基准。

训练日志要存全,别只存准确率。我一般会记录:每轮的训练损失、验证损失、训练准确率、验证准确率、学习率、梯度范数、每类召回。这些曲线在出问题的时候是唯一可靠的诊断依据。尤其是梯度范数,能提前预警梯度爆炸;每类召回能提前发现不均衡恶化。

随机种子的影响超出想象。小样本任务上,同一个配置换个种子跑,验证准确率能有 1 到 2 个点的波动。所以判断某个改动是不是真的有效,至少跑三个种子取平均,否则很容易把噪声当成提升。这个纪律看起来麻烦,但能避免大量无效的调参。

数据量的边际收益会快速衰减。在模拟项目里,我从每类 200 张加到 500 张,验证准确率从 82% 涨到 89%,涨了 7 个点;从 500 张加到 1500 张,只从 89% 涨到 91.5%。所以当数据量到一定规模后,继续堆标注的性价比就不高了,这时候把精力转向更精细的类别体系梳理、错标修正、难例挖掘,回报会更高。

别迷信网上流传的"最佳参数"。学习率、dropout、增强强度这些超参,和目标数据集的大小、类别数、图像风格强相关。别人的 1e-4 在你的数据上可能就是灾难。我的做法是先在一个小规模子集上做粗粒度的网格搜索(学习率试 1e-3、1e-4、1e-5 三档,dropout 试 0.3、0.5 两档),找到大致范围后再在全量数据上精调。这样能在半天内定位到合适的量级,比盲目套用别人的配置靠谱得多。

给业务方看的结果,不要只给准确率。跟业务方沟通的时候,说"准确率 89%"没什么感觉,说"在 100 张待标图里,模型能自动处理其中 72 张且错误率低于 2%,剩下 28 张转人工",对方的反应完全不一样。把模型指标翻译成业务指标,是这个项目能不能真正落地推广的关键一步,也是很多人做技术时容易忽略的地方。

这个项目后续还能往下延展的方向其实挺多的:多标签分类(一件衣服同时是"针织"和"长袖")、属性识别(颜色、领型、袖长分开预测,比单一品类分类更有业务价值)、细粒度检索(用骨干输出做特征向量,支持以图搜款)。骨干网络从 VGG16 换成更轻的结构,也是跑通之后一个很自然的下一步。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询