如果你是从这个系列一路跟过来的,大概率会被这一篇的标题惊到——推荐系统学习之路怎么突然切到了树叶、小图和狗?我自己的规划里,第十六篇原本应该继续讲排序或者多目标建模,但动手学深度学习那本教材的进度推着推着,就把我推到了计算机视觉这边。既然躲不过,我索性把三个在公开数据集上非常经典的图片分类任务一起做了:Kaggle树叶分类、CIFAR10、ImageNet Dogs,用CNN把"从像素到语义"这条路完整走一遍。
这一篇会分成三个独立但递进的实战项目来展开。它们分别代表图片分类的三种典型场景:小样本细粒度分类、低分辨率通用分类、大数据量迁移学习。不管你是冲着推荐系统来的,还是本来就在入门CV,把这三种场景各跑通一次,CNN相关的功底基本就齐了。标题里的TODO,我拆成三件事:先看懂三个数据集之间到底差在哪,再逐个上手跑通,最后沉淀出一个以后换数据集能直接套用的通用决策模板。
1. 三个数据集的难度阶梯:为什么按这个顺序做
1.1 树叶分类、CIFAR10、ImageNet Dogs分别对应什么挑战
先说树叶分类。这个任务的核心痛点是"类别多、样本少、长得像"。公开赛题里大约有近百个植物物种,每个物种只有几十张到一百来张叶片图像,而且很多物种之间的叶片形态差异非常细微——同样是带锯齿边缘的椭圆叶,你可能得盯半天才能确认是两种不同的树。这种任务在CV里叫细粒度图像分类,考核的不是"认不认得常见物体",而是"能不能区分看起来很接近的细节"。
CIFAR10正好相反,类别只有10个,每类有5000张训练图,数据量完全够。但每张图只有32×32像素,分辨率低到人眼都经常看不清。图里是猫、狗、卡车还是飞机,很多情况下只能靠颜色和大概轮廓去猜。这种任务的问题不是数据不够,而是有效信息太少,模型稍不注意就会抓住训练集里的噪声当特征。
ImageNet Dogs是一个中型规模的子集,大约有120个犬种、两万多张训练图,分辨率比CIFAR10高了好几个档次,但类别数量也比CIFAR10多得多。它更接近真实世界的工业级场景:数据多、类别多、图像质量参差、类与类之间的差异有时大有时小。这类任务靠手工设计特征完全不现实,靠从零训练一个超深网络又太慢太贵,最合理的路线是站在别人已经训练好的模型肩膀上继续走。
1.2 三个任务背后的三条技术路线
我做完这三个项目之后最大的收获,不是记住了几个网络结构,而是明白了一个道理:图片分类任务没有"银弹模型",只有"适合当前数据条件的路线"。
用一张表可以看得很清楚:
| 任务 | 数据规模 | 分辨率 | 类别差异 | 最合适的路线 |
|---|---|---|---|---|
| 树叶分类 | 小 | 中等 | 很小 | 预训练模型抽取特征 + 简单分类头 |
| CIFAR10 | 较大 | 极低 | 中等 | 从零训练轻量CNN + 强数据增强 |
| ImageNet Dogs | 大 | 较高 | 差异大 | 预训练模型微调 + 分层学习率 |
推荐系统里的经验在这里也适用:先诊断数据,再选模型。你在做召回模型的时候,用户行为稀疏、物品数量大、语义差异细微,这些问题和树叶分类几乎一模一样;你总不能一上来就堆一个几十亿参数的大模型,而是要想办法用一个足够强的底座提取特征,再接一个轻量层做决策。放到CV里,那个"足够强的底座"就是预训练模型。
学习顺序也是刻意安排的。先做树叶分类,是因为任务对"防过拟合"的要求最高,逼着你养成"先看数据再看模型"的习惯;再做CIFAR10,是因为从零训练一个网络能让你把卷积、池化、BN、学习率这些底层组件的脾气摸透;最后做ImageNet Dogs,是把前面两关攒下的经验综合起来,真正去操作一次迁移学习,也最能体会到预训练模型的威力。
2. 动手前必须补的概念:CNN在图片分类里到底在学什么
2.1 从一张图到一堆向量
我建议你先忘掉所有复杂的数学公式,用一句话理解CNN:它是一台从像素矩阵中逐层提炼语义信息的机器。一张彩色图片在计算机里就是三个二维矩阵,分别对应红、绿、蓝三个通道,矩阵里每个格子的数值代表这个像素的亮度。
CNN做的第一件事,是用一堆小矩阵在图片上滑动,这些小矩阵就是卷积核。一个卷积核相当于一个小型特征检测器——它只关注局部区域里有没有某种特定模式。早期卷积层学到的往往是边缘、色块、横竖纹这种非常基础的模式;再往深一层,网络把这些基础模式组合起来,开始能识别"眼睛""轮子""树枝"这种部件级的特征;到了更深层,整个网络对一个物体的整体语义就有了判断。
这个过程可以类比成画一棵树:你先用粗线条勾出轮廓和主枝干,然后慢慢补充枝叶纹理,最后再去皴擦那些细小的明暗变化。CNN里的卷积层堆叠也是从粗到细、从局部到整体。
2.2 为什么图片分类绕不开CNN
最直接的原因是参数规模。一张32×32的CIFAR10图铺平是3072个像素值,如果直接接全连接层,后面一层哪怕只设512个神经元,光这一层的权重就有150万个;放到224×224的ImageNet级别图片上,全连接层的第一层就会出现上亿的参数,训练几乎不可能完成。CNN通过卷积核的参数共享和局部连接,把参数量降了几个数量级,同时还能保持对空间结构的感知能力。
另一个核心概念是池化。池化的本质是采样——最常见的是最大池化,在2×2小窗口里取最大值。它做的事情很粗暴但很有效:保留局域内最强的响应,丢弃掉不重要的信息,同时让图像特征尺寸减半、感受野变大。这也是为了引入一点平移不变性:物体稍微挪动几个像素,池化后的特征仍然能保留下来。
全部卷积和池化完成后,会有一个"压平"的过程,把空间特征变成一个长向量,再接若干全连接层,最终输出每个类别的得分。实际训练的时候,最后一层后面通常会加一个softmax,让得分变成概率分布,再配合交叉熵损失去更新整个网络。
2.3 数据增强为什么在图片分类里这么值钱
在推荐系统里想做数据增强很难——你不能把一个用户的点击序列随便平移、翻转,否则语义就变了。但CV是个例外。一张猫图水平翻转、上下移动几个像素、调暗一点,它还是同一只猫。这就是图像数据的"不变性"优势。
所谓数据增强,就是在训练过程中人为生成这些变化后的图片,让模型看到更多"本质相同、表面不同"的样本。它在防过拟合上的性价比极高,通常比调整网络结构更有效、更便宜。后面三个实战项目里,我每一步都先用增强把模型拖到不容易过拟合的状态,再去调其他超参,这个顺序很重要——如果增强没做好,你调再多的学习率都是在一个会漂移的地基上盖房子。
提示:动手之前先检查你的训练集、验证集分布是否一致,再做增强。如果验证集本来和训练集同源,增强过猛反而会让训练集分布偏离验证集,导致验证指标反而变差。
3. 树叶分类实战:小样本细粒度任务不能从零训练
3.1 第一反应往往是错的
拿到树叶分类这种数据集,很多人第一反应是"数据少就做数据增强,然后从零训练一个小型CNN"。这个思路在类别较少的任务上偶尔能跑通,但放到上百个物种、每类只有几十张图上,基本是死路。
我一开始也走过弯路。当时直接套了一个6层卷积的网络,从零开始训练,训练集准确率轻松到了99%以上,验证集却一直卡在30%上下。这说明模型把每个类别的个性样本背下来了,根本没有学到泛化的叶形特征,这就是典型的严重过拟合。小样本细粒度任务的难点在于:类别之间的差异非常微妙,而可用的训练样本又太少,模型没有办法自己找到那些有判别力的特征。
这种情况下,正确的策略是把重心从"设计模型"转移到"利用已有知识"上。打个比方,与其让一个没见过树的学徒从零开始认一百种叶子,不如让一个已经在森林里摸爬滚打多年的植物学家快速过一遍样本,他只需要记住每种叶子的特殊细节就够了。移植到机器学习里,这个"植物学家"就是在ImageNet这么大尺度的通用图像上预训练好的模型。
3.2 特征抽取方案的完整步骤
我的方案是"冻结预训练骨干,只训练分类头",甚至都不需要一开始就微调整个网络。步骤非常直接:
- 把所有图片统一缩放到224×224,做归一化处理。
- 加载在ImageNet上预训练过的模型,去掉它最后的全连接层。经过全局平均池化后,每张图会变成一个固定长度的特征向量,ResNet系列通常是2048维。
- 把训练集的每一张图都过一遍预训练模型,把输出特征保存下来。这步是离线进行的,可以一次性做完,之后分类头训练时不再需要触碰图片。
- 用这些特征训练一个非常小的分类器,我用的是两层MLP,中间加Dropout(0.5),输出层类别数设成树叶种类数。
- 用交叉验证选择MLP的隐藏层宽度和Dropout比例,同时记录验证集指标。
这套流程跑下来,验证集准确率轻松从30%跳到80%以上。更难得的是,因为特征抽取是一次性的,后面训练MLP的耗时可能只有几秒钟,整个过程甚至可以在普通CPU上完成——这对没有强力GPU的入门者很友好。
3.3 踩坑记录:解冻全部层的反面教材
后来我贪心了一把,想着如果整个模型联合微调,效果应该还能再涨。我把骨干网络的冻结全部解除,用一个很小的学习率从头训了20个epoch。结果验证集准确率先升了几个点,随后开始下滑,最终反而比单纯的冻结特征抽取方案差了将近10个点。
原因现在回头看很清楚:树叶数据量太少了,预训练模型在主数据集上学到的通用特征(边缘、纹理、曲面关系)已经非常成熟,回传梯度和微调这些层,等于是用一百来张图去覆盖人家从上百万张图里学到的知识,属于典型的"用少量数据污染大规模预训练特征"。树叶分类的正确做法是只调顶层,或者最多解冻最后两个残差块,而且学习率要比正常值低一个数量级。
另外还有一个经验教训:增强力度要适中。叶子识别的物理特性比较特殊,比如旋转180度可能仍然是同一类叶子,但旋转90度可能就把叶子的朝向信息破坏了;大幅度的色彩抖动也可能会让模型丢掉叶片固有的色调线索。所以增强策略不是越猛越好,要理解数据本身的属性再决定做什么变换。
4. CIFAR10实战:从零训练一个轻量CNN的完整管线
4.1 32×32的小图上,重模型反而是负担
树叶分类让我尝到了预训练模型的甜头,到了CIFAR10我一开始也想直接搬ResNet50来微调。结果验证集精度还没一个自己设计的轻量模型高,原因是CIFAR10图像尺寸只有32×32,正常预训练模型是为224×224的大图设计的,前面的阶段一次下采样就把小图压得面目全非,信息损失太严重。
这个经验在CV里很反直觉:越大的模型不一定是越好的模型,模型容量要和输入分辨率、数据规模匹配。CIFAR10的数据量不小(5万张训练图),类别也只有10类,完全够一个轻量CNN从零学习。
4.2 一个能用的简单网络结构和它的设计理由
CIFAR10项目的核心是训练管线,网络结构不用太花哨。我用了一个3个卷积块的轻量网络,结构相当朴素:
import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.3), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))每一层都有明确目的。卷积层逐步把通道数从3扩到32、64、128,是在增加特征表达的丰富度;每层后面的BatchNorm加速了收敛,也顺带起到了一点正则化效果;三个2×2最大池化把32×32的输入一路降到4×4,使最后一个卷积块输出的空间分辨率非常小,这时候再用全局平均池化压成128维向量,参数量和过拟合风险都控制住了。
这个网络总参数量相当小,配合一张普通显卡训练起来飞快。如果你的目标是理解模型原理而不是打比赛出成绩,这类轻量结构是最合适的调试载体。
4.3 数据增强组合拳与训练配置
CIFAR10的增强我分了两档。第一档是基础增强:RandomCrop(32, padding=4) 加 RandomHorizontalFlip。等效于让每张图有无数个"轻微偏移、翻转"的变体,把原来5万张的训练量在效果上放大好几倍。第二档是进阶增强:随机擦除或者Cutout——把图中一个随机位置的16×16方块像素遮掉,强迫模型不依赖任何单一局部特征,这是摆脱过拟合的很有效的一招。
训练配置也是实验出来的固定套路。优化器先用Adam跑20个epoch左右完成热身,学习率设为1e-3;然后切成带动量的SGD,学习率用1e-2,配合5e-4的权重衰减继续训练。为什么中途换优化器?Adam的优势是前期收敛快、超参不敏感,但到后期往往收敛得不够"干净";SGD加动量虽然开头慢,最后几轮却常常能把精度再往上拉一截。批量大小取128,套餐总计约60个epoch,已经足够看到稳定的结果。
我做了几组消融对比,结果很有代表性:
| 配置 | 验证集准确率 |
|---|---|
| 无增强 + Adam到底 | 约67% |
| 基础增强 + Adam到底 | 约73% |
| 基础增强 + Adam+SGD切换 | 约77% |
| 基础增强 + 随机擦除 + 完整管线 | 约79% |
每一档提升都来自独立的模块,这也反过来说明:结构、增强、优化策略是三个互补的杠杆,不要指望换一个网络结构就把精度从60直接干到90。
4.4 训练曲线怎么看:过拟合的早期信号
我特意把训练日志打印成间隔输出,隔一段时间就去看一次loss曲线。最典型的过拟合信号是:训练loss还在持续下降,验证loss却开始掉头向上。这在曲线图上就是一个"开口向上"的分叉。
不要等到验证精度明显崩了才想起来做措施,看到验证loss连续三个epoch没有跟着训练loss走的时候,就应该动手了。我的处理顺序是:先加大增强强度,再看Dropout和权重衰减,最后才考虑缩小模型或调整结构。那个阶段你会发现,数据增强和正则化手段能解决的问题,远比你想象的多。
5. ImageNet Dogs实战:微调预训练模型的正确姿势
5.1 微调不是"换最后一层然后整个网一起训"
到了ImageNet Dogs,数据量大了,图像质量高了,终于可以放心地用大模型了。这里的常规操作是加载一个在大规模图像数据上预训练好的ResNet50,把最终的分类层从1000类换成120类,然后微调。但微调远不止"改输出维度、整网训"这么简单,我踩过好几次坑才摸到合理的节奏。
错误示范:加载预训练权重后,直接把全网络各层的学习率都设成1e-4开训。结果模型虽然也在收敛,但前期明显偏慢,而且最终精度一般。问题在于:预训练模型浅层学到的通用特征很稳定,用同一学习率对它做无差别更新,容易让已经被打磨好的特征慢慢退化。
5.2 两阶段微调流程与分层学习率
我最终跑通的流程分两阶段。第一阶段冻结整个骨干网络,只训练新换上的120类分类头,用1e-3的学习率跑5到10个epoch。这个阶段的目的是把新分类头初始化好,让模型先能做基础的类别区分。
第二阶段有选择地解冻骨干网络的后半段。以ResNet50为例,我会把layer3和layer4解冻,保持layer1、layer2以及stem冻结,然后用整个模型联合训练。
# 第一阶段:只训练新分类头 for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True # 第二阶段:解冻后两个残差阶段 for name, param in model.named_parameters(): if name.startswith("layer3") or name.startswith("layer4"): param.requires_grad = True特征层的语义层次决定了这个策略为什么有效:浅层卷积核学习的是边缘、纹理这些几乎跨数据集通用的模式,没必要动;深层卷积核学到的是更抽象的部件级语义,比如"眼睛的形状""毛发的质感",这些特征需要根据新任务的类别调整。所以浅层保持冻结、深层做适应,学习率也要区别对待——我用的是骨干部分1e-5、分类头1e-4的分层设置。
这里有个对比:如果从零训练同样的ResNet50,用普通设备可能要几天才能看到一个像样的结果;而用微调路线,一块普通显卡在合理预算内就能跑出一个能上线的模型。数据量越大的公开任务,迁移学习的优势体现得越明。通过这个项目,我才真正理解为什么计算机视觉领域把预训练模型当作基础设施来用——它几乎省掉了所有从零开始的前期磨合成本。
5.3 难例分析:混淆矩阵是最有用的调试工具
模型跑完测试集之后,很多人只看一个准确率数字就收工了。我会建议你多花半小时去看混淆矩阵——把预测结果按真实类别汇总成一个矩阵,哪两个类互相认错,一眼就能看出来。
在ImageNet Dogs上,最典型的误判往往出现在外形接近的品种之间,比如两种雪橇犬、两种牧羊犬。这些难例反映的不是模型能力不足,而是类间视觉差异确实太微小。针对这类问题,可以做的事情有三个方向:第一,给容易混淆的类别做更强的定向增强,比如更多的随机裁剪,让模型看到这些狗的更多局部形态;第二,检查那些类别在训练集里的样本数量,如果某类图像本身少得可怜,精度上不去就不是模型的问题而是数据问题;第三,考虑用更强大的骨干网络替换ResNet50,比如更大规模的预训练模型,容量越大,区分相近类别的能力通常越强。
提示:混淆矩阵放在普通的分类任务里是一个容易被忽略、却特别有用的工具。它比验证集平均准确率更能反映模型的真实短板,我在推荐系统里排查召回不准的品类时也用过类似思路,按"真实类别 x 预测类别"做交叉统计,很多定位问题都比盲目调参更快。
5.4 犬种任务里的数据增强细节
ImageNet Dogs的图像尺度比CIFAR10大不少,增强手段可以放开手脚。我用的组合是:随机缩放裁剪——每次从原图随机裁出一块大小和位置变化的区域再缩放到224×224,加上随机水平翻转和适度的颜色抖动。
随机缩放裁剪有个额外的好处:它让模型必须学会在不同尺度的局部信息里做判断。犬种识别经常依赖的是脸型、耳朵形状、毛发纹理这样的局部特征,随机裁剪等于在强制模型多看这些关键部位,不正好和细粒度分类的难点对齐了吗。这比死死盯住整图的增强策略要好用得多。
6. 三个任务跑完之后:沉淀出一个通用的图片分类决策模板
6.1 拿到新数据集先问五个问题
三个项目做完,最大的收获是一套可复用的判断顺序。现在再拿到任何一个图片分类任务,我不会急着打开编辑器写网络结构,而是先问自己五个问题:数据总量大概多少?类别数有多少?图片分辨率处于什么水平?类别之间的视觉差异是明显还是细微?手里的算力大概在哪个档次?
这五个问题对应五种完全不同的技术决策。数据量少、类别差异细微,就选预训练模型加特征抽取,重点防过拟合;数据量足够但分辨率很低,就选轻量网络从零训练加强力增强;数据量足够、分辨率也高,就放心地做预训练迁移学习,该微调微调、该替换骨干替换骨干。
6.2 一张路线选择表直接抄作业
我把三个月前自己零基础开始做的决策过程,汇总成下面这张表。以后你拿到新任务,照着它判断就行:
| 场景画像 | 推荐路线 | 主要风险 | 优先手段 |
|---|---|---|---|
| 样本少、类别多、类间差异小 | 特征抽取 + MLP分类头 | 过拟合 | 冻结骨干特征、MLP加Dropout |
| 样本足、分辨率低、类别粗 | 从零训练轻量CNN | 信息量不足 | 强数据增强、优化器切换 |
| 样本足、类别多、图像质量高 | 预训练骨干微调 | 浅层特征被破坏 | 分层学习率、两阶段训练 |
| 任意场景但有明显类间混淆 | 难例分析驱动迭代 | 单一指标欺骗 | 混淆矩阵定向补短板 |
对号入座之后,剩下的就是标准动作:读图预处理、归一化、设置好数据加载器、设计或加载模型、选优化器和损失函数、跑训练循环、记录训练曲线。这一套模板跑熟了,我建议你把它抽成一个脚本,以后换数据集只需要改类别数、图像尺寸、增强策略三个参数,骨架代码完全复用。我目前正在把这一步落实到自己的练习仓库里,这也是标题里那个TODO还在继续的原因。
6.3 从三个项目里提炼出的三条通用教训
第一条:先防过拟合,再谈模型改。图片分类任务里,不管数据多还是少,增强和正则化都是性价比最高的投资。训练曲线出现分叉的第一时间,永远是先加增强,而不是换模型。
第二条:模型容量要和输入规模匹配。32×32的图上跑超深网络、用一棵树的样本却想微调全量骨干网络,都是典型的容量不匹配。大不是什么坏事,但"合适"永远比"高级"重要。
第三条:所有的技巧本质上都在做同一件事——特征提取。预训练模型是把你已经知道的特征复用过来,卷积层堆叠是让网络自己学习特征,数据增强则是让特征对无关变化不敏感。想明白这一点,CV里的很多概念放在推荐系统里照样能对上号,后面我如果再学到跨领域内容,也会用这个视角去理解。
我个人在实际操作中的体会是:这三个数据集最大的价值并不是刷出多高的准确率,而是让我在短时间里密集经历了几种完全不同的"数据困境"。树叶分类教会我敬畏数据量,CIFAR10教会我信任管线,ImageNet Dogs教会我利用已有知识。下一步我打算把之前推荐系统学习路线里的Embedding和这里学到的特征提取放在一起做个对照,跑通之后再来更新这个系列。