系列更到第十六篇,照理说还应该在推荐系统里打转,可这个标题看起来明显“串台”了——从推荐系统直接跳到计算机视觉,还一口气列了Kaggle树叶分类、Kaggle Cifar10、ImageNet Dogs三个图片分类任务,最后挂着一个TODO。其实我一直觉得,深度学习学的不是某一个领域的模型,而是建模思路本身。推荐系统处理的是稀疏离散特征,CV处理的是像素矩阵,可训练循环、迁移学习、正则化这些底层套路是完全共用的。这篇笔记就对应动手学深度学习里CNN那几章的实战环节,用三个经典任务把CNN图片分类从数据准备到提交结果完整过一遍。适合刚跑完推荐模型想接触CV的读者,也适合那些想把分类流程规范化、不想停留在“看教程”阶段的同学。
1. 三个任务到底在学什么
1.1 树叶分类:小数据细粒度入门
Kaggle上的树叶分类竞赛,官方数据量并不大,训练集只有一千来张图片,类别却有99个,平均下来每个类别可能就十张上下。对任何模型来说,这都是一个“饿肚子”级别的训练集。更麻烦的是,不同树种的叶片形状高度相似,有的区别只在叶缘锯齿、叶脉走向、叶柄长度这些细节上,属于典型的细粒度图像分类问题。
选这个任务入门,我觉得非常合适。数据小意味着迭代快,一次训练可能只需要几分钟,你可以一天内尝试很多种数据增强组合、多个模型结构,迅速建立起“哪些操作有效、哪些操作没用”的判断力。很多初学者总想找个大数据集才肯动手,其实反而忽略了练手阶段最重要的东西:对过拟合的敏感度。树叶分类因为样本太少,过拟合几乎一定会出现,你逼着自己去处理它,比在大数据集上跑一次完整流程学到的东西多得多。
这个任务的提交格式也很友好,只需要预测每个测试图片属于哪一个物种,写成一个两列的CSV交上去就行。对第一次接触Kaggle流程的人来说,不容易被复杂的评估协议劝退。
1.2 CIFAR-10:经典小图分类
CIFAR-10是计算机视觉里历史最悠久、使用最广泛的入门数据集之一。6万张32×32的彩色小图,10个类别,每个类别6000张,训练集和测试集按5比1划分。类别跨度很大,飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车,基本是粗粒度识别,任务本身对人工设计特征来说很难,但对现代CNN来说已经比较温和。
为什么练完树叶分类还要做CIFAR-10?因为这两个任务的“难点配方”完全不同。树叶分类难在数据量少和类别细分,CIFAR-10难在图片分辨率太低——32×32的图,一只猫的轮廓可能只占十几个像素,模型压根看不到多少纹理细节。在这个数据集上,数据增强的作用会被放大得非常明显。我实测下来,同样的ResNet18,不做增强的验证准确率可能只有80%出头,做了随机裁剪加水平翻转后能轻松上到88%以上,这就是接近8个点的差距。
还有一点值得提:CIFAR-10训练一个中等规模的模型只需要十几分钟,非常适合做各种对比实验。你想验证某个想法对模型效果的影响,在这里跑最快。这也是很多论文拿它当基准数据集的原因。
1.3 ImageNet Dogs:迁移学习实战
ImageNet Dogs是ImageNet大规模分类任务的一个子集,把原始数据中所有犬种类别单独拎出来,凑成120种狗的细粒度分类。数据量有上万张,但依然比完整ImageNet少一个数量级。犬种之间差异极小,比如柴犬和秋田犬,普通人肉眼都经常分不清,同一犬种不同拍摄姿态、角度、光照下的差异甚至可能大于不同犬种之间的差异。
这个任务的真正价值在于“迁移学习”。ImageNet上的预训练模型在1000类上已经学到了海量的通用视觉特征,从纹理、边缘到物体部件,这些特征对狗的分类也完全适用。我们要做的不是从零开始训练一个网络,而是拿着预训练权重,只把最后分类头换成120类的版本,然后对整个网络做微调。这是目前工业界和竞赛里最主流、最稳妥的图片分类做法,也是动手学深度学习系列里特别强调的一种实战能力。
把三个任务放在一起看,正好覆盖了图片分类的三类典型场景:样本极少的细粒度分类、中等规模的经典分类、以及大规模预训练加微调的分类。我个人的学习顺序也是从树叶开始,然后CIFAR-10,最后ImageNet Dogs,每个任务做起来都有新鲜感,不会觉得是重复劳动。
2. 环境准备与数据处理细节
2.1 Kaggle数据获取与组织
Kaggle的数据下载,最推荐的方式是用官方命令行工具。直接通过浏览器手动下载也可以,但命令行方式更干净,还能方便地重复下载不同版本。需要说明的是,这一部分主要是基于我自己的常用做法,具体的网络环境、账号配置每个人不太一样,照着流程来基本不会出问题。
先在命令行安装kaggle客户端,然后把从Kaggle个人账号页面下载的kaggle.json文件放到用户主目录下的.kaggle文件夹里。这个文件包含了API访问凭证。有些系统会自动把文件放在下载目录里,记得手动移动。之后每次下载竞赛数据,只需要进到对应竞赛页面,复制命令执行即可,命令类似这样的形式:
pip install kaggle mkdir -p ~/.kaggle mv kaggle.json ~/.kaggle/ chmod 600 ~/.kaggle/kaggle.json kaggle competitions download -c leaf-classification kaggle competitions download -c cifar-10解压数据后,建议按下面的目录结构重新组织,虽然手动整理有点繁琐,但后面写代码时会省很多事:
data/ leaf_classification/ train/ image_1.jpg image_2.jpg test/ ... train.csv test.csv cifar10/ train/ test/ dogs/ train/ n02085620/ n02085782/ ... test/为什么要手动整理成ImageFolder格式?因为PyTorch的torchvision.datasets.ImageFolder可以直接按目录结构读取类别标签,省去自己写数据集类的麻烦。树叶分类的标签在train.csv里,图片文件名和类别要手动映射;CIFAR-10自带标签文件;ImageNet Dogs的原始目录名就是类别ID。三个任务的数据格式各不相同,提前统一组织好,后面的训练代码就能复用。
2.2 数据划分:训练集比验证集更重要
竞赛数据一般只给train和test,但你不能拿train全部去训练,因为这样就没有办法评估模型在未知数据上的表现。正确的做法是再划分出一个验证集。为什么要留验证集?因为竞赛的测试集是有提交次数限制的,你不可能每次调参都靠提交去试,验证集就是你的“离线测试集”,用来模拟模型在未知数据上的泛化能力。
树叶分类数据量太少,直接随机划分会导致某些稀有类别在验证集里可能一个样本都没有,所以我采用了分层抽样,按类别比例划分,保证每一个类别都有样本进入验证集。这一步在sklearn里很容易实现:
from sklearn.model_selection import train_test_split df_train, df_valid = train_test_split( df_labeled, test_size=0.15, stratify=df_labeled["species"], random_state=42 )对CIFAR-10这种自带标准train/test分界的数据集,我一般直接把他的train目录再划分出一部分做验证集。常见做法是每类取5000张训练、1000张验证,保持了类别均衡。ImageNet Dogs样本量相对大一些,直接随机划分10%作为验证集即可。
划分时固定随机种子这个习惯很重要。不固定种子的话,每次运行代码验证集都不一样,你很难判断模型效果的提升到底是来自调参还是来自数据划分的随机波动。
2.3 图片增强:动手学深度学习的隐藏重点
图片增强几乎是图片分类任务里最重要的一个环节,它的核心思想是:通过对原始图片做随机的、不影响类别语义的变换,人为制造出更多的训练样本。对深度学习来说,这相当于一种正则化手段,能有效抑制过拟合。
但增强也不是越多越好,每个任务都有合适的“度”。树叶分类这种白底叶片的特写图片,叶片方向是任意的,所以随机旋转特别有效;但颜色抖动就要很温和,因为叶片颜色本身就是物种分类的重要依据,动太多反而会破坏关键信息。CIFAR-10是32×32的小图,经典做法是随机裁剪4像素的填充边缘再加水平翻转,我实测过还能再加上随机擦除,效果更好。ImageNet Dogs则要模拟ImageNet训练时的数据分布,随机缩放裁剪加水平翻转就够了,颜色增强不能过头,否则狗毛的颜色信息会被破坏。
验证集和测试集千万不能加随机增强,只需要缩放和中心裁剪。经常有同学训练集效果好但验证集效果差,排查半天发现是验证集也做了随机翻转,导致评估不稳定。
3. CNN核心原理与模型选型
3.1 卷积层为什么能处理图片
很多人一听到卷积就觉得高深,其实拆开看没那么玄乎。图片本质上是一个像素矩阵,每个像素点有RGB三个通道值。全连接层会把所有像素展开成一长串向量,每个像素当作独立特征,但这样做忽略了图片最重要的性质——相邻像素之间有局部相关性。卷积核本质上是一个很小的权重矩阵,比如3×3,它在图片上滑动,每个位置计算一次局部加权求和,生成一张新的特征图。
可以这么理解:卷积核就像一个小型放大镜,每次只看一小块区域,提取这个区域里的某种特征,然后滑动到下一个位置。如果这个区域出现了符合卷积核权重的模式,输出值就比较高。同一个卷积核在整个图片所有位置使用同一套权重,这就是参数共享,也是CNN参数量远小于全连接网络的原因。
一个卷积核只能检测一种特征,所以卷积层通常会有很多个卷积核,每个学习不同的模式。浅层卷积核学的是边缘、颜色块,中层学的是纹理、部件形状,深层学的是物体级别的语义信息。这也是为什么后面做迁移学习时,前面层的特征通用性特别强,稍微做微调就能适配新任务。
代码实现上,PyTorch里构建一个卷积层非常简单:
import torch.nn as nn conv_layer = nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3, padding=1)输入是3通道RGB图,输出是64个特征图,每个特征图对应一个卷积核的响应结果。要注意padding参数,它决定了输出特征图的尺寸,padding=1配合kernel_size=3可以让输出尺寸和输入保持一致。
3.2 池化、Dropout与批量归一化
卷积层负责提取特征,池化层负责压缩。最大池化就是取一个区域内的最大值,可以理解为保留局部区域内最强烈的特征响应。它一边降低特征图分辨率,一边扩大模型的感受野,同时带来一定的平移不变性——图片里物体稍微挪动几个像素,池化结果基本不受影响。
Dropout在训练时随机丢弃一部分神经元,让模型不依赖某些特定节点的输出,本质上是一种集成思想。PyTorch里的Dropout会区分训练和评估模式,训练时随机丢弃,评估时自动使用完整网络并缩放权重,所以不需要自己处理。
批量归一化(BatchNorm)是我个人非常喜欢的一个模块。它在每个批次内对激活值做标准化,让数据分布保持稳定,从而可以允许更大的学习率,加速收敛,还能缓解梯度消失。但它有一个使用陷阱:batch_size如果太小,BN统计的均值和方差就不稳定,训练效果会明显变差。所以我在小数据集上也不会把batch_size降得太低。
池化、Dropout、BN这些技术,做推荐系统的同学其实都不陌生。特征交叉网络里也大量使用Dropout,训练大规模Embedding时也会用到类似BN的归一化技巧。学CNN最大的收获之一,反而是把这些通用组件的原理彻底搞清楚。
3.3 选ResNet还是自己搭一个CNN
动手学深度学习系列里有一节是手写一个简单CNN做图片分类,我觉得很有必要照着写一遍。从两个卷积层加一个全连接层开始,自己实现训练循环,看着loss一点点下降,你会对网络的容量、过拟合、训练速度这些概念建立起直观感受。但如果是想参加竞赛或者解决实际问题,手写的简单CNN几乎肯定打不过预训练的ResNet。
ResNet厉害的地方在残差连接。传统的深层网络容易出现一个奇怪的现象:层数增加后,训练误差反而变高,这叫“退化问题”。理论上更深的网络表达能力应该更强,但优化起来反而更难。残差连接让每一层学习的是输入和输出之间的“残差”,相当于给梯度提供了一条高速通道,深层网络也能稳定训练。
我的建议是两件事分开做:理解原理时,手写一个小CNN跑CIFAR-10;实战出结果时,直接加载预训练的ResNet18。ResNet18是性价比很高的选择,GPU内存占用不大,训练速度快,准确率又足够作为baseline。ResNet50或EfficientNet更强,但训练时间和显存占用都会上一个台阶,等你把完整流程跑通之后再去折腾也不迟。
3.4 预训练权重的正确使用姿势
预训练权重是深度学习实战里最值得依赖的“外挂”。ImageNet-1k模型被训练过海量图片,学到了通用视觉特征,这些特征对绝大多数图片任务都有效。哪怕你的任务和ImageNet完全不相关,用预训练权重初始化也比从零开始训练好得多。
PyTorch里模型加载预训练权重很简单:
import torchvision.models as models model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) num_ftrs = model.fc.in_features model.fc = nn.Linear(num_ftrs, num_classes)这里的关键步骤是替换最后一层全连接。ImageNet的输出是1000类,你的任务可能是99类或10类或120类,最后那个全连接层必须换成对应输出维度的新层。新层的权重是随机初始化的,前面层则保留了预训练权重。
微调有两种主流策略。数据量特别小时,先冻结特征提取层,只训练分类头,可以避免预训练特征被破坏;数据量充分时,可以全网络微调,但学习率要调低,不然预训练权重会被打乱。我个人实际经验是,全网络微调配上低学习率,效果通常比冻结特征层好。如果在叶子分类这种数据极少的任务上担心过拟合,可以先冻结训练几个epoch,观察loss稳定下降后再解冻全部参数。
还有一个经常踩的坑:模型训练完要提交预测时,必须调用model.eval()切到评估模式。不然BatchNorm和Dropout还停留在训练状态,预测结果会不稳定,同一张图片跑两次结果都不一样。
4. 完整训练流程实录
4.1 数据加载的实现细节
数据加载这部分,三个任务各有各的坑。树叶分类的标签是单独一个CSV文件,图片文件名在文件里对应。ImageFolder读取不了这种结构,需要写一个自定义Dataset类,在__init__里把CSV读取进来,建立一个文件名到类别的映射。CIFAR-10最省事,直接用torchvision.datasets.CIFAR10就行。ImageNet Dogs和树叶分类类似,但它的目录结构天然符合ImageFolder规则。
自定义Dataset的核心在于__getitem__方法,它根据索引读取图片和标签,返回经过增强后的张量。这里要注意图片读取后一定要转换为RGB模式,否则有些图片是灰度图会导致通道数不匹配。另一个细节是增强是在加载时实时做的,所以同一个epoch内每次读取同一张图片都会得到不同的增强版本,这正是数据增强想要的效果。
DataLoader的num_workers参数也值得说一下。它控制着数据预加载的进程数,设成0表示在主进程里加载,速度慢;设成4或8表示额外启动多个进程并行加载,可以大幅减少GPU等待数据的时间。在Windows上,如果num_workers大于0,代码需要用ifname== "main"保护多进程入口,不然会报错。
训练时DataLoader要设置shuffle=True,让每个epoch的样本顺序打乱,避免模型记住固定的序列顺序。验证集和测试集设置shuffle=False,保持评估结果的稳定可比。
4.2 训练循环与关键参数选择
一个标准的训练循环流程是这样的:前向传播计算输出和损失,反向传播计算梯度,优化器更新参数,每隔一定步数计算验证集准确率。代码骨架如下:
model.train() for epoch in range(num_epochs): running_loss = 0.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()优化器选择上,Adam和SGD是两大主流。Adam收敛快、对学习率不敏感,适合快速跑通流程;SGD加上动量项在小数据集上泛化能力通常更好,但需要更仔细地调学习率。我习惯先用Adam跑通,得出baseline后再换成SGD进行精细调优。
学习率是影响训练效果最敏感的参数。微调预训练模型时,全连接层是全新随机初始化的,可以给稍大一点的学习率,比如1e-3;预训练的特征层则用1e-4到3e-4,避免破坏已经学好的特征。从零训练时初始学习率可以设成1e-2,配合余弦退火调度器,在训练后期慢慢降低学习率,有助于收敛到更好的局部最优。
训练过程中我还会用混合精度加速。PyTorch的自动混合精度训练很简单,只需要把损失计算放到autocast上下文里,再用GradScaler缩放梯度,显存占用能降低不少,训练速度也有明显提升,尤其在老旧一点的GPU上效果更加明显。
4.3 从预测到提交CSV
模型训练完成后,要对测试集做预测并生成提交文件。首先确保模型处于评估模式,然后逐个批次把测试图片传入模型,得到每张图片的类别概率分布。
树叶分类的提交格式是两列CSV,第一列是图片ID,第二列是预测的物种名称。最容易出错的地方在这里:模型输出的是类别索引,你需要拿到索引到类别名的映射关系,才能在提交文件里写对名称。ImageFolder的classes属性保存了这个映射,但要注意它按字母序排列,和你CSV里的顺序可能不一致,必须显式对齐。
ImageNet Dogs的提交格式则有些不同,需要输出每张图片属于全部120类的概率矩阵,这就要用到softmax输出,而不是简单的argmax。CIFAR-10竞赛提交的是每个样本的预测类别编号。
我个人的一个习惯是:预测结果先存成.npy文件,再做提交文件的组装。这样后面想尝试TTA、集成等后处理技巧时,不需要重新跑一遍模型预测,直接加载保存的概率结果即可,能省不少时间。
5. 三个任务的针对性优化
5.1 树叶分类:类别不平衡处理
树叶分类99个类别的样本量很不均衡,一些常见树种的样本可能有好几十张,稀有树种只给几张图。这种极度不平衡让普通的训练方式很难照顾好每一个类别。
我尝试过类别重采样,就是让稀有类别在一个epoch内被重复多次,但效果并不理想。因为整个数据集本身只有一千来张,重采样本质上是把同样的样本反复喂给模型,很容易在稀有类别上过拟合。真正有效的是MixUp和CutMix这类进阶增强手段。MixUp把两张图片按比例混合,标签也按比例混合;CutMix把一张图片的一块区域挖掉,贴上另一张图片的内容。这两种方法相当于在原始数据的基础上持续生成新样本,对于细粒度小数据集非常有效。
另外,评估时除了整体准确率,我还会单独计算稀有类别的准确率。竞赛指标只看整体准确率,你不能为了照顾少数类别牺牲大多数类别的表现。但如果某个稀有类别的准确率异常高而多数类别偏低,说明可能过拟合了。
5.2 CIFAR-10的数据增强实验
CIFAR-10是验证数据增强想法的最佳试验场。模型训练时间短,可以快速跑多组对比实验。经典增强组合是随机裁剪加水平翻转,随机裁剪的具体做法是padding=4把32×32的图扩展到40×40,再随机裁回32×32,相当于给图片增加了平移扰动。
在此基础上,我进一步加入了随机擦除(Cutout):在图片上随机遮蔽一块小区域,强迫模型学会利用周围上下文信息,而不是依赖某一小块的强特征。实验证明对CIFAR-10能带来1到2个点的提升。
一个值得注意的经验是:CIFAR-10的32×32小图,BatchNorm对batch_size的变化更敏感。我一台机器上测试过batch_size从64降到16,同样的增强配置下准确率掉了将近3个点。所以在这种模型快、数据小的任务里,不要为了省显存把batch_size压得太低。混合精度在这种情况下也能用,反正训练很快,多试几种组合成本也不高。
5.3 ImageNet Dogs的微调策略
ImageNet Dogs微调是三个任务里最接近真实工业场景的。120种犬种分类,样本量充足但类别差异细微,能否发挥预训练模型的优势,取决于微调策略。
我的训练流程分两步走。第一步,冻结编码器部分的所有参数,只训练新替换的分类头。因为这个分类头是随机初始化的,如果一开始就让它和预训练的特征层一起训练,梯度冲击可能把预训练特征搅乱。先把分类头训到基本收敛,让它在预训练特征上能做出合理预测。第二步,解冻编码器所有参数,用很小的学习率2e-4对整个模型进行全局微调。这一步让特征层的参数根据任务特性做出细微调整,同时因为用了低学习率,不会大范围破坏预训练学到的通用特征。
在增强上,随机缩放裁剪加水平翻转是标配,但我故意没加太多颜色抖动。犬种的毛色和纹理本身就是非常有效的判别特征,过度颜色增强相当于人为抹掉这些关键线索,训练效果反而变差。
评价时TF50(Top-5准确率)比Top-1更能反映模型对犬种的整体区分能力。120个类别,第1预测错误但第2和第3预测正确的情况非常多,这说明模型已经学到了大部分特征,只是类别边界还不够清晰。
6. 踩坑与排查速查表
6.1 效果差的通用排查路径
图片分类任务里,训练效果不理想的时候,正确的排查顺序比急着换模型重要得多。我总结了一套快速定位问题的方法。
训练集loss完全不下降时,先别怀疑模型结构,而是检查数据预处理。图片有没有做标准化?标准化用的是不是ImageNet的均值和方差?标签和图片对应关系有没有错位?这些低级错误最容易出现,也最容易被忽略。其次再看学习率,太大容易震荡不收敛,太小收敛速度极慢。
验证集loss先降后升,这是过拟合的经典信号。方向很明确:增加数据增强强度、加大Dropout、考虑早停、或者干脆换更小的网络。我在树叶分类任务上就遇到过训练准确率99%、验证准确率只有70%的情况,最后是通过MixUp和提前截断(只在验证集最佳点保存模型)解决的。
还有一个容易混淆的情况:训练集和验证集效果都很好,但测试集提交成绩不理想。这时要检查验证集划分是不是和竞赛的测试数据分布不一致。树叶分类尤其容易踩这个坑:训练图片大多是实验室扫描的白底叶片,测试集可能来自不同采集环境,背景、光照、焦距都不一样。这时候需要手动对训练图片做一些模拟测试分布的预处理,比如调整亮度对比度。
6.2 环境相关的问题
GPU显存不足是实战中最常见的问题之一。我通常按优先级依次尝试:减小batch_size、开启混合精度、换更小的模型结构。要注意的是,减小batch_size会影响BN的稳定性,因此一般用混合精度作为优先选择,效果更好。
多卡训练时还有个容易被忽略的坑:DataParallel包装的模型,BN层的行为和单卡稍有不同。梯度累积也是一种解决办法,但要提醒一点,BN的计算仍然是以当前批次样本为准的,累积梯度不会改变BN统计量,所以效果可能不如直接增大batch_size。
Kaggle数据下载失败也经常遇到。确保kaggle.json已经放在了正确的位置,且权限设置为600,不然API会拒绝访问。下载过程中断的话,重新执行一次下载命令通常会自动续传,不用重新下载所有文件。
6.3 后续还能怎么扩展
这三个任务跑完,图片分类的基本功就算打牢了。后续想深入的话,可以从几个方向扩展。一是尝试更高阶的模型结构,比如EfficientNet系列或者ConvNeXt,看能不能在准确率上再更进一步。二是学习目标检测和语义分割,把卷积特征提取应用到更复杂的视觉任务上。三是回到推荐系统,把迁移学习的思想迁移到推荐模型的预训练上——这是我现在正在折腾的方向,思路其实是相通的。
最后分享一点个人经验:真正拉开差距的,往往不是模型选得多新,而是数据处理、数据增强、验证集划分、提交格式这些看起来琐碎的细节。做推荐系统调特征工程时我也有同样的感受。下次遇到任何图片分类问题,先按照“数据划分合理、增强适度、预训练微调、验证集评估”这条路径梳理一遍,基本不会出大错。