从海量走到极少: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 分类器

这套方案的核心是:在极端资源约束下,通过精心设计的流程榨干模型已有知识。下面这张图梳理了完整的实践流程,每个步骤都有自己的价值和坑点。

flowchart TD A[第一步: 明确任务和评估标准] --> B[第二步: 数据预处理] B --> C[第三步: 示例选择策略] C --> D[第四步: Prompt 设计] D --> E[第五步: 模型选择和调参] A --- A1[定义任务类型<br>类别数/输入格式<br>评估指标] B --- B1[基础文本清理<br>保留专业术语] C --- C1[随机选择/聚类筛选<br>人工微调/混合策略] D --- D1[尝试多个版本<br>迭代优化指令] E --- E1[对比模型性能<br>调优 temperature]

看完流程图有个整体印象后,我们再展开每个步骤的具体做法和踩坑细节。

第一步:明确任务和评估标准

先搞清楚这次到底要做什么,否则后面的所有折腾都是空忙活。

  • 任务类型:单标签多分类(每个文本归到一个类别)
  • 类别数: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/) 转载或引用必须申明原指尖魔法屋来源及源地址!