简介:针对小样本学习场景,这份基于PyTorch的开源项目完整实现了Prototypical Networks(原型网络),源自arXiv 1703.05175论文,代码覆盖从数据加载、批次采样、模型推理到损失更新的全流程,适合正在入门元学习、少样本分类的研究者或工程师参考。包内共12个文件,其中7个Python脚本构成核心代码,分别负责模型结构定义、原型损失计算、批采样策略、Omniglot数据集加载、命令行参数解析与训练主流程;另含2张示意图、README说明与LICENSE许可,压缩包仅135KB,体积小巧、目录层级清晰,便于逐模块阅读和二次开发。目前已有920人学习下载。借助该项目可以快速在Omniglot上跑通few-shot实验,理解类原型表征与度量学习的实现细节;也能以train.py为主入口,调整参数、替换数据或扩展损失函数,搭建自己的小样本学习基线方案,适合作为论文复现和课程设计的起点。
1. 小样本分类的经典解法:Prototypical Networks 用“类代表”完成 5-shot 识别
假设你现在手里只有一个新类别的 5 张照片,传统分类网络会当场失效:softmax 输出层是针对训练时见过的类别定死的,新类进来既没有对应神经元,也没有足够数据重训。Prototypical Networks(原型网络)就是 few-shot learning 里绕不开的经典方案:它把每个类别压缩成一个「原型向量」,新的输入图像在嵌入空间里跟所有原型比距离,离谁近就判给谁,整个推理过程不需要为新类做任何梯度更新。这个 PyTorch 项目正是该方法的完整实现,从 episode 采样、卷积 backbone、原型计算到训练评估一应俱全,拿到手就能在 Omniglot、miniImageNet 这类小样本基准上复现 5-way 1-shot 和 5-way 5-shot 的实验结果。适合两类人:正在做小样本图像分类课题的学生,以及刚入手 few-shot 想找一个干净参照实现的 Python 开发。
2. 项目全景与技术选型:一个 episode 从数据采样到 loss 的完整链路
打开这类仓库之前,建议先理解 few-shot 训练跟普通图像分类的本质区别。普通分类每个 batch 从所有类别里均匀采样图片,模型最终输出一个长度为「总类别数」的向量;few-shot 则把每一次训练变成一个「小任务」,每个小任务里只有 N 个类别、每类 K 张支撑图片,模型要在这个临时任务上学会区分。这个「小任务」就是 episode,整个项目的一切代码都在围绕它转。
2.1 仓库结构:每个文件在 few-shot 流程里的位置
我拆到手的仓库不大,核心代码集中在 src 目录,入口脚本放在 scripts 下,典型的组织方式如下:
. ├── data/ │ ├── omniglot/ # 手写字符数据集原始文件 │ └── mini_imagenet/ # miniImageNet 按类目录整理 ├── src/ │ ├── model.py # PrototypicalNetwork 与 backbone │ ├── dataset.py # 数据集读取、episode 采样器 │ ├── trainer.py # 单 episode 训练逻辑 │ └── utils.py # 参数解析、seed 固定、日志 ├── scripts/ │ ├── train.py # 训练入口 │ └── evaluate.py # 评估入口 ├── checkpoints/ # 训练产出的模型权重 └── README.md各文件的职责和阅读顺序,可以用一张表说清楚:
| 文件 | 职责 | 建议阅读顺序 |
|---|---|---|
| src/model.py | 卷积嵌入网络、原型计算、距离打分 | 1 |
| src/dataset.py | 图片加载、support/query 划分、EpisodeSampler | 2 |
| src/trainer.py | 损失计算、反向传播、指标统计 | 3 |
| scripts/train.py | 命令行参数、数据加载、训练主循环 | 4 |
| scripts/evaluate.py | 恢复 checkpoint、固定 seed、跑 N 个 episode | 5 |
先从 model.py 读起是对的,因为原型网络的核心只有「嵌入 + 距离」两个概念,模型文件通常不超过 100 行;dataset.py 反而是最容易出 bug 的地方,support 和 query 的索引一旦错位,训练曲线会非常难看。
2.2 技术选型拆解:PyTorch、episodic 训练和欧氏距离
这个项目选 PyTorch 是有原因的。episode 采样本质上要求「每一轮重新组合一批图片」,数据集类的__getitem__返回的应该是一个索引,而不是固定图片,这种动态图风格在 PyTorch 里写起来最顺手。调试时你可以在训练中途随手打印 logits 的张量形状和数值范围,不需要像静态图时代那样把整个计算图提前编译好。
backbone 通常选 4 层卷积,每层 64 个 3×3 卷积核,配 BatchNorm、ReLU 和 2×2 最大池化。小样本任务的训练数据本来就少,几十层的 ResNet 在这类任务上很容易过拟合,四层 CNN 参数少、收敛快,在 Omniglot 这种 28×28 的小图上已经能压到论文同档水平。输入通道数通过参数控制,跑 Omniglot 传1,跑 miniImageNet 传3。
距离度量默认是欧氏距离的平方,这也不是随手选的。原型网络有一个理论保证:当嵌入空间的距离度量是欧氏距离时,类内样本的均值向量就是该类别的最优原型表示。一旦你换成余弦距离或者马氏距离,「类均值 = 最优原型」这个性质就不成立了,效果需要重新调。这也是为什么很多教程劝你别一上来就改距离度量,我会在最后一章专门讲怎么改才稳。
2.3 episode 采样逻辑:support 和 query 是怎么切出来的
dataset.py 里最核心的组件是 EpisodeSampler,它的作用就是每轮从训练集中动态抽出一个任务。我见过的大部分实现,核心逻辑都长这样:
class EpisodeSampler: def __init__(self, labels, n_way, k_shot, query_shot): self.labels = torch.tensor(labels) self.class_ids = torch.unique(self.labels) self.n_way = n_way self.k_shot = k_shot self.query_shot = query_shot def sample(self): chosen = self.class_ids[torch.randperm(len(self.class_ids))[:self.n_way]] support_ids, query_ids = [], [] for c in chosen: idx = torch.where(self.labels == c)[0] perm = idx[torch.randperm(len(idx))] support_ids.append(perm[:self.k_shot]) query_ids.append(perm[self.k_shot:self.k_shot + self.query_shot]) return torch.cat(support_ids), torch.cat(query_ids)这段代码做了三件事:先从全部类别里随机选n_way个类,再在每个类内部随机打乱样本索引,最后把前k_shot个分给 support、接下来query_shot个分给 query。注意 support 和 query 来自同一个类别集合但不重叠,这样才能保证「用 K 张样本学到区分」这个任务成立。
实际使用时有一个硬约束:数据集中每个类别至少要有k_shot + query_shot张图片,否则索引会越界。Omniglot 每类至少有 20 张样本,所以 5-shot 配 15 个 query 没问题;你自己收集的数据集如果每类样本不足 20 张,就得把 query_shot 相应调小,或者做数据增强扩量。
3. 核心模块拆解:从 image tensor 到原型向量再到 logits
这一章是整个仓库的发动机。原型网络的前向过程可以拆成三步:嵌入函数把图片映射到特征空间,每个类的 support 特征求均值得到原型,query 特征和所有原型算负平方欧氏距离得到 logits。走完这三步,剩下的就是交叉熵损失和梯度更新。
3.1 嵌入网络:四层卷积块搭起来的特征提取器
model.py 里先定义了一个可复用的卷积块,然后把它堆四次形成嵌入网络:
import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.net = nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) def forward(self, x): return self.net(x) class EmbeddingNet(nn.Module): def __init__(self, in_ch=1, hidden=64): super().__init__() self.encoder = nn.Sequential( ConvBlock(in_ch, hidden), ConvBlock(hidden, hidden), ConvBlock(hidden, hidden), ConvBlock(hidden, hidden), ) def forward(self, x): return self.encoder(x)这里每个 ConvBlock 都是「卷积 + BN + ReLU + 池化」四件套。卷积用 3×3、padding=1 保证尺寸不缩,真正缩小分辨率的是 MaxPool2d(2),每层把宽高减半。BN 的作用在小样本场景里比较微妙:它对每个 batch 统计均值方差,episode 内样本数量少时统计量噪声大,后面避坑章我会专门讲这个问题。
参数说明:in_ch是输入图像的通道数,Omniglot 灰度图传 1,miniImageNet 彩色图传 3;hidden是每层卷积的输出通道数,常用 64,显存紧张可以降到 32,效果会略掉。28×28 的输入经过四层池化后会变成 1×1 特征图,这其实是好事,后续处理非常清爽;84×84 的输入会得到 5×5 的特征图,处理方式稍有不同。
3.2 原型计算与距离打分:类均值加负平方欧氏距离
模型的主干逻辑在 PrototypicalNetwork 里,support 和 query 共用同一个嵌入网络,然后分别处理:
import torch import torch.nn.functional as F class PrototypicalNetwork(nn.Module): def __init__(self, in_ch=1, hidden=64): super().__init__() self.embedding = EmbeddingNet(in_ch, hidden) def forward(self, support, query, n_way, k_shot): support_feat = self.embedding(support) query_feat = self.embedding(query) # 原型 = 每类的 support 特征求均值 support_feat = support_feat.view(n_way, k_shot, -1) prototypes = support_feat.mean(dim=1) # query 特征展平,和原型做欧氏距离 query_feat = query_feat.view(query.size(0), -1) dists = torch.cdist(query_feat, prototypes, p=2) ** 2 return -distsview(n_way, k_shot, -1)这一步的含义是把每个类的k_shot个样本特征叠在一起,最后的-1是嵌入维度,在 28×28 输入下就是 64。mean(dim=1)对第 1 维求均值,得到(n_way, 64)形状的原型矩阵。对于 84×84 输入,特征图是(B, 64, 5, 5),view(-1)会直接展平成 1600 维,相当于把空间位置也当成特征;更省参数的做法是先接一个自适应平均池化压到 64 维,两种写法在这个仓库里都见过。
torch.cdist(query_feat, prototypes, p=2)计算的是欧氏距离,再平方是因为论文里的距离项是-||x - c||^2。合起来,代码返回的 logits 形状是(query_total, n_way),每一行表示这个 query 样本和 N 个类原型的相似度打分。距离越小,负值越小,对应的类概率越大。
3.3 训练逻辑:负对数似然当作 loss,一次 episode 一次更新
原型网络没有单独的分类头,也不需要在 episode 内做任何梯度更新,所以训练循环非常短:
import torch.nn.functional as F def train_one_episode(net, optimizer, support, query, query_labels, n_way, k_shot): net.train() logits = net(support, query, n_way, k_shot) loss = F.cross_entropy(logits, query_labels) acc = (logits.argmax(dim=1) == query_labels).float().mean().item() optimizer.zero_grad() loss.backward() optimizer.step() return loss.item(), acc这里的query_labels是一个长度为query_total的索引数组,取值范围是0到n_way - 1,表示每个 query 样本在本次 episode 中属于第几个类。F.cross_entropy内部自带 softmax,所以前面返回-dists直接传进来即可,不需要再手动做归一化。
值得注意的细节是梯度流向。loss 对 logits 求梯度,logits 又由 query_feat 和 prototypes 共同决定,而 prototypes 来自 support_feat 的均值,因此梯度会同时流过 query 分支和 support 分支。也就是说,support 图像虽然没有直接参与损失计算,但它们的原型参与了梯度反传,嵌入网络照样能从 support 侧学到表征。
训练脚本里还需要在每轮 episode 结束后打印 loss 和 acc。刚开始训练时 acc 应该在 1/n_way 附近,比如 5-way 任务随机猜就是 20%,随着训练进行缓慢爬升。如果 loss 一上来就卡在某个奇怪数值上不动,优先检查 logits 的量级,正常应该在 0 附近取负,绝对值不超过十几。
4. 训练与评估:在 Omniglot 和 miniImageNet 上跑出实验数字
模型代码看懂了,接下来就是把训练跑起来。这个仓库的入口都在 scripts 目录下,数据准备、训练参数、评估方式三条线分开,互不干扰。第一次跑强烈建议先用 Omniglot 验证环境,成功后再上 miniImageNet。
4.1 数据准备:两类数据集的预处理差异
Omniglot 是一个手写字符数据集,每张图是灰度图,原始尺寸 105×84,常见做法是缩放到 28×28 并做四方向旋转增强(0、90、180、270 度),相当于把数据量扩大四倍。数据划分要严格使用官方自带的 background 和 evaluation 分割,训练类、验证类、测试类互不重叠,否则评估结果没有说服力。
miniImageNet 是 ImageNet 的一个子集,100 个类别共 6 万张图片,常用划分是 64 类训练、16 类验证、20 类测试。图像是彩色图,常见做法是缩放到 84×84,训练时加随机裁剪和水平翻转增强,验证时不加。预处理上的差异对结果影响非常明显,很多复现数字对不上都是卡在归一化方式和 crop 细节上。
准备好数据后,训练命令长这样:
python scripts/train.py \ --dataset omniglot \ --n-way 5 \ --k-shot 5 \ --query-shot 15 \ --train-episodes 30000 \ --val-episodes 200 \ --optimizer adam \ --lr 0.001参数说明:--n-way 5表示每个 episode 里有 5 个类别,这是 few-shot 里最常用的配置;--k-shot 5表示每类给 5 张支撑图片,如果想做 1-shot 实验改成 1 即可;--query-shot 15表示每类还要额外抽出 15 张查询图片用来计算损失。--train-episodes就是训练总轮数,小样本任务的「epoch」概念很弱,一般直接用 episode 数来控制训练量。
4.2 训练参数配置:不同数据集下的常用组合
我把两类数据集上常见的参数组合整理成一张表,方便对照:
| 参数 | Omniglot 常用值 | miniImageNet 常用值 |
|---|---|---|
| n_way | 5 | 5 |
| k_shot | 1 / 5 | 1 / 5 |
| query_shot | 15 | 15 |
| 输入尺寸 | 28×28 灰度 | 84×84 彩色 |
| 训练 episode 数 | 10000 ~ 30000 | 20000 ~ 60000 |
| 优化器 | Adam,lr 1e-3 | SGD momentum 0.9,lr 0.1 衰减 |
| 批量 episode 数 | 32 或 64 | 32 或 64 |
Omniglot 任务相对简单,Adam 配 1e-3 足够快到收敛;miniImageNet 更难,很多复现会用 SGD 配上学习率衰减,效果比 Adam 更稳。注意「批量 episode 数」跟前面的n_way无关,它表示一次梯度更新同时跑多少个 episode,通常取 32,太大会让每个 step 变慢,太小则 BN 统计量不稳定。
训练过程里我习惯每 1000 个 episode 打印一次 loss 和 acc,同时把模型 checkpoint 存到 checkpoints 目录。第一次跑建议开一个 10000 episode 的小规模实验,确认曲线正常后再放长时间,不然发现配置错了,前面几小时全白跑。
4.3 评估脚本:准确率之外一定要看置信区间
评估阶段和训练阶段最大的区别是:评估要固定 seed,并且跑很多个 episode 取平均。few-shot 的单次 episode 结果方差非常大,可能这次抽到的任务全是容易区分的类,准确率 90% 以上;下次抽到长得相似的大类,准确率直接掉到 50%。因此评估命令里通常有一个--eval-episodes参数:
python scripts/evaluate.py \ --checkpoint checkpoints/omniglot_5way5shot.tar \ --dataset omniglot \ --n-way 5 \ --k-shot 5 \ --eval-episodes 1000 \ --seed 2024评估脚本的核心逻辑是遍历指定数量的 episode,累加每次预测正确的样本数,最后除以总查询样本数得到平均准确率。数据集图片少的时候,可以每类重复多抽几个 query,保证分母足够大。跑完 1000 个 episode 后,我一般会顺手算一下 95% 置信区间:
import math def confidence_interval(acc, n_episodes): return 1.96 * math.sqrt(acc * (1.0 - acc) / n_episodes)n_episodes越大,区间越窄。100 个 episode 下区间可能在 ±5% 到 ±8%,1000 个 episode 能把区间压到 ±1% 左右。评测报告里如果只写一个准确率不给区间,基本等于没写,因为你无法判断两个模型之间的差异到底是真实差距还是采样波动。评估时不要用训练时的随机状态,固定 seed 到 2024 这类值,结果才可复现。
5. 避坑指南:训练不收敛与评估方差大的常见问题排查
这里集中写几条我在复现这类仓库时实际踩过的坑,全部按照「现象 → 原因 → 解决」拆开。前面 2.3 节的view和索引逻辑,还有 3.3 节的标签对齐,是最容易出错的两个地方,下面都有对应记录。
5.1 训练期:loss 不降,卡在某个固定数值附近
现象:训练刚开始,loss 稳定在 1.6 附近(5-way 任务的随机交叉熵就是 ln 5 ≈ 1.609),跑了很久也不下降,或者偶尔下降一点又弹回去。
原因:先判断是不是标签错位。query_labels 如果用了数据集的原始类别编号,比如 0 到 99,而 logits 只有 5 列,F.cross_entropy不会报错,但梯度方向完全错误,loss 会一直停在一个诡异值。其次是学习率问题,Adam 配 1e-3 跑 Omniglot 一般没问题,但如果你换了更大的 backbone 或更深的网络,梯度量级变大,1e-3 可能导致震荡。
解决:先打印 logits 的形状和数值范围,确认是(query_total, n_way),且数值在 -10 到 0 之间;然后检查query_labels是不是 0 到 n_way - 1 的索引。两者都没问题就把学习率降到 1e-4 试跑 1000 个 episode,曲线仍不降再回头看数据预处理。
5.2 训练期:精度突然掉到谷底,恢复得很慢
现象:训练进行到中段,acc 从 0.95 突然掉到 0.2,之后要花很长时间才能爬回来,甚至直接崩掉。
原因:最常见的是 BatchNorm 在小 episode 上统计量不稳定。训练进入中后期,单 batch 的均值方差抖动变大,BN 的 running stats 被带偏,紧接着整个网络输出漂移。另外学习率过大时,某个 batch 的梯度会一步把参数推出正常区域。
解决:如果用的是 32 个 episode 一起算梯度,先把 batch 提到 64 增大 BN 统计样本数;或者干脆把 BN 换成 GroupNorm,小样本任务中 GroupNorm 往往比 BN 稳很多,代价是收敛速度略慢。模型权重如果保存过中段 checkpoint,可以回滚到掉点之前的版本,调小学习率再继续。
5.3 评估期:验证准确率正常,测试集准确率却惨不忍睹
现象:验证时 acc 稳定在 98% 左右,换到测试类以后只剩 60%,看起来模型完全不泛化。
原因:类划分泄漏,这是 few-shot 实验里最隐蔽的翻车点。如果数据划分时随机把所有类混在一起分训练和测试,同一个字符集的训练类可能来自同一批手写风格,测试类跟训练类过于相似,验证阶段自然准确;真正面对没见过的字符集时,风格差异一上来,立刻打回原形。
解决:用数据集的官方背景划分。Omniglot 直接用 background 和 evaluation 两批数据,背景字符集用来训练,评估字符集用来测试;miniImageNet 用标准的 64/16/20 划分。切数据之前先算一遍训练类和测试类的交集,确保为 0,这个检查我每次都会做。
5.4 评估期:置信区间 ±8%,完全没法比较模型好坏
现象:两个模型跑评估,一个 acc 0.62,另一个 0.64,但置信区间算出 ±0.08,无法得出结论。
原因:评估的 episode 数太少。few-shot 单个任务方差本来就大,如果只跑 50 个 episode,采样波动完全盖过了模型差异。这算是评估设计的问题,不是模型问题。
解决:把--eval-episodes提到至少 1000。如果数据集本身的类不够多,可以降低 query_shot 并增加重复采样的轮数。评估时固定 seed,让两个模型面对完全相同的任务序列,这样比较才有意义。另外,同时记录每个 episode 的 acc 分布,而不只是平均,能更早发现问题。
5.5 玄学:同一份代码,别人 0.99,自己只有 0.5
现象:跟着教程复现同样的配置,别人的屏幕上是漂亮的收敛曲线,自己这边 acc 一直在 50% 左右晃动,代码看起来一模一样。
原因:这种「玄学」问题九成出在预处理上。比如 Omniglot 是否做了旋转增强,像素归一化是除以 255 还是用 0.5 均值,图片 resize 是 28×28 还是 32×32,这些细节不对,嵌入空间整体漂移,距离度量全乱。
解决:对比实现时不要只看模型代码,把数据 transform 逐行对一遍。打印输入 tensor 的 shape、dtype、数值范围,很多代码块里默认输入是 float 0-1 区间,如果你的输入是 uint8,BN 和卷积的统计量会完全不对。从那以后我每次复现小样本项目,第一件事永远是先用一小批数据跑通 forward,打印中间张量,确认数值量级合理再上完整训练,这个习惯能省掉大量调参时间,希望帮到你。
6. 二次开发:替换 backbone、距离度量与数据集的三板斧
仓库跑通只是第一步,大部分开发者拿这份代码是为了改造到自己的场景。三个最常动的点:换特征提取网络、换距离度量、换自定义数据集。前两个都属于模型层改动,第三个属于数据层改动。
6.1 把四层 CNN 换成 ResNet 嵌入
小模型跑通后,想提升表征能力,常见的做法是把 EmbeddingNet 换成预训练 ResNet:
import torch.nn as nn import torchvision.models as models class ResNetPrototype(nn.Module): def __init__(self, backbone="resnet18", embedding_dim=128): super().__init__() base = models.resnet18(weights=None) base.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) self.encoder = nn.Sequential(*list(base.children())[:-1]) self.head = nn.Linear(512, embedding_dim) def forward(self, x): feat = self.encoder(x).flatten(1) return self.head(feat)注意两个细节:第一,替换 backbone 后,输入的图像尺寸最好提到 224×224,否则 ResNet 前几层的下采样会把小尺寸特征压没;第二,预训练权重对同分布数据有帮助,但如果你的数据分布和预训练域差太远,加载预训练权重反而会拖慢收敛。
6.2 把欧氏距离换成余弦相似度
有些任务下,特征向量的长度包含无用信息,用余弦相似度更合理。替换方式很直接:
import torch.nn.functional as F def cosine_logits(query_feat, prototypes, temperature=10.0): q = F.normalize(query_feat, dim=1) p = F.normalize(prototypes, dim=1) return torch.mm(q, p.t()) * temperaturenormalize把向量长度归一为 1,点积等价于余弦相似度。后面的temperature很重要,归一化后点积最大值不超过 1,直接做 logits 会让 softmax 太平、梯度消失,乘以 10 把数值范围拉回来。换度量后必须重新调参,建议固定 backbone、seed 和 episode 数,先跑 5-way 1-shot 对比欧氏和余弦的收敛曲线。
验证改造是否有效,最直接的方法是同一份评估脚本分别跑改造前后的 checkpoint,比较 1000 个 episode 的均值与置信区间。如果新度量掉点,先检查 temperature 在 5 到 20 之间扫一遍,再看有没有必要对特征做 L2 归一化前的缩放。我自己踩过很多次 cosine 掉点,最后都是 temperature 没调对。希望这些改造思路能让你在这个仓库上少走几步弯路。
本文还有配套的精品资源,点击获取