从海量走到极少:AI少样本学习笔记
但这次现实很残酷:标注一条医疗文本需要专业医生审核,成本高到令人发指,项目预算能支撑的标注量只有几十条。
为什么需要 Few-Shot
传统监督学习默认训练集和测试集独立同分布,还得有足够数据才能学出稳定模式。医疗文本分类这两个前提往往都不成立。
- 标注成本:医疗、法律、金融这些专业领域,一条标注的成本可能是普通文本的十倍甚至百倍
- 数据漂移:用户会换词、用新概念、塞网络梗,昨天的训练数据今天可能就失效了
- 长尾场景:核心场景数据充足,但长尾场景永远缺样本,而实际问题往往就卡在长尾上
现代大语言模型(LLM)预训练阶段已经见过海量文本,常识、领域知识和模式都在权重里。Few-Shot 更像是给几个示例,把模型已有的理解方式拉到当前任务上,而不是从零教它新知识。
这就好比教一个成年人开车,你不需要从"什么是方向盘"开始讲,只要告诉他这辆车的挂挡逻辑和几个操作要点,他自己就会用已有的驾驶经验迁移过去。
Few-Shot 的几种方式
Zero-Shot:完全不示例
最省事的方式,直接用指令描述任务:
请判断以下文本描述的疾病类型,从以下选项中选择:心血管疾病、呼吸系统疾病、消化系统疾病、神经系统疾病。
文本:患者出现持续性胸痛、呼吸困难、心悸等症状。
这种方式在某些简单任务上有效,但问题明显:模型对任务的理解完全靠指令描述,一旦指令不够精确,结果就会飘。
One-Shot / Few-Shot:给少量示例
给模型几个标准示例,让它模仿:
示例1:
文本:患者出现咳嗽、咳痰、喘息等症状。
分类:呼吸系统疾病
示例2:
文本:患者出现腹痛、恶心、呕吐等症状。
分类:消化系统疾病
文本:患者出现持续性胸痛、呼吸困难、心悸等症状。
分类:?
这种方式的优点是:示例比纯指令更直观,模型能直接从示例中学习到期望的输出格式和判断标准。但缺点也很明显:示例的选择对结果影响巨大,选得不好反而误导模型。
Chain-of-Thought:让模型思考
对于需要推理的任务,可以在示例里加入思考过程:
示例1:
文本:患者出现胸痛、呼吸困难,心电图显示ST段抬高。
思考:胸痛+呼吸困难+ST段抬高是典型的心肌梗死表现,属于心血管疾病。
分类:心血管疾病
示例2:
文本:患者出现咳嗽、咳痰,胸片显示肺部斑片状阴影。
思考:咳嗽、咳痰是呼吸道症状,肺部斑片状阴影提示肺炎,属于呼吸系统疾病。
分类:呼吸系统疾病
文本:患者出现持续性胸痛、呼吸困难、心悸等症状。
思考:?
分类:?
这种方式的好处是让模型学习推理路径,而不仅仅是输入到输出的映射。但会显著增加 token 消耗,对于简单任务可能得不偿失。
实践:从零构建 Few-Shot 分类器
这套方案的核心是:在极端资源约束下,通过精心设计的流程榨干模型已有知识。下面这张图梳理了完整的实践流程,每个步骤都有自己的价值和坑点。
看完流程图有个整体印象后,我们再展开每个步骤的具体做法和踩坑细节。
第一步:明确任务和评估标准
先搞清楚这次到底要做什么,否则后面的所有折腾都是空忙活。
- 任务类型:单标签多分类(每个文本归到一个类别)
- 类别数:4个(心血管、呼吸系统、消化系统、神经系统)
- 输入:非结构化的医疗问诊文本,长度 50-200 字
- 评估指标:准确率(简单粗暴,但够用)
- 可用数据:60 条标注数据,每类 15 条
- 目标:在保留 30 条作为测试集的情况下,用剩下 30 条做 Few-Shot 示例,尽可能达到可用的准确率
第二步:数据预处理
医疗文本里有大量专业术语和缩写,直接丢给模型可能会遇到未登录词。但现代 LLM 的词汇表已经覆盖了大部分医学术语,所以只做了基础的清理:
import re
def clean_text(text):
# 去除多余空白
text = re.sub(r'\s+', ' ', text)
# 标准化标点
text = re.sub(r'。+|!+|?+', '。', text)
# 去除特殊符号(保留中文标点)
text = re.sub(r'[^一-龥。,、;:?!""''()《》A-Za-z0-9]', '', text)
return text.strip()
做完后发现效果没有明显提升,说明现代 LLM 对中文的容错能力已经不错了。这步后来直接砍掉,保留原始文本反而能保留一些诊断线索。
第三步:示例选择策略
只有 30 条数据要做成 4 个分类的示例,每个类别平均只有 7-8 条。这种情况下,怎么选示例就变得关键。
尝试了几种策略:
随机选择:每个类别随机选 N 条
- 问题:可能选到非常相似的例子,覆盖面窄
聚类后选代表:用 embedding 对每个类别的文本做聚类,每个簇选最接近中心的文本
- 优点:能保证示例的多样性
- 问题:聚类本身需要一定量的数据,样本太少时聚类效果不稳定
人工筛选:让领域专家从标注数据里挑出最典型、最有代表性的例子
- 优点:质量有保障
- 问题:需要专家时间,成本高
混合策略:先做简单的相似度去重,再随机选,最后人工微调
- 最终采用了这个,在保证多样性的同时控制了人力成本
选出来的示例有个硬性要求:不能有歧义。医疗文本本来就模糊,如果示例本身就有多种解读,模型会被带偏。
第四步:Prompt 设计
Prompt 写清楚任务比堆形容词重要。试了几个版本:
版本 1(太啰嗦):
你现在是一个资深的医疗诊断专家,拥有多年的临床经验。请根据以下患者的症状描述,判断其可能的疾病类型。
疾病类型包括:
1. 心血管疾病:涉及心脏和血管系统的疾病
2. 呼吸系统疾病:涉及肺部和呼吸道的疾病
3. 消化系统疾病:涉及胃肠道和消化器官的疾病
4. 神经系统疾病:涉及大脑、神经和肌肉系统的疾病
请仔细阅读患者描述,综合考虑症状、体征和可能的检查结果,做出准确判断。
这个版本的问题是:废话太多,token 浪费严重,而且定义反而限制了模型的理解。
版本 2(精简版):
根据文本描述的症状,判断疾病类型。
选项:心血管疾病、呼吸系统疾病、消化系统疾病、神经系统疾病
这个版本够简洁,但缺点是:没有示例,模型容易产生理解偏差。
版本 3(最终版):
判断文本描述的疾病类型,从以下选项中选择一个:
心血管疾病、呼吸系统疾病、消化系统疾病、神经系统疾病
示例1:
文本:患者出现胸痛、气短、下肢水肿等症状。
分类:心血管疾病
示例2:
文本:患者出现咳嗽、咳痰、发热等症状。
分类:呼吸系统疾病
示例3:
文本:患者出现腹痛、腹胀、恶心呕吐等症状。
分类:消化系统疾病
示例4:
文本:患者出现头痛、头晕、肢体麻木等症状。
分类:神经系统疾病
文本:{待分类文本}
分类:
这个版本的优点:
- 指令简洁明确
- 每个类别有一个标准示例
- 示例覆盖了典型症状组合
- 输出格式固定,方便解析
第五步:模型选择和调参
用了两个模型做对比:GPT-3.5-Turbo 和 Claude 3.5 Sonnet。
GPT-3.5-Turbo:
- 优点:速度快,成本低
- 缺点:对专业术语的理解有时不够准确,偶尔会出现"自信地错"
Claude 3.5 Sonnet:
- 优点:专业领域的理解更好,输出更稳定
- 缺点:速度稍慢,成本略高
最终选择了 Claude 3.5 Sonnet,因为医疗场景宁可慢一点、贵一点,也不能让模型胡说八道。
temperature 参数的调整过程:
- 0.0:输出非常稳定,但有时过于保守,在边界案例上容易陷入某个固定答案
- 0.3:稳定性不错,也保留了一定的探索性,最终采用了这个
- 0.7:输出多样性高,但结果不够稳定,同样的输入有时会得到不同分类
踩坑实录
坑 1:示例过拟合
一开始选示例时,为了"精准",选了几个症状描述非常具体的例子。结果在测试集上发现:只要症状和示例高度相似,模型就分类正确;稍微换几种表述,结果就开始飘。
# 示例
文本:患者出现持续性胸痛,伴出汗、心悸。
分类:心血管疾病
# 测试案例1(和示例相似)
文本:患者出现持续性胸痛、心悸、出汗。
分类:心血管疾病 # 正确
# 测试案例2(表述不同但实质相同)
文本:患者说胸口一直疼,还觉得心跳快、出虚汗。
分类:呼吸系统疾病 # 错误
解决方法:减少示例里的具体症状描述,让示例更像"模板"而不是"完形填空"。同时增加一些症状表述的多样性,让模型学到模式而不是死记硬背。
坑 2:类别不平衡
医疗数据天然就是不平衡的:某些病种案例多,某些少。 Few-Shot 时如果每个类别给的示例数量一样,模型会过度拟合到样本少的类别(因为例子太典型了,记不住)。
尝试了几种平衡策略:
- 给少样本类别更多示例:合理,但总样本有限,会导致某些类别示例太少
- 给少样本类别更典型的示例:合理,但需要专家筛选
- 在 prompt 里明确说明类别权重:试了效果一般,模型似乎不太在意这种提示
最终采用了第一种和第二种结合的方式:给少样本类别稍多一点示例,同时确保所有示例的典型程度相近。
坑 3:输出格式不稳定
模型有时会输出多余的词,比如:
分类:心血管疾病
有时又会输出:
应该是心血管疾病
还有时:
心血管疾病
解析时就炸了,需要写一堆正则来处理各种可能。
解决方法:在 prompt 里明确指定输出格式,并在后处理阶段做严格的格式检查:
import re
def parse_classification(output):
# 尝试多种模式匹配
patterns = [
r'分类[::]\s*(\S+)', # "分类:心血管疾病"
r'(\S+)疾病', # "心血管疾病"
r'选项[::]\s*(\S+)', # "选项:心血管"
]
for pattern in patterns:
match = re.search(pattern, output)
if match:
candidate = match.group(1)
# 检查是否在选项中
if candidate in ['心血管', '呼吸系统', '消化系统', '神经系统']:
return candidate + '疾病'
# 如果都不匹配,返回默认值或抛异常
raise ValueError(f"无法解析输出: {output}")
坑 4:上下文长度限制
示例塞多了,加上待分类文本,很容易超长。Claude 3.5 Sonnet 的上下文长度虽然很大,但越长推理越慢,也越不稳定。
尝试了几种优化:
- 压缩示例:把示例里多余的描述删掉,只保留核心信息
- 动态选择示例:根据待分类文本,从示例库里选最相关的 2-3 个,而不是全塞进去
- 示例向量化:把示例压缩成一个总结句,再在后面补充"典型症状:…"
最终采用了动态选择:先用 embedding 算相似度,选最相关的 3 个示例,既保证了相关度,又控制了长度。
结果与反思
折腾了一圈后,在 30 条测试集上的最终结果是:准确率 82%。
这个数字看起来不高,但考虑到只有 30 条训练示例,而且是在专业医疗领域,已经算是可用的水平了。更重要的是,这套方案有几个优点:
- 可扩展性强:新来一个病种,只需要补充几个示例,不需要重新训练
- 成本低:标注成本控制在几十条,可以快速迭代
- 解释性好:每个示例都是人工选的,可以解释模型为什么这么分
对比传统方案(几千条标注数据+微调 BERT),Few-Shot 方案在准确率上虽然略低,但在灵活性、成本和迭代速度上完胜。
当然,这套方案也有明显限制:
- 依赖大模型:成本高,速度慢,不适合实时场景
- 示例质量敏感:示例选得不好,结果会直接崩
- 泛化能力有限:遇到和示例完全不同的表述,还是会出错
如果将来有更多标注数据,可能还是会回到传统微调路线;但 Few-Shot 作为快速验证和原型工具,价值是实打实的。
结语
少样本学习能解的是标注预算紧、又要先跑通一版的问题,别指望几十条示例就能打平大规模微调。
标注资源充足、时间也宽裕,我还是会走传统微调;预算和时间都卡死,或者得快速试错,Few-Shot 值得先上。这次 82% 的准确率本身不是重点,关键是摸清了约束边界——数据量、领域专业度、迭代节奏,决定了该用哪条路。
以后再碰类似项目,我会先用 Few-Shot 搭个 MVP,看短板在哪,再决定要不要砸标注成本。
版权声明: 本文首发于 指尖魔法屋-从海量走到极少:AI少样本学习笔记(https://blog.thinkmoon.cn/post/303-ai-few-shot-learning-massive-minimal-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。