简介:面向熟悉Python编程的开发者与机器学习研究者,这份PyTorch实现复现了经典少样本学习论文中的原型网络方法。原型网络的核心思路是嵌入空间中计算每个类别的原型向量,再通过距离度量判断查询样本类别,特别适合训练数据不足的图像分类任务。压缩包仅12个文件,以7个Python源码为主体,覆盖数据集加载、批次采样、原型网络模型、损失函数及完整训练脚本,还附带2张结构示意图、说明文档和许可证;整个包仅135KB,代码量精简、结构紧凑,适合快速阅读和二次开发。目前已有920人下载学习。通过该项目,读者可以在Omniglot数据集上直接运行训练,结合实际代码理解少样本学习中的片段式采样机制、原型计算流程以及原型损失函数的实现细节,并在此基础上调整网络结构或替换数据加载方式,为后续研究提供可复现的起点。
1. 少样本学习里的原型网络,为什么 PyTorch 实现值得自己写一遍
假设你手头有一个图像分类服务,某类商品只有五六张标注图,却要在测试批次里把几十张图片分对。常规分类器到这里已经崩了,因为测试类别并未出现在训练阶段。原型网络(Prototypical Networks)的思路是把“分类”推迟到测试时才构造:用每个类别的少量支撑样本在特征空间求平均得到原型,再让查询样本去匹配离它最近的原型。这个标题对应的项目正是少样本学习(Few-shot Learning)里最经典的算法在 PyTorch 下的落地实现。适合想快速跑通一个 baseline 的新手,也适合需要一天内换 backbone、换距离函数做消融的老手;它的核心训练代码量非常小,值得自己从零写一遍。
2. 原型网络的核心设定:支撑集、查询集与距离函数的几何
2.1 先理解 episode:少样本任务不是按 batch 分,而是按“考试”分
常规图像分类里,一次迭代拿一个 batch,类别随机混在一起;少样本学习不是这样,它把一次迭代定义成一个 episode,相当于一次开卷考试。流程是:先从整个训练集里随机抽 N 个类别,再从这 N 个类别里各抽 n_shot 张作为支撑集,另抽 n_query 张作为查询集。模型只能靠支撑集来“临时学习”分类边界,然后对查询集做预测。“5-way 5-shot”意味着每类 5 张图、共 25 张支撑图,典型的每类再抽 15 张查询图。
| 组成 | 采样方式 | 数量示例(5-way 5-shot) | 用途 |
|---|---|---|---|
| 支撑集 | 每个抽样类别取 n_shot 张 | 5×5=25 | 计算原型 |
| 查询集 | 每个抽样类别取 n_query 张 | 5×15=75 | 计算损失、评估预测 |
| 类别池 | 从全类别中随机抽 N 类 | 总类别数 M 中取 5 | 保证每次考试不重样 |
这个设定里最关键的一点是:每个 episode 的“分类边界”都在变,模型见过的不是某个固定分类头的权重,而是“如何利用支撑集信息做判断”的那套特征表示。这也是你读代码前必须对齐的第一个术语,否则后面所有的数据加载逻辑都会看晕。
实际训练时,一个 epoch 通常是几百个 episode。每个 episode 里支撑集和查询集都必须从同一批类别中抽取,但样本不能交叉。如果支撑集里有一张图同时也是查询样本,模型等于开卷考试还拿到了原题答案,训练指标会虚高到几乎没有参考价值。
2.2 一个原型就是特征空间里的类内均值,为什么偏偏用欧氏距离
原型网络最核心的假设是:每个类别在特征空间中都有一个代表性位置,这个位置就叫原型。最常见的做法是把该类所有支撑样本输入编码器,得到特征向量后直接取平均,公式写成:
c_k = (1 / |S_k|) * Σ f_θ(x_i),其中 x_i 属于支撑集中第 k 类样本
给定一个查询样本 x_q,它属于每个类别的得分定义为负的平方欧氏距离:
score(q, k) = -‖f_θ(x_q) - c_k‖²
这里用平方欧氏距离不是随手选的,它有几个实际好处:
- 距离对特征向量的模长敏感。如果两个样本只差一个倍数,余弦相似度认为方向相同就够了,但模长里往往包含“这个样本有多典型”的信息,平方欧氏距离保留了这个信息。
- 原型是类内样本的均值点,均值点和查询点之间的欧氏距离在低样本时比较稳定,不要求特征归一化到单位球面。
- 训练时的梯度形式简单:对原型的梯度就是原型与查询向量差的函数,直接用 PyTorch 反向传播即可。
有同学上来就换余弦相似度,理由是它只看方向、不看尺度,“感觉”更稳。但实践中在常见卷积骨干下,这会明显改变原型的几何语义:类内样本方向相近但模长差异大时,均值向量会被长样本带偏,距离判断反而更不稳定。我的建议是:把余弦当作消融实验对象,不要当作默认配置;真要用余弦,特征得做 L2 归一化,损失函数和距离度量要同步换成对应形式,否则两者自相矛盾。
2.3 训练目标其实是负对数概率,温度系数是最后那个旋钮
有了距离,还得把距离转成分类概率。做法是 softmax:对每个查询样本,先算出它到所有原型的负距离,再做归一化。公式为:
p(y=k | x_q) = exp(-d_k / τ) / Σ_j exp(-d_j / τ)
标准配置取 τ=1.0,d_k 是查询样本到第 k 个原型的平方欧氏距离;训练时对查询集中每个样本计算交叉熵损失并求平均。τ 大时概率分布变平缓,τ 小时概率分布变尖锐。这个参数在训练阶段保持 1.0 就好,真正调整它是在推理阶段,属于“从能跑到好用”的进阶手段,我会在第 6 章单独讲。
还有一个容易被忽略的细节:softmax 的类别数等于当前 episode 的 n_way,而不是整个数据集的类别总数。也就是说,模型每次只需要在 5 个候选类里选一个,任务难度和推理时的设定必须一致。如果你训练时用 10-way,推理时却只给 5 个候选类,性能会有一截明显的差距,这在评估对比时最容易造成误判。
3. 在 PyTorch 里实现原型网络:编码器、原型与距离的向量化
3.1 编码器:四层卷积加全局池化是最不容易翻车的起点
做少样本图像任务时,我习惯从一个小型卷积编码器开始,而不是上来就上预训练大模型。原因有两个。第一,few-shot 任务里特征维度不需要太高,64 维通常就够,太高的维度会让欧氏距离在高维空间里趋于平坦,区分度反而变差;第二,小型编码器在单张显卡上几小时就能跑完几百个 episode 的训练,便于快速验证更高层的决策,比如距离函数、n_way、n_shot 这些参数。
import torch import torch.nn as nn import torch.nn.functional as F class ConvEncoder(nn.Module): def __init__(self, in_channels: int = 3, hidden_dim: int = 64, embedding_dim: int = 64): """ hidden_dim: 前三层卷积的输出通道数 embedding_dim: 最后一层卷积的输出通道数,也是最终特征维度 """ super().__init__() self.conv1 = nn.Conv2d(in_channels, hidden_dim, 3, padding=1) self.bn1 = nn.BatchNorm2d(hidden_dim) self.conv2 = nn.Conv2d(hidden_dim, hidden_dim, 3, padding=1) self.bn2 = nn.BatchNorm2d(hidden_dim) self.conv3 = nn.Conv2d(hidden_dim, hidden_dim, 3, padding=1) self.bn3 = nn.BatchNorm2d(hidden_dim) self.conv4 = nn.Conv2d(hidden_dim, embedding_dim, 3, padding=1) self.bn4 = nn.BatchNorm2d(embedding_dim) def forward(self, x: torch.Tensor) -> torch.Tensor: # x: (batch, 3, H, W),H/W 通常是 32 或 84 for conv, bn in [(self.conv1, self.bn1), (self.conv2, self.bn2), (self.conv3, self.bn3), (self.conv4, self.bn4)]: x = F.relu(bn(conv(x))) x = F.max_pool2d(x, 2) # 全局平均池化,把每个通道的二维激活压成一个实数 return x.mean(dim=[-2, -1]) # (batch, embedding_dim)逻辑说明:每经过一个“卷积 + BN + ReLU + 2x2 最大池化”,特征图尺寸缩小一半;输入 32x32 时,经过四层后为 2x2,再做全局平均得到 64 维向量。这里没有展平后接全连接层,而是用全局平均池化,参数量少很多,特征也更平滑,对少样本场景更友好。
参数说明:
embedding_dim=64是原型网络里常见的特征维度,不是越高越好;- BatchNorm 放在卷积之后、ReLU 之前,对训练稳定性很重要;
- 如果输入分辨率是 84x84,四层池化后变 6x6,同样可用;
- 想要更强编码器时,把
hidden_dim从 64 升到 128,但前提是数据量能撑住,否则会过拟合。
3.2 原型计算与距离计算的 PyTorch 向量化写法
一个常见新手做法是 for 循环逐类算原型,在只有 25 张支撑图时没什么问题,但当 n_way 变大或做批量评估时,循环会成为明显瓶颈。这里直接用一个view和广播机制完成向量化,这也是标题对应项目里最常见的写法:
def compute_prototypes(support_emb: torch.Tensor, n_way: int, n_shot: int) -> torch.Tensor: """ support_emb: (n_way * n_shot, d) 支撑集特征 """ prototypes = support_emb.view(n_way, n_shot, -1).mean(dim=1) return prototypes # (n_way, d) def compute_logits(query_emb: torch.Tensor, prototypes: torch.Tensor, temperature: float = 1.0) -> torch.Tensor: """ query_emb: (n_query_total, d) 所有查询样本特征 prototypes: (n_way, d) 返回 logits: (n_query_total, n_way) """ diff = query_emb.unsqueeze(1) - prototypes.unsqueeze(0) # (n_query_total, n_way, d) squared_dist = (diff ** 2).sum(dim=-1) return -squared_dist / temperature逻辑说明:compute_prototypes先把支撑特征重排成(n_way, n_shot, d),然后沿第 1 维度求平均;compute_logits分别对查询和原型扩展维度,利用广播机制一次性算出所有距离,再取负号得到 logits。这样每个查询样本到所有原型的分数都在一个张量里,后续直接用交叉熵即可。
参数说明:
temperature默认 1.0,推理阶段想提高置信度时可以调小,见第 6 章;- 当
d=64时,n_query * n_way * d的中间张量很小,不用担心内存; - 如果换了 512 维的 ResNet 特征,建议改用
torch.cdist(query_emb, prototypes, p=2.0),它对距离矩阵的计算省内存,但注意它返回的是欧氏距离的平方根,而不是平方距离,梯度行为与手工写法略有差异,用哪个就固定哪个,不要在训练和评估之间混用。
3.3 归一化到底放在哪一层:输入层和特征层要分开讨论
图像输入前通常会做标准化,用训练集的均值和方差把像素值换到零附近,这属于输入层归一化,所有模型都需要。容易踩坑的是特征层归一化:有人习惯在编码器输出后加一个 L2 归一化,也就是把每个特征向量长度变成 1,然后再算平方欧氏距离。这时平方欧氏距离展开为 ‖a‖² + ‖b‖² - 2a·b,因为两项模长都等于 1,距离就退化成了 2 - 2cos,也就是余弦相似度的单调变换。
这个等价关系很容易被忽视。我见过很多人一边在特征层加 L2 归一化,一边继续用上一节里的平方欧氏距离,最后训练出来的模型验证准确率怎么调都上不去。几何语义已经不匹配了。坚持做特征归一化,就用余弦距离;坚持欧氏距离,就别做 L2。二选一,否则一个小小的预处理会让整个语义无声地变掉。
4. 端到端的 episode 训练循环:数据加载、损失计算与多场评估
4.1 自己写一个 episode 采样器:类别随机、样本不重复
训练数据加载器的核心是把数据集的样本重组成一个一个 episode。采样规则是:先从所有类别里随机抽 N 类,再在每类里随机抽 n_shot 个样本作为支撑集、n_query 个作为查询集。这里最容易被忽略的是支撑集和查询集不能有共同的样本,它们可以有同样的标签,但必须是不同个体。
import random class EpisodeSampler: def __init__(self, dataset, n_way: int, n_shot: int, n_query: int, episodes_per_epoch: int = 200): self.dataset = dataset self.n_way = n_way self.n_shot = n_shot self.n_query = n_query self.episodes_per_epoch = episodes_per_epoch # 先把每个类别的样本索引收集好 self.class_to_indices = {} for idx, (_, label) in enumerate(dataset): self.class_to_indices.setdefault(label, []).append(idx) def __iter__(self): for _ in range(self.episodes_per_epoch): support_x, support_y, query_x, query_y = [], [], [], [] chosen_classes = random.sample( list(self.class_to_indices.keys()), self.n_way) for k, cls in enumerate(chosen_classes): indices = random.sample( self.class_to_indices[cls], self.n_shot + self.n_query) support_x.extend(indices[:self.n_shot]) query_x.extend(indices[self.n_shot:]) support_y.extend([k] * self.n_shot) query_y.extend([k] * self.n_query) yield (support_x, support_y, query_x, query_y)逻辑说明:__iter__每次生成一个 episode,输出四个 Python 列表,使用方再按索引从 dataset 取图像。类别标签重编号为 0 到 n_way-1,因为原型网络只关心当前 episode 内的相对类别数。
参数说明:
episodes_per_epoch=200是常见设定,相当于每轮训练 200 次“考试”;太少的话模型见到的类别组合不充分;random.sample保证同一类别内支撑、查询样本不交叉,这是防止信息泄漏的关键一步;- 如果数据集本身类别很少,比如只有 10 类,
n_way不要取到 8 以上,否则任务难度和真实评估场景会不一致。
4.2 训练循环:优化器选择与损失计算
有了 episode 数据,训练循环非常简洁:支撑集和查询集分别过编码器,得到原型和 logits,再对查询标签计算交叉熵。下面是单个 epoch 的训练函数:
import torch import torch.nn.functional as F def train_one_epoch(encoder, sampler, optimizer, device, dataset): encoder.train() total_loss, total_correct, total_samples = 0.0, 0, 0 for support_x, support_y, query_x, query_y in sampler: support_x_t = torch.stack([dataset[i][0] for i in support_x]).to(device) support_y_t = torch.tensor(support_y, device=device) query_x_t = torch.stack([dataset[i][0] for i in query_x]).to(device) query_y_t = torch.tensor(query_y, device=device) support_emb = encoder(support_x_t) query_emb = encoder(query_x_t) prototypes = compute_prototypes(support_emb, sampler.n_way, sampler.n_shot) logits = compute_logits(query_emb, prototypes) loss = F.cross_entropy(logits, query_y_t) optimizer.zero_grad() loss.backward() optimizer.step() pred = logits.argmax(dim=-1) total_loss += loss.item() * len(query_y) total_correct += (pred == query_y_t).sum().item() total_samples += len(query_y) return total_loss / total_samples, total_correct / total_samples逻辑说明:每个 episode 的支撑集和查询集独立过编码器,原型只由支撑集特征算出;损失反向传播时,查询距离对原型和查询特征都有梯度,因此编码器所有卷积层都会收到更新信号。
参数说明:
- 优化器我通常先用 Adam,学习率 1e-3,这个配置在小型编码器上基本不会翻车;
- 换成 SGD 的话,学习率要从 1e-2 起步,搭配动量 0.9 和余弦退火,否则容易发散;
- BatchNorm 在 n_way × n_shot 很小时统计不稳定,比如 5-way 1-shot 只有 5 个样本进入 BN。这时可以改用 GroupNorm,或者把支撑集和查询集拼接后统一过编码器,再按索引切分特征,让 BN 看到更多样本。
4.3 评估不该只看一个 episode:多场考试平均才可信
单 episode 准确率的方差非常大,5-way 5-shot 的结果可能从 80% 跳到 95%,取决于抽到哪些类、这些类的类内差异有多大。真正可信的评估是在验证集上跑 100 到 200 个 episode,求平均并报告标准差或标准误。
import numpy as np @torch.no_grad() def evaluate(encoder, sampler, device, dataset, episodes: int = 200): encoder.eval() episode_accs = [] for support_x, support_y, query_x, query_y in sampler: support_x_t = torch.stack([dataset[i][0] for i in support_x]).to(device) query_x_t = torch.stack([dataset[i][0] for i in query_x]).to(device) query_y_t = torch.tensor(query_y, device=device) support_emb = encoder(support_x_t) query_emb = encoder(query_x_t) prototypes = compute_prototypes(support_emb, sampler.n_way, sampler.n_shot) logits = compute_logits(query_emb, prototypes) pred = logits.argmax(dim=-1) acc = (pred == query_y_t).float().mean().item() episode_accs.append(acc) mean_acc = float(np.mean(episode_accs)) std_err = float(np.std(episode_accs)) / (episodes ** 0.5) return mean_acc, std_err逻辑说明:评估时不需要反向传播,所以用@torch.no_grad()包裹。每个 episode 单独计算准确率,最后对列表求平均和标准误。
参数说明:
episodes=200时,5-way 准确率的标准误通常在 1 个百分点左右;时间紧张最少也要跑 100 个;- 评估数据集的类别划分必须和训练完全分开,类别重叠会让结果虚高,这属于数据泄漏,实验前要确认;
- 比较两次改动是否有意义时,看差异是否超过两倍标准误,否则基本是噪声。
5. 避坑:原型网络在 PyTorch 里常见的五个翻车点
5.1 特征层做了 L2 归一化,却仍然用平方欧氏距离
现象:训练时 loss 能稳降,但验证准确率明显低于预期,怎么调学习率都上不去。
原因:L2 归一化后特征全部落在单位球面上,平方欧氏距离退化为关于余弦相似度的单调函数。模型实际学的是一个“方向匹配”任务,损失函数和距离度量的语义不一致,特征向量里的模长信息被直接丢弃。
解决:二选一。保留平方欧氏距离,就去掉特征层 L2;保留 L2,就把距离改成 2 - 2cos,或者直接用余弦相似度参与 logits。改完后重新跑 100 个验证 episode 对比平均准确率。
5.2 支撑集和查询集共享了同一次随机增强
现象:训练准确率接近 99%,验证准确率却只有 70% 左右,两边差距异常大。
原因:如果对同一张原始图片套用同样的随机增强 transform,支撑和查询可能来自同一张原始图或非常相似的裁剪版本,相当于测试时把答案提前放进了支撑集,模型是在“抄答案”。
解决:严格按索引切分支撑和查询样本,不要靠随机增强去“生成”不同样本。训练时可以给支撑和查询分别实例化不同的 transform 对象,或者固定随机种子;评估时统一用同一套固定裁剪策略。
5.3 学习率设置过高,或 batch 太小让 BatchNorm 失稳
现象:loss 在前几十个 episode 内冲高甚至出现 NaN,之后一蹶不振;换个随机种子有时又能跑,时好时坏。
原因:原型网络每个 episode 的数据分布差异极大,SGD 在高学习率下一步跨出能毁掉整个特征空间。另外 n_way × n_shot 太小时,BatchNorm 的均值和方差估计噪声很大。
解决:改用 Adam 学习率 1e-3 先跑通;若坚持 SGD,从 1e-2 起步并加余弦退火。样本太少时把 BatchNorm 换成 GroupNorm,或在一个 episode 内先拼接支撑集和查询集再过编码器。
5.4 用 5 个 episode 的准确率当最终结论
现象:同一份代码、同一份权重,上午跑准确率 88%,下午跑变成 82%;同事只改了一个随机种子就超过你。
原因:评估样本量太小。5-way 任务单 episode 的标准差通常有 2 到 4 个百分点,只跑 5 个 episode 的话,结果基本是随机噪声。
解决:至少跑 100 到 200 个评估 episode,把每个 episode 的准确率存成数组,报告均值 ± 标准误。比较两个改动时,用相同的评价口径,看差异是否超过两倍标准误。
5.5 支撑集里混入重复或近似重复样本
现象:验证准确率高得离谱,换一批数据就打回原形;排查数据后发现某些类别里的“不同”样本其实是同一张图,或只差水印。
原因:数据清洗时没做去重,或者视频抽帧连续帧高度相似。这等于支撑集里塞了几份答案,模型沿着答案就能猜对查询。
解决:构建数据集后先做一步去重。用编码器特征算余弦相似度,把同类中相似度大于 0.95 的样本标记出来,人工抽检后决定保留哪一张。这个习惯能省掉后面一大半的排查时间。
6. 从能跑到好用:温度锐化、转导原型与增量式原型缓存
6.1 推理时的小温度可以提升区分度
训练时保持 τ=1.0 没问题,但推理时可以试试把温度调低到 0.5 到 0.8。负距离经过 softmax 后概率分布会更尖锐,5-shot 场景下常能白捡 0.5 到 1 个百分点。实现方式就是compute_logits(query_emb, prototypes, temperature=0.6),然后在同样一批验证 episode 上对比,看均值变化有没有超过标准误。
6.2 转导原型:让查询样本“反哺”原型
转导推理是少样本里很实用的一招。先用当前原型对查询样本做初步预测,再根据高置信度查询样本的特征对原型做加权修正。形式上,把查询特征按预测概率加权平均加到原型的分子里,重新归一化后得到新的原型。这个过程迭代两三次一般就收敛,本质上是 EM 思想。批量操作在 PyTorch 里用矩阵乘就能实现,代码量不超过 20 行。它对低 shot 场景尤其有效,公开比赛里经常能救回两三个百分点。
6.3 增量式原型缓存:省下重新训练的时间
少样本落地时经常遇到“新类别逐步到来”的场景。这时候只需要把新类别的少量样本过一遍已经训练好的编码器,求个均值,存进一个特征缓存表,下次直接参与 logits 计算,完全不需要重新训练编码器。原型缓存因此成为运行期更新分类器的常见做法。我的习惯是每次新类别到达时,把该类的特征均值和样本数一起存下来;攒到 10 个类别以上时做一次质量巡检,看是否有原型之间距离过近,距离太近就考虑是否需要补充样本或者调温度。
我现在每次做少样本实验,都会先花 10 分钟跑一遍这套最小循环,拿到的准确率作为 baseline。之后所有 trick,无论温度、转导还是缓存,都必须在同一个评估口径下和 baseline 对比,否则就是在拿噪声做结论。原型网络翻车大多不是模型问题,而是数据泄漏、特征归一化和评估量不足这三件事。希望帮到你。
本文还有配套的精品资源,点击获取