我接过不少少样本文本分类的需求,最典型的场景就是:客户那边只有一个内部工单分类系统,每个类别只给三十到五十条真实语料,总共十几个类别,让我做一个能扛住线上数据的分类器。你要是把这批数据直接丢给常规的BERT微调,大概率过拟合到飞起,验证集上看着还行,新样本一来就现原形。后来我陆续把原型网络、度量学习、提示调优这三条路线在多个项目里试了个遍,踩了不少坑,也攒了一些能直接用的经验。
这篇文章就是围绕这三个词展开:少样本文本分类、原型网络、度量学习、提示调优。适合正在做NLP落地、被标注数据少折磨的工程师,也适合刚入门少样本研究的学生。我会把底层逻辑讲清楚,把代码细节和调参经验也补上,尽量让你看完之后能直接动手复现。
1. 少样本困境与三条技术路线的底层逻辑
1.1 少样本分类难在哪
先明确一个概念,少样本文本分类指的是每个类别只有极少标注样本,常见形式是N-way K-shot:N个类别,每个类别K个标注样本。K通常是1、2、5这种级别,最多不超过20。这种设定下,传统监督学习的根基——大量的梯度更新和复杂的决策边界——完全站不住。
难在三个地方。第一,参数多、数据少。一个基础的文本分类模型从随机初始化的分类头开始学,嵌入层、Transformer层几千上万维的参数,靠几十个样本根本训不动。第二,类别分布不可靠。少量样本无法代表整个类别的语义分布,尤其是文本这种表达自由的模态,同一个意思可以有十几种说法。第三,评估噪声大。少样本场景下模型性能对支撑集非常敏感,换一组样本,准确率能抖好几个点,调参时根本分不清是模型真变好了还是运气好。
所以少样本分类的核心不是设计一个更复杂的分类器,而是设计一种“不依赖大量样本也能产生决策能力”的机制。原型网络、度量学习、提示调优,分别从三个完全不同的角度切进这个问题。
1.2 三条路线的观察视角
原型网络的核心思想:每个类别用一小簇样本的“平均值”代表,分类时看查询样本离谁近。就像你只在照片里见过某个人三次,但你对他的印象会自然形成一个“平均脸”,下次见到他,凭直觉能认出来。技术上就是把同类别的所有样本向量取均值,得到类原型,然后比较距离。
度量学习的视野更宽:它根本不关心学到的向量是什么方向,只关心样本之间的“相对距离”——同类样本拉近,异类样本推远。在这个框架下,原型网络只是度量学习的一个特例:类别原型的均值代表+距离判断。度量学习可以灵活替换距离函数、设计更复杂的相似度网络,甚至用对比学习的方式做数据增强。
提示调优的思路完全不一样。它不训练分类头,也不重新学距离空间,而是把文本分类任务改造成预训练语言模型原本就擅长的“预测被遮住的词”。举个例子,判断一条评论情感,直接让模型回答“这真是一部[MASK]的电影”,让模型去填“好”还是“烂”。这种方式能最大化释放预训练模型里已经沉淀的语言知识和世界常识,是少样本场景下性价比极高的方案。
2. 原型网络与度量学习的核心细节拆解
2.1 原型网络的形式化定义
原型网络的设计简洁得令人意外。假设有一个支撑集S,包含N个类别,每个类别K个样本。先用一个编码器f把每个文本映射成向量。然后对每个类别c,计算其所有支撑样本向量的均值,得到原型向量:
p_c = (1 / K) * sum(f(x_i)), for x_i in class c
对查询集里的样本x_q,计算它到每个原型的距离,再通过softmax转换成概率:
P(y = c | x_q) = exp(-d(f(x_q), p_c)) / sum_{c'} exp(-d(f(x_q), p_{c'}))
这里的d是距离函数,原文用的是欧氏距离的平方,并且整个向量的范数在训练前会被归一化。代码实现很短,我直接给一个PyTorch风格的伪代码,方便你对照理解:
import torch import torch.nn.functional as F def prototypical_loss(embeddings, labels, num_classes, centroids=None): """ embeddings: 支撑集+查询集的编码向量,shape [B, D] labels: 当前batch的类别标签 返回: 查询集上的交叉熵损失 """ # 每个类别的原型 = 该类别support样本向量的均值 if centroids is None: centroids = [] for c in range(num_classes): class_mask = (labels == c) class_vecs = embeddings[class_mask] centroids.append(class_vecs.mean(dim=0)) centroids = torch.stack(centroids) dists = torch.cdist(embeddings, centroids, p=2) logits = -dists ** 2 return F.cross_entropy(logits, labels)注意,这个写法只适合支撑集和查询集放在同一个batch里前向一次的场景。更规范的做法是训练时每个episode都好说,先编码支撑集得到原型,再编码查询集算距离,两部分的梯度都会回流到编码器里。
为什么均匀均值能作为类别代表?统计上,如果同一类别的向量围绕某个语义中心聚集,样本均值是无偏估计。在高维语义空间里,单个样本可能落在类别的边缘或某个语义子区,均值能把这些偏差中和掉,比拿随便一个支撑样本当代表要稳得多。这也是原型网络在1-shot下仍有不错表现的原因:就算只有一个支持样本,只要编码器好,这个唯一样本也能落在相似的位置区域里。
2.2 距离函数的选择与温度系数
我实际用下来的经验是,距离函数的选择对最终效果影响很大,开源代码里最常见的三种:欧氏距离、平方欧氏距离、余弦相似度。少样本场景下,我推荐用平方欧氏距离,但前提是嵌入向量都做L2归一化。
原因在于,欧氏距离对向量模长敏感,如果某个类别的支撑样本向量模长偏大,它离所有查询点都会更远,相当于模型在偷偷学“谁更自信”,而不是学语义相似性。L2归一化之后,欧氏距离和余弦相似度在数学上倾向一个单调关系,但平方欧氏距离对应的是余弦相似度的线性变换,梯度行为更好,不像直接求余弦那样在向量接近时梯度趋近于零。
另外很重要的一点是温度系数。原型网络原论文里公式没有温度,但实践中几乎都要加一个可学习的温度参数,也就是把距离除以γ再送进softmax:
logits = -dists / γ
γ越小,概率分布越尖锐;γ越大,分布越平缓。少样本训练很容易过自信,也就是查询样本和某个原型距离已经很远了,却仍然给出接近1的概率。加上一个可学习的γ后,模型可以自己校准confidence,训练稳定性明显提升。起步值我一般设0.05到0.1,丢进优化器跟着学就行。
2.3 训练时的采样方式与坑
原型网络的训练采用episode式采样,也叫元学习式训练。每一轮构造一个小任务:从训练集里抽N个类别,每个类别抽K个样本作为支撑集,再抽M个样本作为查询集。然后在当前episode上计算损失、回传梯度。
N-way K-shot 的选择要跟目标场景对齐。你要做5-way分类,训练时最好也设为5-way,或者比5更大一点,比如8-way训练,评估时5-way。因为原型网络在训练时见过更多类别的区分任务,会逼迫编码器学到更细的区分离散能力。K-shot同样如此,目标1-shot就练1-shot,目标5-shot就练5-shot,不要用5-shot训练然后期望它在1-shot时表现完美。
这个环节最容易翻车的是数据泄漏和重复文本。曾有一次我把原始语料里的重复样本直接塞进支撑集和查询集,同一个句子两个标签,模型直接用“背题”的手法把损失降到很低,跑出来的指标完全失真。处理方式很简单:做基于正文的去重,把完全一致的句子哈希化,若重复则只在支撑集保留一份。
3. 提示调优在少样本分类中的实操路径
3.1 从“训练分类头”到“填空”的转变
传统微调做文本分类是在预训练模型后面接一个随机初始化的分类头,然后微调全部参数。少样本下,这个分类头缺乏足够的梯度信号,而随机初始化的部分又很容易把预训练知识带歪。提示调优的出发点,就是不引入任何新参数,让模型用最初预训练时的能力来做分类。
具体到文本分类,做法是构造一个提示模板。假如我要判断一条评论的情感,模板可以设计成:
“这条评论的情感是[MASK]的。评论:电影还不错,演员演得挺好。”
然后让模型预测[MASK]位置的词,把它映射到“正向”或“负向”。模型还在Pre-training阶段就见过这种“观点+情感评价”的语言模式,所以哪怕不给任何标注样本,它也知道这里大概率会填“正面”“好”之类的词。这就是提示调优能够在极低资源下工作的原因——它不是在学新任务,而是在唤醒旧能力。
3.2 模板与标签词的设计
模板设计看起来简单,实际上细节很多。最省事的做法是用手工模板,比如“这是一个关于[类别]的文本”,把类别名填进去。但纯填类别名有两个问题:一是类别名可能不在预训练词表里,二是类别名并不一定适合作为[MASK]的答案。
更好的做法是标签词映射。类别是“交通”,就映射成“交通罚款”“堵车”这类词,而不是直接用“交通”这个抽象词。实例是“道路拥堵严重”,模板可以是:
“这段话讨论的主题可以概括为[MASK]。段落:道路拥堵严重”
模型在“概括为”后面填出的词,才对。如果标签词和类别语义关联不够强,可以给同一个类别准备多个候选标签词,取所有候选得分的平均,或者选最大。
还有一个隐藏问题:模板的语序和措辞会影响概率分布,严重时甚至造成“模板偏差”。同一个文本,换个模板,模型给[MASK]填的词都不一样。缓解手段是训练时给模板加入少量随机扰动,比如在句子前后插入不同的衔接词,让模型不依赖某个固定模板的措辞。这个技巧在只有几十条标注数据时意外地有效。
3.3 连续提示与Prompt Tuning的正规操作
手工模板属于离散提示,它的问题是可搜索空间极小,人工找起来非常费劲,而且每换一个领域就要重新设计。连续提示,也就是soft prompt,把模板从离散token变成一组可学习的连续向量,放在连续输入的最前面或中间,与模型一起微调。
具体操作是这样的:准备一段特殊的张量,形状和token embedding一致,记为prompt参数;把文本token embedding和这个prompt参数拼接起来,送进Transformer;只更新prompt参数和最后的匹配层,冻结整个预训练模型。训练几轮后,模型向量空间里找到适合当前任务的“虚拟提示词”。
在少样本场景下,我一般把连续提示和原型网络组合起来用。标准的用法分为两步:先用提示模板做预测,把[MASK]位置的目标token embedding提取出来,当作查询文本的表示;然后接原型网络的部分,通过支撑集构建原型,用距离做最终判定。这种混用方案要比只在一个小型预训练模型上单独做提示调优稳定不少,因为提示得分本身依然复核语义,原型的结构又解决了类别间界限模糊的问题。
3.4 大模型时代的补充思考
现在一提到提示,很多人第一反应是给大模型写prompt。但少样本分类场景下,小模型加提示调优依然有价值。推理成本低、可离线部署、结果可复现,这对很多业务系统来说比“能聊”更重要。大模型的上下文学习确实强,但在数据隐私要求高、响应时延必须压到几十毫秒的场景里,拷贝一个大模型回来跑是很不现实的。
另外一个值得尝试的折中方案是:在少量训练数据上先给大模型做轻量级适配,再用它去生成伪标签,扩充小模型的训练集。伪标签的质量可以用置信度阈值控制。当然这套路有点脏,跑实验时要仔细清洗,不然会引入大量噪声。
4. 三条路线怎么选与混合使用
4.1 方案对比
我整理了一个表格,直接对比三条路线在几个关键维度上的差异。这张表是基于我在项目里的基准效果总结的,可以作为方案选型的参考。
| 维度 | 原型网络 | 度量学习 | 提示调优 |
|---|---|---|---|
| 适用模型规模 | 中小型预训练模型均可 | 中小型预训练模型均可 | 需要带[MASK]或生成能力较强的语言模型 |
| 额外训练成本 | 低,编码器可冻结也可微调 | 低,主要学习距离空间和表示 | 中,需要设计模板或训练soft prompt |
| 数据需求量 | 每类几个样本即可起步 | 每类几个样本即可起步 | 每类零样本到几个样本均可用 |
| 可解释性 | 看距离和原型向量 | 看距离和负样本对比 | 看[MASK]词得分,直观 |
| 推理速度 | 快,只需一次编码加距离计算 | 快,视距离函数复杂度 | 中,需要同时考虑多个候选词得分 |
| 稳定性 | 较高,但依赖特征质量 | 较依赖负样本构造 | 受模板质量影响明显 |
在小模型、样本极少、时间紧的约束下,原型网络是我第一选择。如果手头有大的中文预训练模型,或者有足够的离线算力可以跑多次推理,提示调优的上限会更高。度量学习通常不是直接拿来用,而是作为底座,嵌入到其他方案里。
4.2 从项目视角给选型建议
第一类需求:客户给的样本量极少,十几个类别,每类只有几条数据,并且要快速上线。这种情况我会直接上原型网络。编码器用轻量级中文预训练模型,冻结前几层,训练最后两层加一层原型距离,几分钟就能跑出可用版本。因为原型网络只需要维护类别中心向量,在线预测时不需要额外保存支撑集,内存占用小。
第二类需求:数据量比“几根头发丝”强一点,每类能有四五十条,但类别语义跨度大、容易混淆。这种情况下提示调优混合方案的收益很高。可以先跑一个soft prompt,把预训练模型的先验知识调动起来,再跟输出层接一个原型距离,从而形成“提示调优+原型输出”的组合。
第三类需求:零样本,一个标签都没有。直接手工做离散提示,配合候选标签词的得分平均,就能得到一个比乱猜强得多的分类器。这个方案我跑过几次,准确率在特定场景下能到70%到80%,属于应急方案里效果最稳定的。
4.3 一种推荐的混合训练框架
做过很多少样本项目后,我目前最喜欢的流程是:
- 先用离散提示给全部无标注数据打伪标签,置信度高于0.9的保留。
- 把人工标注的真实少量样本和伪标签样本合在一起,训练一个原型网络。
- 训练完成后,再用原型网络对低置信度样本做二次分类,挑出矛盾样本,送人工复核。
- 复核后的样本再回到训练集,迭代一到两轮。
这个流程能有效放大数据来源,同时保留原型网络不依赖大量标注的核心优势。在竞标和快速交付的项目里,这套流程让我在标注数据不到两百条的情况下,把分类准确率从65%拉到85%。当然伪标签的噪声不可完全消除,所以我在第3步加了人工复核环节,这个环节不能省。
5. 常见问题与排查技巧实录
5.1 训练掉点的常见原因
少样本场景下,模型效果差很多时候不是因为模型不行,而是训练细节出了问题。我把在项目里实际踩过和帮别人排查到的坑总结成几张速查表,每条都对应具体的排查方法。
| 现象 | 常见原因 | 排查方式 |
|---|---|---|
| 损失不下降 | 支撑集样本在所有类别上编码后高度相似 | 检查编码器是否冻结过度,或训练数据里类别本身缺乏区分度 |
| 验证指标波动极大 | 支撑集划分不固定,评价噪声高 | 用多个随机种子重复评估,报告均值与方差 |
| 1-shot比5-shot效果还差很多 | 训练时N/K配比与评估不一致 | 检查训练episode的采样设置,与评估设置对齐 |
| 模型对某类完全分不出来 | 该类别的支撑样本包含噪声或过短文本 | 去查看支撑集具体样本,人工清洗掉离群文本 |
| 概率分布极端集中 | softmax温度过小或标签平滑缺失 | 加入温度缩放或标签平滑 |
其中“损失不下降”这块儿,我遇到最多的情况其实是标签映射出错。比如训练数据里标签是中文,原型的类别索引却按英文顺序排,等于模型在瞎学映射关系。检查目标编码和类别ID的关系,是第一件事。
5.2 评估指标的坑
少样本分类的评估,比常规分类更讲究可信度。很多人直接拿一次训练的结果汇报准确率,这是特别不严谨的。因为支撑集样本只有几十条,随机抽一批和抽另一批,准确率可能从72%跳到86%。正确的做法是至少跑5个随机种子,报告平均值和标准差。更严格的做法是采用k-fold评估,把每类样本分成几折,轮流当支撑集和查询集,然后汇总统计。
还有一个实际陷阱,候选标签词的得分。模型填[MASK]位置时,如果是单一答案,模型很可能把所有备选词的分数都压在一个很小的量级上,导致softmax看起来像硬编码。解决办法是给每个标签设置多个候选词并且限制得分范围,或者用一个校准层自动调节温度。
5.3 原型向量被带偏的补救方法
原型网络一个受人诟病的点在于,均值很容易被个别极端样本带偏。少样本下尤其严重,比如类别是“退款问题”,5条支撑样本里有4条都是关于“退货慢”的,原型就被带成了“物流慢”,查询样本再来一条“怎么申请退款”,距离反而拉大。
补救手段有几个。一是对支撑集做离群点剔除,计算每个支撑样本到自身类别中心的距离,超过某个阈值的直接删掉。二是给原型加一个缩放向量,也就是训练一组每维度的可缩放系数,模型自己根据真实数据调整每个维度对类别贡献的权重。三是用注意力机制代替简单均值,给每个支撑样本分配一个权重,离群样本权重自然降低。后面这个方案实现稍复杂,但效果提升明显,适合在意精度的生产项目。
写在最后的实操体会
这几个技术在真实业务里的表现让我后来明显更倾向组合方案,而不是按某一个算法死磕。原型网络解题路径最顺畅,但它缺乏对预训练知识的高效利用;提示调优知识利用率高,但很容易被模板设计绑架。两个结合起来,刚好互相补齐弱点。
如果一定要给一条最简单直接的实操建议:你在小模型、低资源、少标注的条件下,先用原型网络做一版快速基线,同时用离散提示做伪标签,然后挑出冲突样本过一遍人工复核。这一套流程不依赖复杂的框架,代码量也不大,却是几乎所有少样本项目里我最有把握的出活组合。数据永远不够,但方法论可以复用得足够好。