最近为了给手里的小样本图像分类任务找一个稳定可复用的 baseline,我把几篇经典论文重新翻出来读了一遍。翻到这篇《Improved Few-Shot Visual Classification》的时候,心里其实有点复杂:它表面上只做了很简单的改动,但在我自己的实验里,它比很多设计精巧的元学习算法都要稳。这篇笔记就来聊聊它到底改了什么、为什么简单的方法反而效果好,以及我复现时踩过的一些坑。
论文的核心思路一句话就能说清:先用基类数据预训练一个普通的图像分类模型,然后在测试阶段把新类别的支持集特征抽出来,直接训练一个逻辑回归分类器做预测。听起来平平无奇,但它通过几个关键细节(特征中心化、L2 归一化、可学习的温度缩放)把这条极简管线做到了当时的最优水平。不管你是刚开始接触 few-shot 分类,还是已经在调各种复杂模型,这篇笔记都值得看看。
1. 这篇论文想解决的问题:被高估的复杂方法
1.1 小样本分类的设定和难点
先梳理一下任务定义。小样本图像分类(Few-Shot Visual Classification)最常见的设定是 N-way K-shot:训练阶段只能接触到基类数据,测试时从新类别里随机挑出 N 个类,每类只给 K 张标注图像作为支持集,然后让模型去判断一批查询图像的类别。K 通常很小,常见的是 1 或 5。也就是说,模型必须在每类只有一两张图的情况下学会区分新类别。
这个问题的难点并不完全在于“样本少”本身,而是基类和新类别之间存在明显的分布差异。模型在基类上预训练时见过的都是日常物体,到了新类别可能变成稀有动物、医学影像、卫星图等完全不同的数据域。如果模型提取的特征不够鲁棒,或者分类器对新类别过于敏感,性能就会迅速下滑。更麻烦的是,常规的有监督训练在每类只有一张图时几乎无法进行——直接把模型拿去做微调,大概率会严重过拟合。
1.2 元学习与度量学习的常用套路
当时主流的 few-shot 方法大致分成两条路线。一条是元学习路线,代表方法有 MAML 这类基于梯度优化的算法:它们在大量 episode 上训练模型,让模型学会“如何用几步梯度更新来适应新任务”。另一条是度量学习路线,代表方法有 Matching Networks、Prototypical Networks 等:它们学习一个特征空间,让同类样本距离更近、异类样本距离更远,推理时直接计算查询样本与支持集原型的相似度。
这两类方法在论文里都报出了不错的数字,但实际复现起来很头疼。MAML 需要展开二阶梯度,计算开销大,而且对训练的超参数特别敏感;度量学习相对轻一些,但对特征提取器的初始化质量要求很高。更关键的是,这些方法经常在某个数据集上有效,换一个数据集或者换一种 backbone 就崩了。我见过很多新手在刚接触 few-shot 时,第一反应就是跑一个复杂的元学习模型,结果折腾了两周还没有简单的线性分类器效果好。
1.3 一个反直觉的观察:复杂方法并不总是更好
这篇论文给我的最大冲击,就是它用非常直白的实验证明了一个反直觉的事实:一个在基类上预训练好的特征提取器,固定住以后只需要在支持集上训练一个简单的分类器,就能打败大多数复杂的元学习和度量学习方法。这个结论在当时的社区里其实已经有人提过,但很少有论文像这样正儿八经地做系统分析,并且把几个关键的操作拆开来验证它们各自的作用。
为什么会有这种现象?我个人的理解是:元学习方法的目标是让模型“适应新任务”,但它们往往是在合成的 episode 上训练,而这种合成任务和真实的新类别分布还是有差距。相比之下,预训练特征提取器利用了基类上的大量监督信号,学到的特征泛化性更强。一旦特征本身足够好,后面接什么分类器反而没那么关键。
2. 核心方法:预训练特征 + 逻辑回归的极简管线
2.1 整体三阶段流程
整条管线可以拆成三个阶段来看。
第一阶段是在基类数据上用标准的交叉熵损失训练一个深度网络,这个网络和常规图像分类模型没有本质区别,可以是 ResNet、WideResNet 或者 ResNet-12。训练完成后,把最后一层分类头丢掉,只用前面的卷积部分作为特征提取器。
第二阶段是特征提取阶段。对测试时遇到的每个 N-way K-shot episode,先把支持集图像输入到固定的特征提取器里,得到一批特征向量。由于支持集样本非常少,这时候不能再去动 backbone 的参数,只能在特征层面做文章。
第三阶段是基于支持集特征训练分类器。论文里的选择是逻辑回归(Logistic Regression),在深度学习语境下就等价于一个紧贴在特征之后的线性分类层。训练完成之后,把查询集图像也通过同一个特征提取器得到特征,送入分类器得到预测结果。
这里有一个容易被忽略的点:在支持集非常小的情况下,逻辑回归比直接使用余弦相似度或者原型网络更稳定。原因是线性分类器会同时学习每个类别的决策边界,而不是只依赖类别的原型向量。当 K=1 时原型向量几乎就是样本本身,而线性分类器还能通过正则化控制边界的位置。
2.2 两个关键改进:特征中心化与 L2 归一化
如果只是简单地“预训练 + 逻辑回归”,效果虽然不差,但还不够惊艳。论文里真正有价值的地方在于对特征做的预处理。
第一个是特征中心化(Centering)。具体做法是计算支持集所有特征在每个维度上的均值,然后用这个均值去减掉所有特征(包括查询集特征)。为什么会有效?因为预训练模型的特征空间中往往存在一个整体偏移,不同类别共享一个较大的背景成分。如果不做中心化,逻辑回归需要额外学习一个偏置项来抵消这个偏移,等于白白消耗一部分模型容量。我在实际实验里观察到,做不做中心化对最终结果的影响能达到 1-2 个点,尤其是当支持集样本很少的时候,这个操作带来的收益会更明显。
第二个是 L2 归一化。这一步是把每个特征向量的模长缩放到 1,让分类器只关注特征的方向而忽略模长。特征模长能反映什么?它通常和样本的“信心”相关,也就是模型对这个特征有多确认。但在小样本场景下,模长并不总是可靠信号,把模长去掉反而能让分类器更专注于类别之间的角度差异。论文里的做法是先中心化再做 L2 归一化,这个顺序很重要:如果先归一化再中心化,等于已经丢失了支持集的整体均值信息,中心化就不彻底了。
2.3 可学习的温度系数与分类器偏置
预处理之外,论文里还有一个不那么起眼但非常关键的细节:温度系数(Temperature Scaling)。
在大规模预训练分类模型里,分类头的输出通常是一个通过全连接层得到的 logits 向量。如果直接在支持集上训练一个逻辑回归分类器,输出 logits 的尺度可能过大或过小,导致 softmax 后的概率分布过于尖锐或者过于平缓。解决办法是在 logits 上乘一个可学习的缩放系数,也就是温度。温度值会在基类预训练阶段和分类器一起学习,也可以在小样本分类阶段根据支持集做一个快速调整。
我自己的感受是,温度缩放这个小东西经常被忽略,但它能带来很稳定的提升。它本质上是给分类器一个“调节自信程度”的旋钮,让模型在面对不同分布的数据时能够自动调整输出的置信度。如果去掉这一步,逻辑回归在 1-shot 场景下输出的概率往往会过于极端,准确率会掉几个点。
3. 训练与推理细节:复现时需要抠清楚的细节
3.1 基类分类头的设计:用 softmax 还是 cosine?
基类预训练阶段的细节直接决定了后续 few-shot 性能的上限。论文里的做法是相对标准的:先用普通 softmax 分类头在基类上训练,等到训练完成后再把分类头换掉。这个阶段有几个超参数需要认真调,包括初始学习率、学习率衰减策略和权重衰减系数。我复现时用的学习率是 0.1,配合余弦退火或者阶梯式下降都行,训练轮数一般在 90 到 120 之间。
这里有一个经验之谈:预训练阶段的数据增强不要过于激进。小样本场景下,我们希望特征提取器能保留更多关于目标本身的判别性信息,而不是过度关注背景或者颜色。常用的增强策略是随机裁剪和水平翻转就够了,不要加入过于复杂的光照扰动。我试过加入更强的增强,发现基类分类准确率确实上升了,但在新类别上的 few-shot 准确率反而下降了,这其实就是典型的过拟合到训练数据分布了。
3.2 episode 采样逻辑
在评估阶段,每个 episode 的采样方式也会影响最终数字。标准做法是从新类别集合中随机挑出 N 个类,每类随机选 K 个样本作为支持集,再从这些类中选取若干个样本作为查询集。这里的 N 通常是 5,K 是 1 或 5。
采样时要注意一个坑:如果某个类别的样本数量很少,必须保证支持集和查询集不重叠。很多刚接触这个领域的人会在实验里不小心把同一张图既放在支持集又放进查询集,导致准确率虚高。另外,查询集的大小也会影响实验的稳定性。我一般每类取 15 到 16 个查询样本,然后跑多次随机采样求平均,常见的做法是跑 600 到 1000 个 episode,这样才能得到一个置信度比较高的平均值。
3.3 推理时的归一化与分类器更新
推理阶段最容易被忽视的细节是:中心化和归一化中所用的统计量,必须只从支持集计算,然后应用到查询集上。很多人会把所有数据混合在一起算均值和方差,这样虽然会降低数值波动,但本质上引入了查询集的标签信息,相当于一个非常隐蔽的泄漏,会让结果虚高。
逻辑回归分类器的训练也有讲究。如果直接用 PyTorch 写一个线性层,用梯度下降训练,需要注意迭代轮数和学习率。支持集只有几十个样本,训练时间太长会导致分类器过拟合,时间太短又不收敛。我更推荐用现成的优化库,比如 scikit-learn 里的 LogisticRegression,它对这类小型线性分类问题有非常成熟的正则化和优化策略。只需要把特征传进去,设置一个合适的 C 值(正则化强度),通常 1.0 左右就是一个合理的起点。
下面这段伪代码展示了核心流程,我自己复现时基本就是照这个套路来的:
import torch import torch.nn.functional as F from sklearn.linear_model import LogisticRegression # features: 从固定 backbone 中提取的特征 # support_feats: [n_way * k_shot, d] # support_labels: [n_way * k_shot] # query_feats: [n_query, d] # Step 1: 中心化(使用支持集均值) mean_feat = support_feats.mean(dim=0) support_feats = support_feats - mean_feat query_feats = query_feats - mean_feat # Step 2: L2 归一化 support_feats = F.normalize(support_feats, dim=1) query_feats = F.normalize(query_feats, dim=1) # Step 3: 训练逻辑回归分类器 clf = LogisticRegression(C=1.0, max_iter=1000) clf.fit(support_feats, support_labels) # Step 4: 推理 logits = clf.decision_function(query_feats) probs = logits / temperature # temperature 可预先设定或学习得到 preds = probs.argmax(dim=1)这段流程看起来简单,但每一步的顺序和参数都不能随意改。尤其是中心化和归一化的顺序,一旦搞反了,效果会明显变差。
3.4 数据增强的作用
数据增强是一个被 few-shot 领域长期低估的因素。很多论文在对比实验时并没有把数据增强当作一个公平的变量,导致结果出现偏差。这篇论文给我的启发是,在基类预训练阶段,数据增强主要影响的是特征的质量,而在 episode 推理阶段,对支持集做额外的增强反而可能带来负面效果。
我在实验中试过在推理阶段对支持集图像做随机裁剪和多尺度变化,然后通过不同的增强版本提取特征再取平均。这样做确实能小幅提升稳定性和准确率,但计算成本上升了很多,而且对不同的数据集效果差异很大。如果你只是想快速搭一个基线,建议先别在推理阶段引入太多花哨的增强。
4. 实验数据与我的复现记录
4.1 论文中几个基准的表现
论文里报告了几组实验,主要是在当时常用的标准基准上做的,包括 miniImageNet、tieredImageNet 和 CUB 数据集。按照我记得的结果,在 miniImageNet 的 5-way 1-shot 设置下,这条 baseline 已经超过了当时很多复杂的元学习算法;在 5-way 5-shot 设置下优势也依然存在。
我没有办法准确回忆起每一个数字,因为当时读论文时刚接触这个领域,注意力全放在方法本身了。但我记得几个定性的结论:1-shot 下复杂模型的优势会被大幅削弱,而 5-shot 下各类模型的差距在缩小。这说明当支持集样本数增加时,分类器本身的建模能力变得更重要,而特征提取器的差异逐渐成为主导因素。
4.2 我自己跑出来的结果
我在一个比较小的数据集上做了一次粗略复现,使用的是 ResNet-12 作为 backbone,先在基类上预训练,然后按照上面的流程做 few-shot 评估。印象里在 5-way 5-shot 设置下,准确率能到 70% 多,1-shot 大概在 60% 上下。虽然和论文里报的数字有一定差距,但如果把 backbone 换成更大的 ResNet-18 或者 WideResNet,性能还能再涨一些。
复现过程中最明显的感受是:这套管线对代码实现的正确性比对超参数更敏感。只要中心化、归一化和分类器训练这几步做对了,哪怕是超参数不是最优,也能得到一个相当可用的结果。反而是那些复杂的元学习算法,稍微有一点实现偏差就会导致几十分的差距。
4.3 一个有趣的观察:backbone 能力决定上限
我在实验里额外做了一个小对比:在同样的流程下,换用不同容量的 backbone,观察 1-shot 和 5-shot 的准确率变化。结果很直观——backbone 越强,few-shot 结果越好,而且提升的幅度在 5-shot 下更大。这说明这条 baseline 的上限主要由特征提取器的质量决定,分类器本身的改进空间是比较有限的。
这个观察也解释了为什么后来很多工作都集中在“如何在基类预训练阶段学到更适合小样本迁移的特征”,而不是花大力气设计分类器。如果你也在调 few-shot 模型,我建议先把关注点放在 backbone 和预训练损失上,不要一上来就在分类器上做文章。
5. 常见误区与踩坑清单
5.1 误区一:把逻辑回归写成内积分类
很容易犯的错误是:理解了逻辑回归之后,直接用一个没有偏置的线性层去跟特征做内积,然后当成逻辑回归。但逻辑回归通常包含偏置项,而且在小型支持集上,偏置项对分类边界的影响不能忽略。如果你自己用nn.Linear实现,一定要保留 bias。如果你用 scikit-learn 的 LogisticRegression,默认设置里是带偏置的,这个细节不需要额外处理。
5.2 误区二:忘了对查询集特征做同样的变换
中心化和 L2 归一化是最容易在代码里忘记的部分。有些人只对支持集做了预处理,查询集直接送入分类器,结果特征分布不一致,准确率会掉得非常夸张。还有人在做中心化时用了所有数据(包括查询集)的均值,这个会引入信息泄漏,结果虚高但无法落地。正确的做法一定是:从支持集计算出均值,然后同时应用到支持集和查询集上。
5.3 误区三:在少样本场景下过度调参
小样本场景下,因为支持集样本很少,验证结果本身就有很大的随机性。如果在这个基础上反复调参,很容易把超参数调到“过拟合到当前随机种子”的状态。我自己就吃过这个亏:为了在某个固定 seed 上提高一个点,花了很多时间调整 C 值和 temperature,结果换一个 seed 或者换一个数据集,性能立刻崩回去。
所以我的建议是:先用默认参数跑一遍,确认代码和流程没问题,再去做超参数搜索;搜索时要在多个随机种子、多个 episode 上评估,而不是只看一个固定设置下的结果。
5.4 踩坑记录:随机种子与 batch 大小
最后记录两个容易出现波动的地方。一是随机种子,miniImageNet 这类数据集上不同 seed 之间很容易差 1 到 2 个点,所以在对比实验时必须固定相同的采样逻辑和 seed 集合。二是 batch size,虽然这里逻辑回归是在特征层面训练,不涉及 backbone 的梯度,但基类预训练阶段如果 batch size 太小,特征质量会不稳定,间接影响后续 few-shot 性能。
6. 读完之后的一些思考
6.1 这篇论文对后续 few-shot 研究的影响
回过头看,这篇论文对后续研究最大的贡献不是某个具体技巧,而是让社区重新意识到简单 baseline 的重要性。在它之后,越来越多的论文把“预训练特征 + 简单分类器”作为必须对比的下界,只有超过这个下界的方法才被认为有真正的增量价值。这对整个领域来说是件好事,因为它过滤掉了一批在复杂方法上堆砌却没有实际收益的工作。
我自己现在做 few-shot 相关的实验时,也习惯先搭一个类似的极简 pipeline,然后在这个基础上逐步增加复杂度。这样做的好处是每一步的改动都能被清晰地量化,不会出现“模型整体换了一套后说不清到底是哪个模块起作用”的尴尬局面。
6.2 它留给我们的开放问题
这篇论文当然不是终点。它用实验证明了简单方法很有效,但并没有彻底解释为什么简单方法有效、复杂的元学习方法在哪类问题上真正有优势。我个人比较关注两个开放问题:一是如何在预训练阶段设计损失函数,让特征天生就更适合 few-shot 迁移;二是如何在不引入大量额外计算的前提下,让简单分类器和数据增强更好地结合。
如果只是从实用角度出发,我建议每个人在第一次接触 few-shot 任务时,都先花一两天时间把这条极简管线完整复现一遍。它不仅能给你带来一个可靠的 baseline,还能让你对特征分布、分类器行为和评估流程有更直观的理解。这种理解,是直接套用现成复杂算法难以获得的。
最后再分享一个小技巧:在复现这类论文时,别急着追求和原文一模一样的准确率。先把代码流程跑通,确认趋势一致(比如 5-shot 明显好于 1-shot、更大的 backbone 效果更好),再逐步对齐实验细节。这一步走稳了,后面做任何扩展都会顺畅很多。