news 2026/10/12 7:44:40

PyTorch实现原型网络:小样本学习与欧氏距离度量实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch实现原型网络:小样本学习与欧氏距离度量实战

简介:在 PyTorch 中实现 Prototypical Networks 的少样本学习项目代码,面向机器学习开发者、研究者以及刚接触 Few-Shot Learning 的读者,可帮助在标注样本极少时完成图像分类训练与模型评估。代码对应 arXiv:1703.05175 论文,以 Omniglot 等数据集为实验对象,覆盖从数据加载、批采样、模型构建到原型损失计算和训练验证的完整流程。包内共有 12 个文件,主要类型包括 7 个 Python 脚本、2 幅示意图、README 说明文档、LICENSE 许可与 gitignore 配置;其中脚本分模块放置,便于理解模型结构、原型损失、批采样器和训练入口之间的关系。整个压缩包仅 135KB,结构紧凑,适合快速下载、阅读和二次修改。目前已有 920 人学习下载。通过这份代码可掌握少样本学习中类原型构造和距离度量的实现思路,也可直接作为 PyTorch 少样本分类任务的参考工程,减少从零搭建的重复工作。

1. Prototypical Networks 是 few-shot 里最值得先复现的 PyTorch 模型

Few-shot Learning 里,Prototypical Networks 是我第一个完整在 PyTorch 里复现的小样本模型,因为它的思路足够朴素:把每个类压缩成一个「原型点」,用距离判断查询样本属于谁。它不需要复杂的元学习梯度流,不需要记忆模块,只需要一个图像编码器、一次均值计算和一个欧氏距离,就能在 Omniglot 和 miniImageNet 上拿到很强的基线结果。对刚接触小样本学习的工程师来说,这个项目是性价比最高的切入点——代码量不到三百行,但训练范式、数据组织方式、度量学习的思想全都在里面。这篇文章直接讲清楚它的原理、可复现代码和调试路径,新手能照抄跑通,熟手能对照检查自己的实现哪里埋了雷。

2. 原型网络原理:类原型、欧氏距离与 episode 式学习,一次讲清

2.1 先把小样本学习的赛制说清楚:N-way K-shot 与 support/query

小样本学习和普通分类最本质的区别,是数据被组织成「任务」而不是「批」。一个任务里包含两个集合:support set 用来给模型「看例子」,query set 用来「考试」。比如 5-way 1-shot,就是每轮任务随机抽 5 个类别,每个类别只给 1 张 support 图片,然后拿这个类别的若干张 query 图片让模型认。这里的「way」是类别数,「shot」是每个类给的标注样本数。

我最早犯的错误,是把它当成普通分类的「扩版」——训练一个分类器,让它在小样本上做微调。这不是 Prototypical Networks 的做法。原型网络的做法是episode 式训练:训练阶段就模拟测试阶段的赛制,每个 batch 不是一个 batch,而是一个 task。模型每轮看到一个全新的类别组合,被迫学会「基于少量样本快速形成决策边界」,而不是记住某个固定类别的权重。

这种设计直接决定了代码结构和数据接口。你不能拿一个普通的DataLoader直接遍历,而是需要自定义一个采样器,每次从数据集中抽 N 个类,每个类再抽 K 个 support、Q 个 query。这个采样器是整个项目的地基,后面第三章会给出完整实现。

了解赛制之后,一个很自然的疑问是:为什么测试时也要保持同样的 N-way K-shot 设置?答案是训练与测试的设置越接近,泛化越稳。你可以在训练时用 5-way,测试时用 20-way,模型照样能跑,但准确率会下降,因为原型计算时类别间的干扰变多了。最常见的做法是训练和评估都用 5-way,shot 数可以不同,比如训练用 1-shot,评估用 5-shot,这是论文里验证过的稳定配置。

2.2 类原型的计算与距离度量的选择:欧氏距离为什么胜过余弦相似度

Prototypical Networks 的核心假设是:每个类别在嵌入空间中服从一个「以原型为中心」的分布。给定 support 集,模型先用编码器把每张图映射成一个向量,然后对每个类别内的向量取均值,这个均值就是该类别的原型。对 query 的向量,模型计算它到每个原型的距离,距离最近的那个原型对应的类别就是预测结果。

关键点在这里:损失函数不是直接优化「距离」,而是优化「负距离的 softmax 概率」。具体来说,query 到某个原型 c_k 的欧氏距离记为 d_k,那么模型输出的概率是softmax(-d_k)。注意前面有个负号,因为距离越小,相似度越高,概率应该越大。

为什么选欧氏距离而不是余弦相似度?这是论文里的一个重要结论,也是实际跑实验时能直接感受到的差异。欧氏距离对应的是高斯分布假设,它对特征向量的每个维度一视同仁;余弦相似度则只关注方向,忽略向量的长度。在小样本场景下,特征向量的模长本身是有判别力的信息——某些类别整体就更「紧凑」,模长的差异能帮助分类。把距离度量换成余弦,准确率会掉。原论文的对比实验显示,在 miniImageNet 的 5-way 5-shot 设置下,欧氏距离比余弦相似度高大约 2 到 4 个百分点。

实现时还有一个容易被忽略的细节:对 query 向量和原型向量,用不用同一个编码器?答案是必须用同一个,而且编码器在整个 episode 中只前向一次。support 和 query 的图片一起过编码器,然后从特征里切分出 support 部分和 query 部分。如果分开前向两次,虽然参数共享,但 BatchNorm 的统计量会被计算两次,影响一致性,特别是当 encoder 带 BN 层的时候。

2.3 训练目标:负对数似然与距离的数学关系

训练目标用负对数似然,等价于在 distance-based logits 上做交叉熵。假设有 N 个类,query 属于第 k 类,它的 logits 向量是[-d_1, -d_2, ..., -d_N],然后用log_softmax+NLLLoss训练。这里有一个微妙之处:prototype 是从 support 算出来的,但梯度会通过distance → prototype → support encoder回传,所以模型既能学「怎么提取特征」,也能学「怎么让同一类的 support 特征聚拢」。

有个常见的误解是「Prototypical Networks 是非参数的,所以没有可学习的部分」。不对。encoder 就是可学习的,原型只是从 encoder 的输出里算出来的中间量。整个训练过程相当于在学一个 embedding 空间,让同类样本靠近,异类样本远离。这个空间的质量直接决定 few-shot 性能,因此 backbone 的选择比损失函数更值得投入。

另一个值得注意的细节是:距离计算时对特征向量做不做归一化。论文原始实现不强制 L2 归一化,但实践中,如果 backbone 输出的特征模长差异很大,训练早期的 loss 会非常不稳定。我通常会在forward里对特征向量做一次可选的 L2 归一化,作为超参数开关。归一化之后欧氏距离的取值域变小,softmax 的温度会受影响,因此也可以引入一个可学习的缩放因子。这属于进阶调优,第三章的代码里我会留出这个开关的位置。

3. 用 PyTorch 从零实现原型网络:模型、采样器与训练循环三段代码

3.1 模型结构:encoder 与 prototype 计算的完整实现

先实现最核心的模型。这里用一个小型 CNN 作为 encoder,适合 Omniglot 的 28x28 单通道输入。如果你要换到 miniImageNet 的 84x84 三通道图,把第一层卷积的in_channels改成 3,再接一个 AdaptiveAvgPool 就能适配。

import torch import torch.nn as nn import torch.nn.functional as F class CNNEncoder(nn.Module): """小型 CNN 编码器,将图像映射为 64 维特征向量""" def __init__(self, in_channels=1, feature_dim=64): super().__init__() self.feature_dim = feature_dim self.conv1 = nn.Sequential( nn.Conv2d(in_channels, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2) ) self.conv2 = nn.Sequential( nn.Conv2d(64, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2) ) self.conv3 = nn.Sequential( nn.Conv2d(64, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2) ) self.conv4 = nn.Sequential( nn.Conv2d(64, feature_dim, kernel_size=3, padding=1), nn.BatchNorm2d(feature_dim), nn.ReLU(inplace=True) ) def forward(self, x): x = self.conv1(x) x = self.conv2(x) x = self.conv3(x) x = self.conv4(x) # 28x28 输入经过三次 MaxPool 后是 3x3,全局平均池化拉成向量 x = F.adaptive_avg_pool2d(x, 1) return x.view(x.size(0), -1) class PrototypicalNetwork(nn.Module): """封装 encoder 与原型计算的前向逻辑""" def __init__(self, encoder, distance='euclidean', norm_feature=False): super().__init__() self.encoder = encoder self.distance = distance self.norm_feature = norm_feature def _pairwise_distance(self, query_feat, proto_feat): if self.distance == 'euclidean': # query: [Q, D], proto: [N, D],输出 [Q, N] diff = query_feat.unsqueeze(1) - proto_feat.unsqueeze(0) dist = torch.sqrt((diff ** 2).sum(dim=2) + 1e-8) elif self.distance == 'cosine': query_norm = F.normalize(query_feat, dim=1) proto_norm = F.normalize(proto_feat, dim=1) dist = 1.0 - torch.mm(query_norm, proto_norm.t()) else: raise ValueError(f"Unknown distance: {self.distance}") return dist def forward(self, support_x, support_y, query_x, n_way): # 全部过同一个 encoder all_x = torch.cat([support_x, query_x], dim=0) all_feat = self.encoder(all_x) if self.norm_feature: all_feat = F.normalize(all_feat, dim=1) support_feat = all_feat[: support_x.size(0)] query_feat = all_feat[support_x.size(0):] # 按类别计算原型 prototypes = [] for i in range(n_way): class_mask = support_y == i proto = support_feat[class_mask].mean(dim=0) prototypes.append(proto) prototypes = torch.stack(prototypes, dim=0) # 计算 query 到每个原型的距离,返回负距离作为 logits dist = self._pairwise_distance(query_feat, prototypes) return -dist

这段代码有几个关键设计。所有输入特征统一过一次 encoder,避免了 BN 统计量不一致的问题,这是很多人容易翻车的点。原型计算用mean(dim=0),简单直接,如果某个类的 support 样本数大于 1,这个均值就是该类别的中心点。距离函数里我加了1e-8防止开根号时出现梯度为零的极端情况,这在 float16 训练时尤其重要。

关于n_way参数,它本质上是告诉模型「当前任务里有几个类」。因为 support_y 是 0 到 n_way-1 的索引,按 mask 取均值时不会出错。如果你把 support_y 传成全局类别编号(比如 15、28、103),上面的代码会全部算错。采样器要保证每个 episode 内类别索引重新编号为 0 到 N-1,这是一个重要的约定。

3.2 episode 采样器:把数据组织成 support 和 query

有了模型,下一步是把数据集切成 episode。这里的关键是:每个 episode 随机抽 N 个类,每个类随机抽 K 个 support 和 Q 个 query。实现上用一个简单的类来管理。

import random import numpy as np import torch from torch.utils.data import Dataset class EpisodeSampler: """从按类别索引的数据中采样 episode。 参数: labels: numpy 数组,每张图片对应的类别标签(全局编号) n_way: 每个 episode 抽几个类 k_shot: 每个类抽几张 support n_query: 每个类抽几张 query """ def __init__(self, labels, n_way=5, k_shot=1, n_query=15): self.labels = np.array(labels) self.n_way = n_way self.k_shot = k_shot self.n_query = n_query # 建立全局类别 -> 样本索引列表的映射 self.class_to_indices = {} for cls in np.unique(self.labels): self.class_to_indices[cls] = np.where(self.labels == cls)[0] def sample_episode(self): # 随机抽 n_way 个类别 chosen_classes = random.sample(list(self.class_to_indices.keys()), self.n_way) support_x, support_y = [], [] query_x, query_y = [], [] for new_idx, cls in enumerate(chosen_classes): indices = self.class_to_indices[cls] # 从该类中随机取 k_shot + n_query 个样本,防止重复 chosen = random.sample(indices.tolist(), self.k_shot + self.n_query) support_idx = chosen[: self.k_shot] query_idx = chosen[self.k_shot:] support_x.extend(support_idx) support_y.extend([new_idx] * self.k_shot) query_x.extend(query_idx) query_y.extend([new_idx] * self.n_query) return ( torch.tensor(support_x, dtype=torch.long), torch.tensor(support_y, dtype=torch.long), torch.tensor(query_x, dtype=torch.long), torch.tensor(query_y, dtype=torch.long), )

采样器的逻辑不复杂,但有个细节必须强调:random.sample(indices.tolist(), self.k_shot + self.n_query)保证了一个类里 support 和 query 的图片不重叠。很多人直接切片,不用 sample,导致同一张图既在 support 里又在 query 里,验证准确率虚高,这就是典型的数据泄露。

返回的是样本索引,而不是图片张量。真正取图的工作交给 Dataset 的__getitem__。这样设计的好处是采样器和数据预处理解耦,后续如果想加难例挖掘或者数据增强,只需要改 Dataset,不用动采样逻辑。

3.3 损失函数与训练循环:负对数似然与反向传播

训练循环本身不复杂,难点在于理解「一个 episode 就是一个 batch」这件事。每个 episode 前向一次,计算一次损失,反传一次。如果在 GPU 上显存够,也可以把多个 episode 堆成一个大 batch,但初学者先别急着做这个优化,一个 episode 一个 batch 的逻辑最清晰。

import torch import torch.nn.functional as F from torch.utils.data import DataLoader class FewShotDataset(Dataset): """按索引取图的 Dataset""" def __init__(self, images, transform=None): self.images = images # numpy 数组或 torch Tensor self.transform = transform def __getitem__(self, idx): img = self.images[idx] if self.transform is not None: img = self.transform(img) return img def __len__(self): return len(self.images) def train_one_epoch(model, sampler, dataset, optimizer, device): model.train() total_loss = 0.0 for _ in range(200): # 每个 epoch 跑 200 个 episode s_x, s_y, q_x, q_y = sampler.sample_episode() # 通过索引取图 support_x = torch.stack([dataset[i] for i in s_x]).to(device) query_x = torch.stack([dataset[i] for i in q_x]).to(device) support_y = s_y.to(device) query_y = q_y.to(device) optimizer.zero_grad() logits = model(support_x, support_y, query_x, sampler.n_way) loss = F.cross_entropy(logits, query_y) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / 200 # 初始化 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = PrototypicalNetwork(CNNEncoder(), distance='euclidean').to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

这里的 Loss 用F.cross_entropy(logits, query_y),它内部会先做 log_softmax 再算 NLL,等价于上一章说的数学形式。有个容易困惑的点是:为什么 logits 是负距离而不是距离?因为 cross_entropy 会把 logits 越大当概率越高,而距离越大表示越远,所以要取负号。

关于train_one_epoch里 200 这个数,它是经验值。Omniglot 有 1623 个类,每类 20 张图,一个 epoch 跑 200 个 episode,大约会覆盖 1000 个类,足够模型看到多样化的类别组合。如果你在 miniImageNet 上训练(100 类),同一个 epoch 内类别会重复出现,这时候把 episode 数调大到 400 或 600 会更稳定。做实验时建议把 episode 数、学习率、weight decay 一起作为可配置参数,不要写死在函数里。

训练过程中的监控指标只有一个:平均 loss。loss 下降说明模型在学「拉近同类、推开异类」。如果 loss 不降,先别调学习率,优先检查 support_y 是不是从 0 到 n_way-1 的连续索引——这是这个项目里最常见的血泪教训。

4. 在 Omniglot 上跑出可复现基线:训练设置、评估脚本与关键参数

4.1 数据集选择与预处理:Omniglot 的结构和读入方式

Omniglot 是 few-shot 里最友好的起步数据集。它包含 50 个字母表,共 1623 个字符类别,每类 20 个手写样本。常用做法是分成 1200 类训练、423 类测试。图像是 28x28 的单通道灰度图。相比之下,miniImageNet 虽然更接近真实场景,但 84x84 三通道图需要更大的 encoder 和更长的训练时间,不适合用来验证代码正确性。

数据预处理的要点有三个。第一,灰度图要转成 float32 并归一化到 [0,1];第二,可以加一点随机旋转和位移作数据增强,这对 Omniglot 这种笔迹数据很有效;第三,不要用 ImageNet 的 mean/std 归一化,Omniglot 本身就是单通道小图,直接用img / 255.0就够了。

import torchvision.transforms as T train_transform = T.Compose([ T.RandomRotation(15), # 随机旋转,增强泛化 T.RandomAffine(0, translate=(0.1, 0.1)), T.ToTensor(), # 转 Tensor 并归一化到 [0,1] ]) eval_transform = T.Compose([ T.ToTensor(), ])

这里训练和评估用了不同的 transform,评估时不做任何数据增强,保证指标稳定。RandomRotation(15)的角度我试过 10 到 20 度,变化不大,但超过 30 度会让字符语义发生改变,反而掉点。

读入数据时,常见的做法是把所有图片读成一个 list,按图像顺序生成对应的 label 数组。我习惯先把数据整理成(images, labels)的格式,再传给前面的EpisodeSampler。注意images的 dtype 用 float32,用 float64 会在 GPU 上浪费一倍显存。

4.2 评估脚本:N-way K-shot 的准确率怎么统计才可信

评估脚本和训练脚本结构类似,但有三个重要的区别。第一,评估时必须固定随机种子,否则每次跑出来的准确率都不稳定,没法判断改动到底有没有效果。第二,评估要跑足够多的 episode,至少 600 个,单次 episode 的准确率方差极大,跑 20 个 episode 得出的结论基本都是噪声。第三,评估时不能用训练时的平均 loss 来判断好坏,应该统计 query 预测正确的比例。

def evaluate(model, sampler, dataset, device, num_episodes=600): model.eval() correct = 0 total = 0 with torch.no_grad(): for _ in range(num_episodes): s_x, s_y, q_x, q_y = sampler.sample_episode() support_x = torch.stack([dataset[i] for i in s_x]).to(device) query_x = torch.stack([dataset[i] for i in q_x]).to(device) support_y = s_y.to(device) query_y = q_y.to(device) logits = model(support_x, support_y, query_x, sampler.n_way) pred = logits.argmax(dim=1) correct += (pred == query_y).sum().item() total += query_y.size(0) return correct / total # 固定随机种子,保证实验结果可复现 torch.manual_seed(42) random.seed(42) np.random.seed(42) acc = evaluate(model, eval_sampler, eval_dataset, device) print(f"5-way 1-shot accuracy: {acc:.4f}")

这段代码的运行方式决定了准确率的意义。每个 episode 有 5 个类、每类 15 张 query,也就是 75 个测试样本。600 个 episode 总计 45000 次预测,这时候的准确率方差就足够小了。经验上,Omniglot 的 5-way 1-shot 基线在 97% 到 98.5% 之间,5-way 5-shot 能到 99% 以上。如果你跑出来只有 85%,大概率是采样器或者模型实现出了问题,而不是数据问题。

一个重要提醒:训练时用的 episode 设置和评估时最好保持一致,尤其是 n_way。训练用 20-way 然后评估用 5-way,会有一点性能损失。更推荐训练和评估都是 5-way,shot 数可以不同,比如训练 1-shot,评估 5-shot,这在论文里被证明是一种对模型更充分训练的策略。

4.3 关键参数速查表:照着设,别乱调

参数Omniglot 推荐值miniImageNet 推荐值备注
n_way55训练与评估保持一致
k_shot1 或 51 或 5训练 1-shot 可增强泛化
n_query1515太少影响梯度稳定性
encoder 特征维度641600 或更高特征维度越高越吃显存
优化器AdamAdamlr=1e-3,weight_decay=1e-5
学习率1e-31e-4 到 1e-3miniImageNet 用 1e-4 更稳
episode 数/epoch200400数据类多则适当增加
评估 episode 数600600低于 200 则结果不可信

这张参数表是我在很多次实验后总结的起点,不是最优解。比如特征维度,64 维在 Omniglot 上足够,但在 miniImageNet 上明显不够用,通常会换用 1600 维的卷积特征。优化器方面,Adam 是稳妥选择,SGD + momentum 在某些实现里也能达到同等效果,但 Adam 对学习率的敏感度低,排错周期更短。

如果你要换数据集,先别急着改模型结构。先把in_channels从 1 改成 3,确认输入图像尺寸能被卷积池化整除,然后用上面的参数跑一个短实验,观察 loss 是否下降。这个验证流程能帮你把「数据集问题」和「模型问题」分开,省下大量排查时间。

5. 踩坑记录:让原型网络翻车的 5 个常见问题与排查方法

5.1 距离函数换错:欧氏距离写成了余弦相似度,准确率掉 20 个点

现象:训练 loss 下降正常,但评估准确率卡在 75% 左右,无论怎么调学习率都上不去。对比论文的 Omniglot 结果,差了 20 个点以上。

原因:为了省事,我把_pairwise_distance里的欧氏距离替换成余弦距离,并加入了 L2 归一化。欧氏距离的平方项在高维空间里更强调「绝对距离」,余弦只看方向。对于 Omniglot 这样的笔迹数据,特征向量的模长确实携带类别信息,丢掉它就丢掉了信息量。

解决:切回欧氏距离,必要时用torch.cdist(query_feat, proto_feat)一行实现。另外对比实验要在固定的 600 个评估 episode 上做,否则两个距离函数的准确率差异会被评估噪声淹没。

我个人一直保留两个距离函数,用配置项切换。因为某些数据集上余弦距离并不差,但决定「用哪个」必须是实验结论,不是拍脑袋。只是默认值永远是欧氏距离。

5.2 特征没有归一化:训练损失震荡,像过山车一样无法收敛

现象:训练前 50 个 episode 的 loss 在 1.8 到 3.0 之间剧烈震荡,完全没有下降趋势。换更小的学习率也只是减轻震荡,没有解决根本问题。

原因:encoder 输出的特征向量,模长随初始化波动很大。比如两个类别的原型距离可能是 0.5,而另一个的可能是 5.0,softmax 的温度被特征尺度带着跑,梯度的变化也就非常剧烈。这个现象在深层网络和没有预训练的模型中尤其明显。

解决:在PrototypicalNetwork.forward里加一个可选的norm_feature=True,对所有特征做 L2 归一化。归一化之后特征落在单位球面上,欧氏距离的取值范围被限制在 [0, 2],loss 的稳定性立刻变好。代价是模长信息丢失,某些数据集上会有小幅度掉点,但换来的训练稳定性值得。

还有一个更精细的补救办法:不归一化,而是在距离上乘一个可学习的缩放因子,让 softmax 的有效温度自适应。这个操作通常放在_pairwise_distance之后,加一个scale = torch.nn.Parameter(torch.tensor(1.0))。

5.3 随机种子没控制:同一套代码,两次实验结果相差 3 个点

现象:白天跑的结果是 97.8%,晚上重跑一次变成 95.2%,代码一行没改。反复几次,结果像玄学一样不可控。

原因:影响随机性的因素不止一个。PyTorch 的权重初始化、采样器的random.sample、数据增强的旋转角度,这三者都会导致结果漂移。评估时没有固定 seed,600 个 episode 的准确率自然跟着随机波动。

解决:在训练和评估脚本的开头统一固定三个 seed:torch.manual_seed(42)、random.seed(42)、np.random.seed(42)。如果要完全可复现,还要在DataLoader里设置worker_init_fn,避免多进程采样时子进程的随机状态不一致。

这个坑的隐蔽之处在于:小样本学习的评估集是随机抽的,不像传统分类有一个固定的测试集。所以「固定 seed」不是一个可选项,而是实验可信度的前提。我习惯把 seed 作为命令行参数,默认 42,换 seed 看方差,报告结果时注明用了哪个 seed。

5.4 support 和 query 互相重叠:评估准确率虚高 5 个百分点

现象:5-shot 评估准确率到了 99.5%,比论文还高,感觉不对劲。仔细检查发现,有些 query 图片和 support 是同一张。

原因:采样器里用了indices[:k_shot]和indices[k_shot:k_shot+n_query]这种切片方式。如果这个类的样本列表是固定的,每次抽到的组合都一样,或者更糟——如果支持集和查询集的图片来自同一个文件,模型相当于开卷考试。这个问题在 miniImageNet 上更隐蔽,因为图片数量多,很难肉眼察觉。

解决:采样器里必须用random.sample(indices.tolist(), self.k_shot + self.n_query),先洗牌再切片。这保证了同一个 episode 内,support 和 query 在图片层面上没有任何重叠。

检查这个坑最快的方法是:打印一个 episode 的s_x和q_x索引,看有没有交集。如果交集非空,那就是数据泄露。后续加数据增强时也要注意,同一张图经过不同的随机增强后,语义上还是同一张图,评估时不能这么搞。

5.5 BatchNorm 的统计量污染:feature 提取不一致,训练和评估表现分裂

现象:训练 loss 降得很好,评估准确率却很低,而且每次评估的结果都不同,方差极大。反复检查模型和数据,没有发现任何逻辑错误。

原因:encoder 里的BatchNorm2d在训练模式下会用 batch 的均值和方差,在评估模式下用全局统计量。问题出在我的一个「优化」上:把 support 和 query 分成两次 forward,第一次算 support 特征,第二次算 query 特征。这样 BN 的归一化统计量来自两个不同的分布,尤其是 query 数量少时,BN 统计量的偏差非常大。

解决:所有图片(support + query)拼接后一次 forward(第三章的代码就是这么写的)。必须确保所有样本共享同一次 BN 统计量。如果显存不够,宁可减小 n_query,也不要分两次 forward。

另一种缓解办法是去掉 encoder 里的 BatchNorm,换成 LayerNorm 或 GroupNorm。小 batch 场景下 GroupNorm 通常更稳定,但会引入额外超参数。我一般先跑通 BN 版本,遇到这个坑再考虑替换。

6. 不换模型结构的进阶技巧:几行代码提升 few-shot 准确率

先把基础版本跑稳,再考虑下面四个技巧。它们都不改动 Prototypical Networks 的核心思想,只是对特征空间做了一些工程性质的处理,代码改动量很小,收益却很直接。第一个技巧是「距离缩放」。欧氏距离经过负号之后输入 softmax,它的有效温度固定是 1。如果特征没有归一化,距离分布的尺度不稳定,softmax 的置信度就会很高或很平。加一个可学习的scale参数,让模型自己学会调节这个温度,通常在 Omniglot 上能带来 0.5 到 1 个点的提升。实现方式是在forward里,return -dist * self.scale。

第二个技巧是「query 特征也参与原型微调」。具体做法是:把 query 预测为某个类后,将高置信度的 query 特征临时并入该类的原型,再算一次距离。这在半监督 few-shot 场景里很有效,但要注意只做一次微调,循环多了反而引入噪声。我通常在推理阶段用这个技巧,训练时不启用。

第三个技巧是针对 backbone 的。如果你从零训练一个小 CNN,特征维度和深度都有限,换一个在 ImageNet 上预训练的 ResNet-12 或者 ResNet-18,few-shot 准确率会有质的提升。代价是显存占用和训练时间。预训练骨干加 Prototypical Network 在 miniImageNet 上,从 50% 左右能拉到 70% 以上。这里的关键是训练时冻结前几层,只微调高层特征,否则预训练知识很快被覆盖。

第四个技巧是做 episode 内的 hard negative 挖掘。标准的 episode 是随机抽 N 个类,通常都很容易区分。你可以人为选择语义相近的类别组成 episode,比如 Omniglot 里同一字母表下的字符,或者 miniImageNet 里同一上位类的子类。这相当于降低了「运气成分」,让模型在更难的任务上训练。这个技巧我试过,效果稳定,但需要你对数据集的类别语义有足够的了解,不太适合自动化。

最后,很多人做完评估就结束了,但我会多跑一个「shot 泛化测试」:用 1-shot 训练的模型,直接评估 5-shot;再用 5-shot 训练的模型,评估 1-shot。这个测试能告诉你模型是真的学会了「从少量样本中归纳」,还是只是过拟合了训练时的 shot 设置。如果两个方向的表现都合理,说明特征空间的质量是过硬的。这个习惯帮我发现过很多潜在的问题,也希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 7:44:22

代码覆盖率100%为何线上仍崩?测试质量需看断言与核心路径

1. 从"100%覆盖"到"上线即崩":那个让我失眠的夜晚上一次线上事故,我到现在都记得很清楚。凌晨两点半,告警群里开始刷屏,支付回调的失败量肉眼可见地往上蹿。我一边盯着监控面板,一边顺手打开了最新…

作者头像 李华
网站建设 2026/10/12 7:41:08

功能复用实战:从重复代码到公共处理管道

做开发这些年,有个体会越来越深: 大部分新功能,其实是在“重做别人已经做过的事” 。不管是订单状态流转、数据导出、权限校验,还是一个小小的日期格式化,翻翻代码仓库,总能找到三四个“长得差不多但又不…

作者头像 李华
网站建设 2026/10/12 7:40:57

Hive数据建模实战:分层设计、存储选型与常见坑位排查

1. 动手之前先把思路摆正:Hive建模到底在"模"什么做了几年大数据平台,最深的感受不是SQL写得多花哨,而是建模这一步定错了,后面全在还债。Hive里的数据建模,不是把一张业务表搬过来建个壳,也不只…

作者头像 李华
网站建设 2026/10/12 7:40:54

Airpak 通风 CFD 教程:从建模到后处理快速入门

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/12 7:39:23

UDEC泰森多边形煤层建模实战:从块体生成到参数标定

干矿山数值模拟这些年,UDEC一直是我处理节理裂隙问题的主力工具。最近一个煤层采动影响分析的项目里,我用UDEC 7.0配合泰森多边形(Voronoi)重新构建了煤层离散块体模型,整个过程踩了不少坑,也积了一些值得分…

作者头像 李华
网站建设 2026/10/12 7:39:21

Llama 3.1 405B 本地部署全指南:显存规划、量化与推理服务实践

开源大模型的运行门槛,被 Llama 3.1 405B 重新定义了一次。Meta 发布 Llama 3.1 系列时,8B 和 70B 版本给人的感觉是“本地大模型”已经普及,但真正让社区反复讨论的,是那个参数规模达到 4050 亿的 405B。很多人下载完权重&#x…

作者头像 李华