如果你刚刚接触图像分类,想找一个“能跑通、能理解、能迁移”的入口,CIFAR10 + ResNet50 这个组合几乎是绕不开的。CIFAR10 是深度学习领域最经典的小图分类数据集,ResNet50 又是残差网络家族里被复用最多的骨干模型,两个搭在一起,既能完整走一遍“数据准备 -> 模型搭建 -> 训练调参 -> 评估分析”的流程,又能真正搞懂残差结构为什么能支撑几十层深的网络。这篇文章就围绕这个组合做详细拆解,覆盖 ResNet50 的残差原理、CIFAR10 数据预处理细节、PyTorch 复现代码、训练调参心得和常见翻车点,适合刚入门的同学,也适合想把分类模型迁移到自己业务数据上的工程师参考。
1. 为什么 CIFAR10 和 ResNet50 能成为“经典中的经典”
1.1 CIFAR10 的数据形态与真实难点
CIFAR10 包含 60000 张 32x32 的彩色图像,一共 10 个类别,每个类别 6000 张。其中 50000 张用于训练,10000 张用于测试。类别包括飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车,可以说覆盖了日常生活中最常见的物体。
32x32 的分辨率很小,很多物体只有几十个像素的尺寸,细节信息非常有限。这意味着模型必须在极其有限的像素里捕捉轮廓、颜色、纹理等特征。这种“小图受限”环境,反而比高清大图更考验模型的特征提取能力。这也是为什么 CIFAR10 长期以来都是验证新模型、新训练技巧的标准测试场,几乎所有经典论文都会在这个数据集上报告一组数字。
另外一个小细节:CIFAR10 的类别样本是打乱的,且存在类别不平衡吗?并没有,每个类恰好 6000 张,非常均衡。所以你在写代码时不需要考虑类别权重那一套,直接拿准确率评估就行,这大大简化了复现的实验设计。
1.2 ResNet50 的江湖地位:够深、够稳、足够通用
ResNet 系列在 2015 年提出时,把神经网络的有效深度从十几层一下子推到了上百层。这个突破的核心是残差学习机制,通过“跳跃连接”让梯度有一条更短的传播路径,从而解决了网络加深带来的退化问题。
ResNet50 是 50 层的残差网络,比 ResNet18/34 更深,但又不至于像 ResNet101/152 那样对算力要求过高。它结构上分为五个阶段:一个 stem 卷积和四个重复堆叠的残差阶段。每个阶段内部使用 Bottleneck 结构,参数量不大但表达能力足够强。ResNet50 在整个视觉领域几乎是“万金油骨干网络”,从目标检测的 Faster R-CNN 到分割模型,甚至不少多模态模型都会用它的变体做视觉特征提取器。
所以,复现 ResNet50 不只是为了跑一个分类 Demo,更重要的是你以后做目标检测、语义分割、自监督学习时,还是要面对它。把它的结构、训练方式、调参手感提前吃透,后续工作的效率会高很多。
1.3 这个组合能解决什么问题、适合谁
在 CIFAR10 上训练一个从零初始化、没有预训练权重的 ResNet50,是一条非常典型的“学习型任务”。它能帮助你理解:
- 数据预处理和增强为什么能提高泛化能力;
- 模型怎么一步步从卷积层中提取特征;
- 训练过程中损失曲线和准确率曲线怎么反馈问题;
- 学习率、权重衰减、BatchNorm 这些组件各自的角色。
同时也适合想迁移到真实业务的工程师。你在实际项目中遇到的图像分类任务,大概率不是 CIFAR10,而是几千张或几万张自己的业务图片。那时候你会用预训练权重微调,而不是从零训练。但微调的代码骨架、评估逻辑、调试思路,和这篇文章里梳理的基本一致。先把这个组合彻底跑通,后面换数据集只是换文件夹路径的问题。
2. 残差到底在解决什么:ResNet50 结构拆解
2.1 深度网络为什么反而“变笨”:退化问题
在没有 ResNet 之前,研究人员发现一个反直觉的现象:在网络深度增加时,训练误差反而会上升。比如一个 20 层的网络和一个 56 层的网络,后者理论上表达能力更强,至少应该能拟合前者的结果,但实际训练却更差。
这不是过拟合导致的,因为过拟合时训练误差应该很低,验证误差才高。而退化问题是训练误差本身就没降下来,说明优化器根本找不到一个有效的解。深层网络在反向传播时,梯度需要经过很多层相乘,非常容易造成梯度消失,导致靠近输入层的参数几乎不更新,学习效率极低。
残差网络对这个问题的处理思路很简单:既然深层难以直接拟合期望的映射,那就把它拆成“恒等映射 + 增量”。网络每一层不需要从头学一个完整函数,只需在输入的基础上修改一点细节,这样即使权重无法学到有效特征,至少能退化成近似恒等映射,保证深层的性能不劣于浅层。
2.2 残差块的数学原理与直观理解
一个残差块可以表达为:
y = F(x, {W_i}) + x
其中 F 是经过两三个卷积层和激活函数的组合变换,x 是输入,最后把 F 的输出和 x 相加,再经过激活函数输出。这里的 x 就是“捷径连接”,也叫 skip connection。
直观理解是:假如某个残差块完全不需要做任何变化,那么 F(x) 直接学成 0 就能让输出等于输入,这比强行学会一个恒等映射要容易得多。可以类比成一个快递员送货,原来的路要求他一步步穿过很多街区,现在给了他一条直达通道,他可以选择继续在街区里干活,也可以偷懒从直达通道通过。网络在训练时更像是“学会了走捷径”,但这条捷径不是偷工减料,它保证了信息和梯度都能顺利流通。
残差块在反向传播时还有一个好处:捷径上的梯度可以直接流向底层,不会经过中间那么多非线性层的衰减。这让几十层网络的训练可行性大大提升。
2.3 ResNet50 的骨架:五阶段结构与 Bottleneck 设计
ResNet50 的具体结构可以用一张表格看清楚:
| 阶段 | 结构 | 输出尺寸 | 层数 |
|---|---|---|---|
| Stem | 7x7 卷积 + 池化 | 32 / 16 | 1 |
| Stage 1 | 3 个 Bottleneck | 56x56 | 9 |
| Stage 2 | 4 个 Bottleneck | 28x28 | 12 |
| Stage 3 | 6 个 Bottleneck | 14x14 | 18 |
| Stage 4 | 3 个 Bottleneck | 7x7 | 9 |
表格里的层数不包含最后的全连接分类层,合计约 50 层,ResNet50 的名字就是这么来的。每个 Bottleneck 结构是 1x1 卷积降维、3x3 卷积提取特征、1x1 卷积升维,比如输入是 256 通道,先通过 1x1 降到 64,经过 3x3 卷积,再通过 1x1 升回 256。这样做的目的是降低中间 3x3 卷积的输入输出通道数,大幅减少参数量和计算量,让网络可以堆到很深。
在 CIFAR10 上复现时还有一个很重要的修改:原版 ResNet50 的 stem 是 7x7 卷积且步长为 2,但 CIFAR10 的输入是 32x32 的小图,如果第一个卷积就用大核大步长,会迅速损失空间信息。常见的做法是把 stem 改成 3x3 卷积、步长 1,或者干脆去掉后面的 MaxPool,把网络的核心残差阶段全部保留。这样模型依然叫 ResNet50,但更贴合小图数据。
3. CIFAR10 的准备:数据加载与预处理里藏着的细节
3.1 下载与目录结构
在 PyTorch 里加载 CIFAR10 非常简单,torchvision 提供了现成的数据集类。第一次运行时会自动下载,下载地址指向官方数据源。常见的问题是网络不稳定导致下载中断,解决办法是手动下载 .tar.gz 文件放到指定目录。
推荐把数据集中管理在一个文件夹下,便于多项目复用。目录结构像这样:
data/ cifar-10-batches-py/下载之后,遍历数据目录可以看到训练批和测试批文件。每个批里包含图像数组和标签。如果只是用 torchvision 接口,这些细节不用手动解析,但了解底层文件结构可以帮助你排查数据加载异常。
3.2 为什么必须做归一化
CIFAR10 图像的像素值范围是 0 到 255,如果直接输入神经网络,数值尺度偏大,不利于梯度下降的稳定性。归一化有两层含义:一是把像素缩放到 0 到 1 或 -1 到 1,二是按通道减去均值、除以标准差,让每个通道的分布接近标准正态。
在 CIFAR10 上,不同通道的像素统计量并不完全一致,所以更精细的做法是每个通道单独归一化。torchvision 官方常用的均值和标准差是:
- 均值:0.4914, 0.4822, 0.4465
- 标准差:0.2023, 0.1994, 0.2010
这组数值怎么来的?对 CIFAR10 训练集的全部像素按 RGB 三个通道分别统计均值和标准差即可。用官方这组数值完全足够,但如果你想用在自己的自建数据集上,就需要自己统计,不能照搬。
归一化应该在数据增强之后进行。先做随机裁剪、翻转等几何变换,再做数值归一化,这是标准处理顺序。有一个很容易踩的坑:测试集也要做归一化,而且必须使用训练集统计出的均值和标准差,不能直接在测试集上重新统计,否则会引入数据泄漏,测试结果虚高。
3.3 数据增强:小图的“免费午餐”
CIFAR10 图像只有 32x32,信息少,如果不做数据增强,ResNet50 很容易过拟合,训练几分钟验证集准确率就停在某个不错但上不去的水平。常用增强组合是随机裁剪和随机水平翻转:
- 先在图像周围填充 4 个像素的黑边,再随机裁剪出 32x32 的区域,相当于给样本引入轻微平移;
- 以 50% 概率水平翻转,增加样本多样性。
这两步合在一起,几乎成为 CIFAR10 训练的标配。很多人问需不需要加旋转、色彩抖动,我的经验是:在 CIFAR10 这种小图上,旋转容易带来语义变化(比如“6”旋转变成“9”这类问题,好在 CIFAR10 没有数字类),色彩抖动收益也不明显。最稳妥的还是裁剪加翻转,配合后面提到的正则化手段。
有一个重要原则:数据增强只作用于训练集,测试集保持不变。这能保证验证指标反映模型在真实分布上的表现,不受随机变换干扰。
3.4 DataLoader 参数设置
DataLoader 里几个参数直接影响训练速度和稳定性:
- batch_size:CIFAR10 常用 64 或 128,硬件允许就用 128;
- shuffle:训练集设为 True,测试集不设;
- num_workers:Windows 上通常设为 0 或 2,Linux 上可设到 4 以上,能缓解数据读取瓶颈预加载 ;
- pin_memory:如果使用 GPU,设为 True 可以加速主机到显存的传输。
一个容易忽略的点:如果机器内存不够,num_workers 过高会导致内存暴涨,训练还没开始就被系统杀掉进程。建议从 2 开始试,逐步上调。
4. PyTorch 复现:从模型搭建到训练循环
4.1 两种搭法:torchvision 现成模型与手写残差块
复现 ResNet50 有两种思路。一种直接用 torchvision.models.resnet50,改最后一层全连接输出为 10 类,代码量最少;另一种是自己写 Bottleneck 和残差阶段,更贴近论文实现逻辑,也更能理解内部结构。
两种方法都值得试。先写一遍手写版本,再用 torchvision 做对照,能发现两者之间的细小差异,比如 torchvision 的实现在第一层卷积上默认使用 7x7 卷积,而在 CIFAR10 上我们要改成 3x3。下面给出一个改造后的手写版本,看起来会比较长,但结构非常清晰。
import torch import torch.nn as nn class Bottleneck(nn.Module): expansion = 4 def __init__(self, in_channels, out_channels, stride=1, downsample=None): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=1, bias=False) self.bn3 = nn.BatchNorm2d(out_channels * self.expansion) self.relu = nn.ReLU(inplace=True) self.downsample = downsample def forward(self, x): identity = x out = self.relu(self.bn1(self.conv1(x))) out = self.relu(self.bn2(self.conv2(out))) out = self.bn3(self.conv3(out)) if self.downsample is not None: identity = self.downsample(x) out += identity out = self.relu(out) return out class ResNet50_CIFAR10(nn.Module): def __init__(self, num_classes=10): super().__init__() # stem 改成小卷积核,步长 1,不接 MaxPool self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) self.layer1 = self._make_layer(64, 64, 3, stride=1) self.layer2 = self._make_layer(256, 128, 4, stride=2) self.layer3 = self._make_layer(512, 256, 6, stride=2) self.layer4 = self._make_layer(1024, 512, 3, stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(2048, num_classes) def _make_layer(self, in_channels, out_channels, blocks, stride): downsample = None if stride != 1 or in_channels != out_channels * 4: downsample = nn.Sequential( nn.Conv2d(in_channels, out_channels * 4, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels * 4), ) layers = [] layers.append(Bottleneck(in_channels, out_channels, stride, downsample)) for _ in range(1, blocks): layers.append(Bottleneck(out_channels * 4, out_channels)) return nn.Sequential(*layers) def forward(self, x): x = self.relu(self.bn1(self.conv1(x))) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.layer4(x) x = self.avgpool(x) x = torch.flatten(x, 1) x = self.fc(x) return x这个版本里,_make_layer 是关键,它负责每个阶段的第一层做下采样并调整通道数,后面几个残差块在通道数匹配的前提下使用恒等捷径。如果你之后想跑 ImageNet 的子集,把 stem 换回 7x7 卷积并加上 MaxPool 就行。
4.2 关键超参数怎么选:学习率、优化器、批大小
用 CIFAR10 从零训练 ResNet50 时,经过大量实验验证,一套很稳的超参数组合是:
- 优化器:SGD,momentum=0.9;
- 基础学习率:0.1;
- 权重衰减:1e-4;
- 批大小:128;
- 训练轮数:50 到 100 轮;
- 学习率策略:在训练总轮数的 1/2 和 3/4 处将学习率除以 10。
为什么不直接用 Adam?Adam 收敛确实快,但在 CIFAR10 这种数据量较小的任务上,SGD 配合余弦退火或阶梯式衰减,最终泛化能力往往更好。这个结论来自实践,也是很多复现实验的首选。如果刚开始跑,Adam 可以更快看到效果,但要做“刷高准确率”的实验,还是回到 SGD。
学习率初始值用 0.1 是相对激进的,ResNet50 较深,BatchNorm 能有效缓解高学习率带来的不稳定。如果发现 loss 在初始阶段剧烈震荡,先检查数据和代码逻辑,之后再把学习率降到 0.01 或 0.03 从新跑。
4.3 训练验证循环:模型模式与梯度关闭要分清
训练循环里最容易出问题的不是网络结构,而是两个小细节:model.train() 和 model.eval() 的切换,以及验证阶段是否用 torch.no_grad()。BatchNorm 层在训练和推理时的行为不同,训练时统计当前 batch 的均值和方差,推理时使用训练过程中保存的滑动均值。如果忘了切 eval,验证指标会一直抖动且不真实。
下面给一个简洁但完整的训练验证循环,结构上可以直接套用到你自己的数据集:
def train_one_epoch(model, train_loader, optimizer, criterion, device): model.train() total_loss = 0.0 correct = 0 total = 0 for inputs, labels in train_loader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * inputs.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total def evaluate(model, test_loader, criterion, device): model.eval() total_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for inputs, labels in test_loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) loss = criterion(outputs, labels) total_loss += loss.item() * inputs.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total每个 epoch 结束后,记录训练损失、训练准确率和测试准确率,同时保存“到目前为止测试准确率最高”的模型。保存方式不推荐每次覆盖同名检查点,最好按 epoch 加准确率命名,便于回溯。
4.4 混合精度训练:让复现跑得更快
如果显存不够或训练太慢,可以开启 AMP 混合精度。核心代码是使用 torch.cuda.amp.autocast 和 GradScaler。对训练脚本的修改很小,但对速度和显存的改善非常明显,尤其在消费级显卡上,CIFAR10 训练时长可以缩短到原来的一半左右。
scaler = torch.cuda.amp.GradScaler() # 训练循环内 with torch.cuda.amp.autocast(): outputs = model(inputs) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()需要注意的是,混合精度下 BatchNorm 层的统计仍以 float32 计算,所以不会带来明显的精度下降。
5. 训练过程中最容易翻车的几个环节
5.1 过拟合是最大敌人:怎样读懂损失曲线
CIFAR10 数据量只有 5 万张训练图,对 ResNet50 这种 2000 多万参数的模型来说,容量明显过剩,如果训练轮数拉得很长,过拟合几乎是必然的。典型表现是训练损失持续下降,训练准确率接近 100%,但测试准确率在某个点之后不再上升甚至回落。
应对方法按优先级排列:
- 数据增强:随机裁剪加翻转是最基础的一层;
- 权重衰减:SGD 的 weight decay 相当于 L2 正则,1e-4 这个量级在 CIFAR10 上效果显著;
- 早停:监控测试准确率,连续多个 epoch 不提升就保存当前最佳模型退出;
- 更激进的增强:如 Cutout 或 MixUp,能进一步提升,但会增加代码复杂度。
我在实际调参时,一般先固定增强和正则,观察前 20 个 epoch 的训练曲线,如果训练准确率很快超过 90%,而测试准确率才 60% 左右,说明模型已经开始记住训练集,这时候不是盲目加数据,而是先检查增强有没有生效,再看权重衰减和学习率是否合理。
5.2 学习率设置不当的典型症状
初始学习率太大,第一个 epoch 的 loss 就可能出现 NaN,或者 loss 一直在高位剧烈震荡,完全不收敛。初始学习率太小,loss 下降非常缓慢,前几个 epoch 几乎看不到明显变化。
从开始就用 0.1 不一定是好选择,尤其是你修改了模型结构后。稳妥的流程是:先用 0.01 跑 10 个 epoch,观察 loss 是否能稳定收敛;如果收敛太慢,下一轮调到 0.1;如果训练集准确率快速到 95% 以上但测试集跟不上,说明学习率偏高,可以配合更早的衰减。
还有一种比较省心的策略:使用 CosineAnnealingLR,让学习率按照余弦曲线周期变化,不需要人为设置衰减节点。对 CIFAR10 复现任务,这个策略也能达到很好的效果,且调参成本低。
5.3 BatchNorm 与 eval 模式切换的坑
网上经常有人问:训练时测试准确率还挺高,一到测试脚本里单独加载模型,准确率突然掉了很多。八成是忘了 model.eval()。PyTorch 的 Dropout 和 BatchNorm 在训练与推理时行为不同,BatchNorm 在 eval 模式下会用全局的 mean 和 var,不再依赖当前 batch。如果你在推理时漏了这行代码,BatchNorm 仍按训练模式计算,数据如果量小而分布波动大,结果就非常不稳定。
另一个关联问题:BatchNorm 在预训练模型微调时,如果调整了 batch size,建议重新打开 BatchNorm 参数的训练,让 BN 层重新估计数据分布,否则测试效果可能下降。这也是为什么很多微调代码会单独设置参数组的 requires_grad。
5.4 训练时间与显存:资源不够也能跑
一个量级参考:单张消费级显卡上跑 CIFAR10 + ResNet50,batch size 128,一个 epoch 大概是 40 到 70 秒,显存占用约 3 到 6 GB。50 个 epoch 下来大概需要 40 到 60 分钟,这个时间成本完全在可控范围内。
如果你的显存只有 2GB 左右,可以缩小 batch size 到 64,同时开启混合精度。再不行就换 ResNet18 先把流程跑通。原理理解了,模型换小只是替换类名的问题。
6. 从复现到迁移:把 ResNet50 用在自己的图像分类任务上
6.1 自定义数据集的接入流程
先解释一个重要的判断标准。CIFAR10 上用随机初始化的 ResNet50 从零训练是有意义的,因为它数据量足够支撑从头学习特征。但在真实业务项目里,数据量很少超过几万张,这时候从零训练很容易过拟合。正确做法是加载 ImageNet 预训练权重,然后进行迁移学习。
使用 torchvision 加载预训练模型并修改分类头的代码非常简单:
import torchvision.models as models model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) num_features = model.fc.in_features model.fc = nn.Linear(num_features, num_classes)如果你的图片尺寸和 ImageNet 接近(比如 224x224),网络结构完全不用改动。如果你的输入是 32x32 这种小图,有两个选择:一是 resize 到 224,使用原版结构;二是修改第一层卷积,去掉池化,但这样预训练权重就部分失效,需要微调时特别小心。
在 PyTorch 中加载自定义图片数据集,最简单的方式是用 ImageFolder:
from torchvision import datasets, transforms data_dir = "./data" train_dataset = datasets.ImageFolder( os.path.join(data_dir, "train"), transform=train_transform ) test_dataset = datasets.ImageFolder( os.path.join(data_dir, "val"), transform=test_transform )目录结构必须按照类别分文件夹,ImageFolder 会自动把文件夹名映射成类别索引。
6.2 预训练权重该冻结还是全量微调
对于数据量不足的情况,常见策略是先把所有参数冻结,只训练最后的全连接层,跑几个 epoch 确定分类头能收敛;然后解冻部分深层阶段,用较小的学习率微调。如果数据量在几千张级别,通常只微调最后两个残差阶段即可。
冻结参数的方式就是把参数的 requires_grad 设为 False,优化器只传入需要更新的参数。这里容易踩的坑是 BatchNorm 层:即使你冻结了卷积层的参数,BN 层仍会在 forward 过程中更新均值方差统计量。如果需要完全“冷冻”模型,要把 BN 也设成 eval 模式,不过这会带来一些训练复杂度。更推荐的方案是只做逐层解冻的微调,让 BN 也参与更新。
6.3 准确率之外还要看什么:混淆矩阵与每个类别的表现
在 CIFAR10 上 90% 的准确率已经不错,但你的真实任务里可能类别分布不均衡,单个准确率指标会掩盖某些类表现很差的问题。这时候要看混淆矩阵和每个类别的精确率、召回率。
用 sklearn 的 confusion_matrix 和 classification_report 就可以快速生成。PyTorch 侧只需要把模型在测试集上的预测结果和真实标签收集成两个数组。
from sklearn.metrics import confusion_matrix, classification_report all_preds = [] all_labels = [] # 推理循环中收集 preds 和 labels # ... print(classification_report(all_labels, all_preds, target_names=class_names)) cm = confusion_matrix(all_labels, all_preds)报告里会显示每个类别的 precision、recall、f1-score。如果某些类别经常混淆,比如“猫”被认成“狗”,可以专门去看那些被误分类的图像样本,分析是不是数据标注噪声、类别间视觉相似度太高,或是数据增强不够多。这一步往往比单纯调学习率更能找到问题根源。
最后再补一点个人体会。CIFAR10 加 ResNet50 这套组合,我前后至少复现过五六次,每次都能发现新的细节。最值得记住的一点:不要用“预训练权重 + 原版 ResNet50”去跑 CIFAR10,因为 ImageNet 预训练模型的 stem 是为 224x224 图像设计的,32x32 的输入会直接矮化输入信息。除非你做了 resize,否则默认加载权重在 CIFAR10 上的表现很可能不升反降。
如果你想在这个基础上继续玩点花样,可以尝试把 ResNet50 的最后一层特征向量抽出来,做 t-SNE 可视化,看看模型学习到的类别聚类情况;也可以把训练好的权重接上一个简单的分类头,测试“冻结特征提取器 + 微调分类器”的迁移效果。整个流程跑通之后,再看任何新模型的工作,都会觉得轻车熟路了。