1. 少样本学习当AI学会“举一反三”想象一下你第一次见到一种叫“柯基”的狗朋友只给你看了三张不同角度的照片。第二天在公园里你就能从一群狗里准确地认出另一只柯基。这个过程你只用了“少量样本”就学会了识别一个新类别并且成功“泛化”到了未见过的个体上。这就是少样本学习Few-Shot Learning试图让AI模型具备的核心能力。在传统的深度学习里要让AI认识“猫”可能需要成千上万张猫的图片从布偶猫到狸花猫各种姿势、各种光线都得喂给它。这就像让一个学生背下一整本百科全书来应付考试虽然有效但成本高、效率低而且一旦考到书上没有的知识他就懵了。少样本学习走的是另一条路它不追求“见过所有”而是追求“学会方法”。它希望模型像那个聪明的学生一样只看几道例题support set就能掌握解题思路然后去解答全新的题目query set。这背后的驱动力非常现实。在医疗领域针对某种罕见病我们可能只能收集到寥寥几个标注好的病理切片在新产品研发中一款新设计的手机外观可能只有几张设计图没有海量的实物照片在个性化推荐里一个新用户刚注册他的行为数据几乎为零。在这些场景下我们没法给模型提供“大数据”但依然期望它能做出靠谱的预测。这时少样本学习就成了关键的技术桥梁。而实现这种“小样本大智慧”的核心就在于巧妙设计两个关键的数据集support set支持集和query set查询集。它们的关系就像教练给的“示范动作”和让你完成的“自选动作”。整个训练过程就是让模型反复在这两个集合的互动中学会一种不依赖于具体数据量而依赖于数据关系的“元学习”能力。接下来我们就深入看看这对黄金搭档是如何工作的。2. 拆解核心概念Support Set与Query Set要理解少样本学习怎么工作你得先把自己想象成那个正在学习的AI模型。现在你面前摆着两堆卡片这就是你的全部学习材料。2.1 Support Set你的“例题本”Support Set中文常叫支持集或支撑集它就是你的“例题本”。在这个本子里每一个新知识类别都只配了很少的几道例题。在少样本学习里我们常用“N-way K-shot”来描述一个任务N代表有多少个不同的类别K代表每个类别给你看几个例子。举个例子我们现在要玩一个“5-way 1-shot”的图像识别游戏。这意味着N5有5种你从未见过的水果比如“火参果”、“刺角瓜”、“蛇皮果”、“仙人掌果”、“诺丽果”。K1每种水果我只给你看1张清晰的图片作为参考。这5张图片就构成了当前任务的support set。它的信息量非常有限你不可能从一张图里知道这种水果所有可能的颜色变化、形状差异或者拍摄角度。模型的任务就是从这仅有的5个“范例”中尽可能提炼出每个类别最核心、最独特的特征。比如模型可能会学到“火参果”的外皮有凸起的瘤状物“刺角瓜”外表有尖刺。这个过程本质上是在构建一个非常紧凑的“类别原型”或“特征锚点”。2.2 Query Set你的“随堂测验”光看例题不练习知识是记不牢的。Query Set查询集就是你的“随堂测验”。在同一个任务里我会再给你一堆新的水果图片它们同样来自刚才那5个类别火参果、刺角瓜等但肯定是和support set里不同的个体图片。比如我给你15张新的水果图每种水果3张。这15张图就是query set。你的任务就是对照刚才support set里记住的5个“范例”来判断这15张新图各自属于哪个类别。模型需要计算query set中每张图片的特征与support set中每个类别“原型”的相似度然后把最相似的那个类别作为预测结果。这里有一个至关重要的原则support set和query set的类别空间是完全一致的。在训练阶段的每一个小任务称为一个episode里它们共享同一组类别。模型在这个小任务中的目标就是学会区分这组特定的类别。通过成千上万次这样的“小任务”训练模型并不是在记忆具体的“火参果”长什么样而是在学习一种通用的“比较和区分”的方法论如何利用有限的范例support set建立类别认知并将这种认知应用到同类别的新样本query set上。2.3 从训练到测试“Unseen”类别的终极挑战理解了训练时support set和query set的“配合演出”我们就能明白少样本学习最精彩、也最困难的部分了测试。在训练阶段模型经历了无数个“5-way 1-shot”或“10-way 5-shot”这样的小任务接触了成千上万个不同的类别组合。但它真正学会的不是“认识狗”或“认识猫”而是“给定几个新类的少量例子我就能学会区分它们”这种元能力。到了测试阶段真正的挑战来了。我会拿出一些完全崭新的类别这些类别在训练时从未出现过。比如训练时模型见过各种水果、家具、动物但从未见过“不同型号的无人机”。现在我做一个测试任务Support Set给你5种无人机的图片每种1张5-way 1-shot。这5种无人机对模型来说是彻头彻尾的“新面孔”。Query Set再给你一些这5种无人机的其他图片。模型需要利用它在过去成千上万个训练任务中磨练出的“元学习”能力快速分析这个全新的support set为5种无人机建立临时的类别概念然后去分类query set。测试集中的这些类别因此被称为“Unseen Classes”未见类别。这种设置严格模拟了现实世界AI总会遇到训练数据中不存在的新事物。少样本学习的目标就是让模型具备这种“触类旁通”的泛化能力。而实现这一目标的关键就在于模型架构和训练算法如何利用好support set和query set这对“舞伴”。3. 让模型学会“学习”主流方法与实战解析知道了规则下一步就是研究战术。少样本学习领域发展出了几种核心思路来让模型从support set中有效学习并泛化到query set。我结合自己的经验给你讲讲最主流的几种方法以及实际操作的感受。3.1 基于度量的学习像“近邻”一样比较这是最直观、也最容易理解的一类方法。它的核心思想是学习一个优秀的“距离度量函数”。这个函数能把输入数据如图片映射到一个特征空间在这个空间里同一个类别的样本彼此靠近不同类别的样本彼此远离。在推理时模型拿到一个support set它会计算这个集合中每个样本的特征并为每个类别形成一个“原型”通常是该类所有样本特征的平均值。然后对于query set中的每一个样本模型计算它的特征到各个类别“原型”的距离比如欧氏距离、余弦相似度。最后将query样本分配给距离最近的那个原型所代表的类别。一个经典的实现是Prototypical Networks原型网络。我当年复现这个网络时感觉它的优雅之处在于简单有效。它的损失函数直接鼓励query样本特征靠近自己类别的原型而远离其他类别的原型。# 原型网络核心思想伪代码示意 def prototypical_loss(support_features, query_features, labels): # support_features: [N*K, feature_dim], N个类每类K个样本 # query_features: [N*Q, feature_dim], 每类Q个查询样本 # labels: 查询样本的标签 # 1. 计算每个类别的原型类中心 prototypes [] for class_id in range(N): # 取出该类所有support样本的特征 class_features support_features[class_id*K : (class_id1)*K] prototype mean(class_features, axis0) # 求平均得到原型 prototypes.append(prototype) prototypes stack(prototypes) # [N, feature_dim] # 2. 计算每个query样本到所有原型的距离如欧氏距离的平方 distances [] for query_feat in query_features: dist sum((query_feat - prototypes) ** 2, axis1) # [N, ] distances.append(dist) distances stack(distances) # [N*Q, N] # 3. 使用负距离或距离的负值作为logits计算交叉熵损失 # 距离越小越相似logits应该越大 logits -distances loss cross_entropy_loss(logits, labels) return loss这种方法的好处是解释性强训练相对稳定。但它的性能高度依赖于特征提取器的好坏。如果特征空间本身“区分度”不够那么简单的距离比较就会失效。在实际项目中我经常需要花很大精力在数据增强和特征网络Backbone的预训练上才能让原型网络达到理想效果。3.2 基于优化的学习模拟“微调”过程另一大类方法思路更“暴力”一些它试图在每一个小任务中模拟整个模型的快速微调过程。最具代表性的就是Model-Agnostic Meta-Learning (MAML)中文叫“模型无关元学习”。MAML的想法非常巧妙它训练一个模型的“初始参数”。这个初始参数的特点在于当面对任何一个新的少样本任务时从这个初始参数出发只需要在这个新任务的support set上进行少量几步比如1-5步的梯度下降更新模型就能快速适应新任务并在该任务的query set上取得好性能。你可以把这个“初始参数”想象成一个“万事通”的起点。它没有专门学好识别猫或狗但它学会了“如何快速学会识别新东西”。MAML的训练过程是在大量不同的任务上进行的。在每个任务中它都会模拟“内循环”和“外循环”内循环任务特定适应用当前任务的support set对模型参数进行几步梯度更新得到一个针对该任务优化后的参数。外循环元学习用这个优化后的参数在当前任务的query set上计算损失。然后这个损失的梯度不是更新内循环后的参数而是反向传播回最原始的初始参数。这样经过反复训练初始参数就被调整到了一个“风水宝地”从这个点出发朝任何新任务的方向即用该任务的support set计算梯度走一小步都能到达一个对该任务性能不错的参数位置。# MAML核心训练循环伪代码示意简化版 meta_optimizer Adam(meta_model.parameters()) # 元优化器更新初始参数 for episode in range(total_episodes): # 1. 采样一个任务包含support set和query set task sample_task(dataset) support_data, query_data task # 2. 克隆当前元模型的参数用于内循环快速适应 fast_weights clone_parameters(meta_model) # 3. 内循环在support set上快速适应几步 for step in range(inner_steps): support_loss compute_loss(meta_model, fast_weights, support_data) # 计算梯度并更新fast_weights注意这里更新的是fast_weights不是meta_model! grad gradient(support_loss, fast_weights) fast_weights fast_weights - inner_lr * grad # 4. 外循环用适应后的参数在query set上计算损失 query_loss compute_loss(meta_model, fast_weights, query_data) # 5. 关键用query_loss的梯度去更新元模型初始参数 meta_optimizer.zero_grad() query_loss.backward() # 梯度会通过内循环的更新步骤传回meta_model meta_optimizer.step()MAML的理念非常强大但说实话训练起来比较“酸爽”。它对超参数特别是内循环学习率非常敏感计算量也大需要二阶导数近似而且容易过拟合。我在实际应用时通常会采用它的简化版比如First-Order MAML (FOMAML)它忽略二阶导数虽然理论保证弱了点但训练起来稳定、快得多很多时候效果也不差。3.3 基于模型的设计让网络“自带”比较机制除了在训练算法上创新还可以直接从模型结构上动刀设计出天生就适合做比较的网络。Relation Network关系网络就是这样一个例子。它不再简单地计算特征间的距离而是引入了一个额外的“关系模块”。这个模块以一对样本一个来自support set一个来自query set的特征作为输入输出一个0到1之间的“关系得分”表示它们属于同一类别的可能性。具体流程是对于query set中的一个样本将它和support set中每一个样本或每一个类别的原型配对分别送入关系模块计算得分然后对这些得分进行整合比如平均得分最高的那个support类别就被认为是query样本的类别。关系模块本身是一个小神经网络它和特征提取器一起进行端到端的训练。这种方法的好处是关系判断的准则是由数据驱动学习出来的可能比人为定义的欧氏距离更灵活、更贴合数据特性。我曾在细粒度图像分类比如区分不同品种的鸟上试过Relation Network发现它对于捕捉细微的、非线性的差异有时比原型网络更有效。4. 实战中的挑战与我的“踩坑”经验理论听起来很美但把少样本学习真正用起来你会遇到一堆教科书上不会细讲的坑。下面我结合几个实际项目经验聊聊怎么让support set和query set发挥最大威力以及如何避开常见的陷阱。4.1 数据还是数据构造与增强的艺术少样本学习对数据极其敏感。因为样本量少每一个样本都“身兼重任”。构造高质量、多样化的episode任务是成功的一半。首先训练阶段的episode采样策略至关重要。你不能简单地从数据集中随机抽几个类、每个类随机抽几张图。我常用的策略是“困难样本挖掘”。比如在采样构成一个episode的N个类别时我会倾向于选择那些在特征空间上比较接近、容易混淆的类别比如“哈士奇”和“阿拉斯加”“摩托车”和“自行车”。这样构造出来的任务更难迫使模型去学习更精细、更具判别力的特征泛化能力会更强。其次对support set进行强数据增强是必须的。因为K可能只有1或5样本的多样性严重不足。我常用的增强组合包括随机裁剪要保证关键信息不丢失、颜色抖动亮度、对比度、饱和度、随机水平翻转有时还会加上CutMix或MixUp这类混合式增强在像素层面创造新的“样本”。但要注意增强必须合理不能改变样本的类别语义。比如对手写数字“6”做任意角度的旋转可能会变成“9”这就破坏了标签的一致性。对于query set在训练时我通常只做较弱的增强如中心裁剪或者不做增强以模拟真实的测试场景。但在测试阶段对于那个唯一的support set我往往会采用测试时增强对每张support图片生成多个增强版本用它们的平均特征来生成更稳健的类别原型这招经常能稳定提升1-3个点的准确率。4.2 特征提取器强大的Backbone是基石无论你的元学习算法多精妙如果特征提取器Backbone本身很弱提取的特征没有区分度那一切都是空中楼阁。用一个在大规模数据集如ImageNet上预训练好的模型作为Backbone几乎是少样本学习的标准起点。这里有个经验不要冻结Backbone的全部层。很多人觉得预训练模型已经很好了直接冻结只训练它后面的元学习部分比如原型网络的距离计算层。但我的实践发现让Backbone的最后1-2个阶段比如ResNet的最后1-2个残差块参与微调让特征空间根据你的少样本任务进行微调效果通常更好。当然学习率要设得比元学习部分小很多比如小10到100倍避免它“忘掉”之前学到的通用知识。另一个技巧是使用更先进的预训练模型。近年来基于Transformer的视觉模型如ViT、Swin Transformer在许多任务上超越了传统的CNN。它们在少样本学习上也展现出了强大的潜力因为其自注意力机制可能更擅长捕捉样本间的长程依赖和关系这对于从少量样本中归纳信息非常有用。我最近的项目把Backbone从ResNet-12换成了一个小型的ViT在miniImageNet的5-way 1-shot任务上直接带来了近5%的提升。4.3 过拟合少样本学习的头号敌人数据量极小模型参数又多过拟合的风险如影随形。除了上面提到的数据增强还有几个对抗过拟合的实用手段第一正则化要舍得用。Dropout在特征提取器的全连接层如果有的话和元学习模块中都很有效。权重衰减L2正则化的参数也需要仔细调校。我有时甚至会用到更激进的方法比如在特征层面添加Dropout随机将一部分特征维度置零强迫模型不依赖于某些特定的特征线索。第二控制模型容量。不是模型越大越好。对于一个简单的少样本任务一个过大的模型会迅速记住那寥寥几个support样本而无法泛化。我通常从较小的模型开始如4层卷积的“MiniCNN”或小型的ResNet只有当确信是模型能力不足导致欠拟合时才考虑增大模型。第三利用无标签数据或外部知识。这是当前的一个研究热点也是工业界实用的方向。如果完全没标签的数据好获取可以用自监督学习如SimCLR、MoCo先对Backbone进行预训练让模型学习到更通用的视觉表征。如果任务有相关的文本描述比如类别的名称或属性可以引入视觉-语言模型如CLIP的知识。CLIP本身就是一种强大的零样本/少样本模型你可以直接用它的图文匹配能力或者将它的特征作为你模型的补充输入效果立竿见影。4.4 评估陷阱别被“平均准确率”骗了少样本学习的论文里大家通常报告在多个测试任务上的平均分类准确率。但这里有个坑方差可能很大。同样是5-way 1-shot一次随机采样测试可能得到70%的准确率另一次采样可能只有60%。这是因为不同的类别组合难度天差地别。因此在你自己做实验评估时一定要报告多次随机运行的平均值和标准差。通常论文会采用在测试集上随机采样数千个episode如600个或2000个来计算最终均值和95%置信区间。只有这样你的结果才有说服力才能和别人公平比较。另外要关注跨域Cross-Domain泛化能力。比如你的模型是在自然图像数据集如miniImageNet上训练的测试时却用在医学影像或卫星图片上。这才是真正的“泛化”大考。我建议在项目初期就设立一个跨域的测试集时刻监控模型在这上面的表现防止它只是在一个舒适区内过拟合。5. 从理论到应用一个简单的代码实战光说不练假把式。我们用一个简化版的Prototypical Networks在经典的手写数字数据集MNIST上实现一个5-way 1-shot的分类任务。我会用PyTorch框架并加上详细的注释。import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader import torchvision.transforms as transforms from torchvision.datasets import MNIST import numpy as np from tqdm import tqdm # 1. 定义特征提取器一个简单的卷积网络 class FeatureExtractor(nn.Module): def __init__(self): super(FeatureExtractor, self).__init__() self.net nn.Sequential( nn.Conv2d(1, 64, 3), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 64, 3), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 64, 3), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), nn.Flatten() ) def forward(self, x): return self.net(x) # 2. 定义原型网络模型 class PrototypicalNet(nn.Module): def __init__(self, feature_dim, dropout_rate0.5): super(PrototypicalNet, self).__init__() self.feature_extractor FeatureExtractor() # 假设经过特征提取器后特征维度是feature_dim # 实际中需要根据输入图像大小计算这里我们假设已知 self.dropout nn.Dropout(dropout_rate) # 添加Dropout防止过拟合 def forward(self, support_x, query_x): support_x: [N*K, C, H, W], N个类每类K个样本 query_x: [N*Q, C, H, W], 每类Q个查询样本 返回: query样本属于每个类别的负距离logits # 提取特征 support_features self.feature_extractor(support_x) # [N*K, feature_dim] query_features self.feature_extractor(query_x) # [N*Q, feature_dim] # 应用Dropout support_features self.dropout(support_features) query_features self.dropout(query_features) N support_x.size(0) // self.K # 从外部获取或设置K这里假设self.K已定义 feature_dim support_features.size(-1) # 重塑并计算每个类的原型均值 support_features support_features.view(N, self.K, feature_dim) # [N, K, D] prototypes support_features.mean(dim1) # [N, D] 每个类的原型向量 # 计算query样本到所有原型的欧氏距离平方 # 扩展维度以便广播计算 query_features_expanded query_features.unsqueeze(1) # [N*Q, 1, D] prototypes_expanded prototypes.unsqueeze(0) # [1, N, D] distances torch.pow(query_features_expanded - prototypes_expanded, 2).sum(dim2) # [N*Q, N] # 返回负距离作为logits距离越小相似度越高logits越大 return -distances # 3. 构建Episode采样器核心 class EpisodeSampler: def __init__(self, dataset, n_way5, k_shot1, n_query15): self.dataset dataset # 包含所有数据和标签的dataset self.n_way n_way self.k_shot k_shot self.n_query n_query # 按类别组织数据索引 self.class_indices {} for idx, (_, label) in enumerate(dataset): if label not in self.class_indices: self.class_indices[label] [] self.class_indices[label].append(idx) self.classes list(self.class_indices.keys()) def __iter__(self): return self def __next__(self): # 随机选择N个类别 selected_classes np.random.choice(self.classes, self.n_way, replaceFalse) support_indices [] query_indices [] support_labels [] query_labels [] for i, cls in enumerate(selected_classes): # 从该类别中随机选择 K_shot n_query 个样本 all_indices self.class_indices[cls] selected np.random.choice(all_indices, self.k_shot self.n_query, replaceFalse) # 前K_shot个作为support support_indices.extend(selected[:self.k_shot]) support_labels.extend([i] * self.k_shot) # 在本次任务中类别标签重映射为0到N-1 # 后n_query个作为query query_indices.extend(selected[self.k_shot:]) query_labels.extend([i] * self.n_query) # 打乱顺序可选但通常query set需要打乱 query_shuffle np.random.permutation(len(query_indices)) query_indices np.array(query_indices)[query_shuffle].tolist() query_labels np.array(query_labels)[query_shuffle].tolist() # 根据索引获取数据 support_data [self.dataset[idx][0] for idx in support_indices] query_data [self.dataset[idx][0] for idx in query_indices] support_data torch.stack(support_data) query_data torch.stack(query_data) support_labels torch.tensor(support_labels) query_labels torch.tensor(query_labels) return support_data, query_data, support_labels, query_labels # 4. 训练循环 def train_model(model, sampler, optimizer, num_episodes10000): model.train() model.K sampler.k_shot # 将K值传入模型方便forward中使用 for episode in tqdm(range(num_episodes)): # 采样一个episode support_x, query_x, support_y, query_y next(sampler) # 前向传播 logits model(support_x, query_x) # [N*Q, N] loss nn.CrossEntropyLoss()(logits, query_y) # 反向传播 optimizer.zero_grad() loss.backward() optimizer.step() if episode % 500 0: acc (logits.argmax(dim1) query_y).float().mean().item() print(fEpisode [{episode}/{num_episodes}], Loss: {loss.item():.4f}, Acc: {acc:.4f}) # 5. 主函数 def main(): # 数据预处理和加载 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset MNIST(root./data, trainTrue, downloadTrue, transformtransform) # 初始化采样器和模型 n_way, k_shot, n_query 5, 1, 15 sampler EpisodeSampler(train_dataset, n_wayn_way, k_shotk_shot, n_queryn_query) # 需要计算特征维度这里我们根据网络结构推算或用一个假数据前向传播一次得到 dummy_input torch.randn(1, 1, 28, 28) feature_dim FeatureExtractor()(dummy_input).shape[-1] model PrototypicalNet(feature_dimfeature_dim) optimizer optim.Adam(model.parameters(), lr1e-3) # 开始训练 train_model(model, sampler, optimizer, num_episodes5000) print(训练完成) if __name__ __main__: main()这段代码虽然简化但包含了原型网络训练的所有核心要素Episode采样、特征提取、原型计算、距离度量和损失优化。你可以看到EpisodeSampler是如何动态地构建每一个包含support set和query set的小任务。模型在训练中永远不会看到两次完全相同的类别组合这迫使它去学习通用的比较和分类能力而不是记忆特定的类别。在实际运行中你可能需要调整网络深度、特征维度、Dropout率、学习率等超参数。在MNIST这样的简单数据集上5-way 1-shot达到90%以上的准确率并不难。但当你迁移到更复杂的真实数据集时挑战才真正开始。这时前面章节讨论的数据增强、Backbone选择、正则化技巧就全都派上用场了。记住少样本学习是一个系统工程每一个环节的优化都可能带来关键的提升。