☰
小样本分类实战:PyTorch原型网络实现与调参指南
2026/10/29 2:29:28 网站建设 项目流程

简介:这份资源是 Prototypical Networks 小样本学习算法的 PyTorch 复现实现,面向已掌握深度学习基础、希望深入理解度量学习与少样本分类的研究生、算法工程师及科研人员,可用于 Omniglot 等数据集的实验复现与二次开发。压缩包共 12 个文件,约 135KB,以 7 个 Python 脚本为核心,涵盖原型网络模型定义、原型损失计算、批采样器、训练入口及 Omniglot 数据加载等模块,另附 2 张示意图、README 说明文档、LICENSE 与 .gitignore 配置,结构紧凑、职责清晰。已有 920 人学习下载,具备一定社区验证基础。读者可据此直接跑通小样本训练流程,理解支撑集与查询集的采样逻辑、类原型计算与距离度量方式,并以此为骨架迁移到自定义数据集或对比其他度量学习方案,是入门原型网络较为轻量的参考实现。

1. 小样本分类的“原型网络”:一份能直接跑起来的 PyTorch 实现

手里只有每类三五张标注图,却要训出一个能分几十类的模型,这是很多做视觉落地的人绕不开的坎。常规监督学习在这种数据量下基本翻车,而原型网络(Prototypical Networks)的思路很朴素:把每类样本映射到同一个嵌入空间,取类内均值当作“原型”,预测时看查询样本离哪个原型最近。这份 PyTorch 实现把嵌入网络、episode 采样、原型计算和训练循环都拆成了独立模块,适合想在小样本分类任务上快速搭基线、又不想从论文公式一行行啃起的人。它不追求 SOTA,但胜在结构清晰、改起来方便,拿来当 few-shot learning 的入门骨架或者课程设计的起点都够用。

2. 原型网络的数学骨架与代码映射:从论文公式到可训练模块

原型网络的核心只有三件事:嵌入函数、类原型、距离度量。论文里写得抽象,落到代码上其实就对应三个可替换的部件。先把这层映射理清楚,后面读源码才不会迷路。

2.1 嵌入函数:把图像压成一个向量

嵌入网络f_φ负责把输入x映射到R^D空间。这份实现里通常是一个卷积主干加全局池化,输出维度D是可配的。选型上要注意:小样本场景下主干不宜太深,参数量一大就容易在少量 support 样本上过拟合。常见做法是用四层卷积(每层 conv-bn-relu-maxpool)堆到 64 或 160 维,输入尺寸一般固定成 84×84 或 28×28,取决于数据集。

import torch import torch.nn as nn class EmbeddingNet(nn.Module): def __init__(self, in_channels=3, hidden=64, out_dim=64): super().__init__() # 四层卷积,每层后接 BN 和 ReLU,最后全局池化压成向量 self.encoder = nn.Sequential( self._block(in_channels, hidden), self._block(hidden, hidden), self._block(hidden, hidden), self._block(hidden, out_dim), ) def _block(self, cin, cout): return nn.Sequential( nn.Conv2d(cin, cout, 3, padding=1), nn.BatchNorm2d(cout), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) def forward(self, x): x = self.encoder(x) # 全局平均池化,输出 (B, out_dim) return x.mean(dim=[2, 3])

这段代码的关键在最后那步mean(dim=[2,3]),它把空间维度抹掉,只保留通道维作为嵌入向量。out_dim就是原型所在空间的维度,设太小类间区分度不够,设太大在样本极少时方差偏高,64 到 160 之间是比较稳的区间。BN 层在小 batch 下统计量不稳,如果 episode 里 support 数量很少,可以把 BN 换成 GroupNorm,这是我踩过一次坑之后固定下来的习惯。

2.2 类原型:support 样本的均值就是类别中心

给定一个 episode,每类有K个 support 样本(K-shot),原型c_k就是这 K 个嵌入向量的算术平均。这一步没有可学习参数,纯计算,但实现时要注意张量形状的对齐。

def compute_prototypes(support_emb, support_labels, n_way): # support_emb: (N_support, D),support_labels: (N_support,) prototypes = [] for c in range(n_way): mask = support_labels == c # 对同一类的嵌入取均值,得到该类的原型向量 prototypes.append(support_emb[mask].mean(dim=0)) return torch.stack(prototypes) # (n_way, D)

n_way是 episode 里的类别数,循环按类号取 mask 再求均值。这里有个容易忽略的点:如果某个类在采样时一个样本都没抽到,mean会得到 NaN,训练直接崩。所以 episode 采样阶段必须保证每类至少K个样本,这个约束要在数据层就卡死,不能指望训练循环兜底。

2.3 距离度量与损失:欧氏距离加 softmax

预测阶段算查询样本到每个原型的欧氏距离,取负距离过 softmax 得到类别概率。论文对比过余弦距离和欧氏距离,结论是欧氏在归一化嵌入下表现更稳,这份实现默认走欧氏。

def prototypical_loss(query_emb, query_labels, prototypes): # 计算查询样本到各原型的平方欧氏距离 (N_query, n_way) dists = torch.cdist(query_emb, prototypes, p=2) ** 2 # 负距离过 softmax,距离越近 logit 越大 logits = -dists return nn.functional.cross_entropy(logits, query_labels)

torch.cdist直接算批量距离,比手写广播省事也不容易错。平方是为了放大近邻差异,配合负号后 softmax 的梯度更集中。损失函数就是标准交叉熵,标签是查询样本的真实类号(在 episode 内重新编号成 0 到 n_way-1)。这套组合下来,整个前向过程没有额外可学习参数,训练信号全部回传到嵌入网络,结构非常干净。

3. Episode 采样与训练循环:把 N-way K-shot 真正跑起来

原型网络不能按常规 batch 训练,必须构造 episode:每个 episode 随机抽N个类,每类抽K个 support 加若干 query。采样逻辑写错是这份代码最常见的翻车点,下面把数据层和训练层拆开讲。

3.1 Episode 采样器:类别不重叠、样本不重复

采样器的职责是给定数据集,每次吐出一个 episode 的索引。要点有三个:类从全集里无放回抽、每类样本无放回抽、support 和 query 不能重叠。

import numpy as np class EpisodeSampler: def __init__(self, labels, n_way, k_shot, q_query): self.labels = np.array(labels) self.n_way = n_way self.k_shot = k_shot self.q_query = q_query # 预先按类分组,避免每次采样都全量扫描 self.class_indices = {c: np.where(self.labels == c)[0] for c in np.unique(self.labels)} def sample(self): classes = np.random.choice(list(self.class_indices.keys()), self.n_way, replace=False) support_idx, query_idx = [], [] for c in classes: idx = self.class_indices[c] # 每类需要 k_shot + q_query 个样本,不足则跳过该 episode if len(idx) < self.k_shot + self.q_query: return None picked = np.random.choice(idx, self.k_shot + self.q_query, replace=False) support_idx.extend(picked[:self.k_shot]) query_idx.extend(picked[self.k_shot:]) return support_idx, query_idx

replace=False是关键,保证同一 episode 内不重复抽同一样本。返回None表示这个 episode 构造失败(某类样本不够),训练循环里要处理这种情况而不是硬跑。class_indices预分组能显著减少采样开销,数据量大时这点优化很实在。

3.2 训练循环:每个 episode 一次参数更新

训练循环按 episode 迭代,每个 episode 前向一次、反向一次。这里没有 epoch 的概念,通常按总 episode 数或验证集准确率早停。

def train_episode(model, optimizer, sampler, device): batch = sampler.sample() if batch is None: return None support_idx, query_idx = batch # 取数据并搬到设备 s_x, s_y = get_batch(support_idx, device) q_x, q_y = get_batch(query_idx, device) s_emb = model(s_x) q_emb = model(q_x) # episode 内标签重映射为 0..n_way-1 s_y = remap_labels(s_y) q_y = remap_labels(q_y) prototypes = compute_prototypes(s_emb, s_y, sampler.n_way) loss = prototypical_loss(q_emb, q_y, prototypes) optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()

remap_labels把原始类号映射成 episode 内的连续编号,因为compute_prototypes和交叉熵都假设标签从 0 开始。这个映射如果漏了,损失会算得莫名其妙,是新手最容易卡住的地方。优化器一般用 Adam,学习率 1e-3 起步,episode 数几千到上万视数据集而定。

3.3 验证与评估:用固定 episode 看准确率

训练过程中要定期在验证集上跑一批固定 episode,统计查询样本的分类准确率。评估时模型切eval(),且 episode 要固定下来(用固定随机种子),否则每次波动太大看不出趋势。

@torch.no_grad() def evaluate(model, sampler, n_episodes=100): model.eval() correct, total = 0, 0 for _ in range(n_episodes): batch = sampler.sample() if batch is None: continue s_idx, q_idx = batch s_x, s_y = get_batch(s_idx, 'cpu') q_x, q_y = get_batch(q_idx, 'cpu') s_y, q_y = remap_labels(s_y), remap_labels(q_y) protos = compute_prototypes(model(s_x), s_y, sampler.n_way) dists = torch.cdist(model(q_x), protos, p=2) pred = dists.argmin(dim=1) correct += (pred == q_y).sum().item() total += len(q_y) model.train() return correct / total

评估用argmin取最近原型,和训练时的 softmax 是同一套逻辑的两种表达。n_episodes取 100 到 600 之间,太少方差大,太多拖慢训练节奏。注意评估完要切回train(),否则 BN 层统计量不更新,后续训练会悄悄退化。

4. 避坑与排查:这份实现最容易翻车的五个地方

代码能跑通不代表结果可信,下面这几条是我在复现和改这份实现时真实撞过的墙,按“现象 → 原因 → 解决”记下来。

4.1 损失不降反升,准确率卡在随机水平

现象:训练几十个 episode 后 loss 震荡,验证准确率约等于1/n_way。原因多半是标签重映射没做,或者 support 和 query 的类号没对齐,导致交叉熵在学一个错位的映射。解决:在train_episode里打印一个 episode 的s_y和q_y,确认两者取值范围都是0..n_way-1且语义一致;再检查compute_prototypes的循环上界是不是n_way而不是数据集总类数。

4.2 训练中途出现 NaN

现象:loss 突然变成 nan,之后全崩。原因通常是某个类在 episode 里样本数为零,mean对空张量求均值得到 nan,反向传播污染全部参数。解决:采样器里对每类样本数做前置校验(见 3.1 的return None),训练循环遇到None直接跳过;另外在compute_prototypes里加一句断言,样本数为零时立刻报错而不是静默产生 nan。

4.3 验证准确率虚高,换测试集就掉

现象:验证集上能到七八十,测试集只有五十出头。原因是验证和测试用了同一批 episode 种子,或者验证集样本泄漏进了训练采样池。解决:训练、验证、测试三份数据在采样器层面就物理隔离,验证 episode 用固定种子单独生成,测试阶段重新抽一批没见过的 episode。小样本任务里数据泄漏的代价比常规分类大得多,因为每类样本本来就少。

4.4 BN 层在小 episode 下统计量失真

现象:训练 loss 正常,评估时准确率明显低于训练时。原因是 BN 用当前 batch 统计量,而 episode 里样本数很少,统计量噪声大,eval()切到滑动平均后又和训练分布对不上。解决:把 BN 换成 GroupNorm 或 LayerNorm,或者把主干里的 BN 的momentum调小、track_running_stats设 False。我一般直接换 GroupNorm,省心。

4.5 显存爆掉或训练极慢

现象:加大n_way或q_query后 OOM,或者每 episode 耗时暴涨。原因是torch.cdist在 query 和原型都大时是 O(N·M·D) 的计算,且 support 和 query 分开前向等于两次主干计算。解决:把 support 和 query 拼成一个 batch 一次前向再拆开,能省一半主干开销;q_query控制在 15 以内,n_way不超过 20,再大就该考虑梯度累积或换更轻的主干。

5. 进阶调参与迁移技巧:让原型网络在真实数据上站住

把基线跑通只是第一步,真正决定这份实现能不能用在项目里的,是几个关键参数的取舍和迁移时的处理。下面这套调参顺序是我反复验证后固定下来的。

先调嵌入维度out_dim。它和n_way要匹配:n_way越大,类间需要更多维度来区分,64 维在 5-way 下够用,20-way 时建议上到 160。调的时候固定其他参数,只扫out_dim ∈ {64, 128, 160},看验证准确率的拐点。接着调k_shot,1-shot 和 5-shot 的表现差异往往比换主干还大,如果 5-shot 明显好于 1-shot,说明嵌入空间还没学稳,可以加 episode 数或加数据增强。

数据增强在小样本里是性价比最高的一环。随机裁剪、水平翻转、颜色抖动这三样对自然图像基本无脑加,但要注意增强只作用在 support 和 query 的输入上,不能改变 episode 的类别结构。下面这个增强组合可以直接抄:

from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(84, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.4, 0.4, 0.4), transforms.ToTensor(), ])

scale=(0.6, 1.0)控制裁剪比例,太小会把目标裁没,0.6 是保守起点。颜色抖动的三个 0.4 分别对应亮度、对比度、饱和度,小样本下别开太大,否则类内方差被放大反而伤害原型估计。

迁移到自己的数据集时,最省事的路径是冻结主干前两层、只训后两层加分类头,等 loss 稳定再全量微调。判断是否该解冻的信号是:验证准确率连续几百个 episode 不涨。另外,如果目标域和预训练域差异大(比如从自然图像迁到医学影像),直接全量训往往比冻结更好,因为低层特征也不可迁移了。

参数常用取值调整信号
out_dim64 / 128 / 160验证准确率拐点
n_way5 / 10 / 20显存与类间区分度
k_shot1 / 51-shot 明显差则加 episode
q_query10 / 15超过 15 收益递减
lr1e-3loss 震荡则减半

最后说一个验证习惯:每次改完参数,我都会用固定种子跑三组不同 episode 的评估取均值,单次评估的波动经常能到五个点以上,只看一次结果很容易被玄学误导。从那以后我每次调参都强制走一遍“固定种子 + 三次评估 + 记录均值”的流程,省下不少来回折腾的时间。希望这套拆解能帮到你,把这份实现真正用起来。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询