AI 迁移学习踩坑记录

去年接了一个中文医疗问答分类的项目,数据量只有 3000 条,标注质量一般,模型效果一直上不去。折腾了一圈发现,同样是 BERT-base,别人能调到 92% F1,我就卡在 85% 上不去。

问题背景

医疗问答分类的场景不算复杂:输入一段用户咨询,输出预设的 10 个类别(症状描述、用药咨询、检查解读、预防建议等)。但有几个硬性限制:

  • 数据量少,只有 3000 条样本,类别分布不均匀,最小类只有 80 条
  • 医疗术语多,通用模型对"CT增强扫描"、“孕早期唐筛"这类词理解有限
  • 对准确率要求高,把用药咨询误判成预防建议在医疗场景下后果比较严重
  • 部署环境有限,推理时间要控制在 50ms 以内,无法上超大模型

一开始直接用了 hfl/chinese-roberta-wwm-ext,Fine-tune 了一轮后发现效果一般。想想也知道,这个模型是在大规模通用中文语料上训练的,医疗领域的专业知识和语言风格覆盖有限。

迁移学习思路

迁移学习在 NLP 里的典型路径是:大规模通用预训练 → 领域语料继续预训练 → 任务数据微调。这三个阶段解决了不同层次的问题:

  • 通用预训练:让模型学会语言的基本结构和语义表示,这是"基础能力”
  • 领域预训练:让模型适应特定领域的词汇、表达习惯和知识分布,这是"专业能力"
  • 任务微调:让模型学会处理具体任务,比如分类、抽取、生成,这是"应用能力"

用个通俗的类比:通用预训练像读完小学,掌握读写算;领域预训练像进入医学院,学会医学术语和临床思维;任务微调像实习值班,学会怎么真正处理病人。

graph LR A[大规模通用语料] -->|预训练| B[基础语言模型] B -->|领域语料| C[领域适配模型] C -->|任务数据| D[任务专用模型] B -.->|直接使用| D' C -.->|直接使用| D''

这里有个重要经验:不要跳过领域预训练。除非你的领域非常通用(比如新闻、电商、社交媒体),否则从通用模型直接到任务微调,数据少的情况下效果通常不如完整路径。

领域预训练实施

收集领域语料是第一道坎。医疗领域的公开语料资源有限,我是这么解决的:

  • 整理了 10 万条公开医疗问答数据,来自中文医学问答数据集和开源医疗社区
  • 加上了 5 万条医疗指南和科普文章,来源包括卫健委官网、三甲医院官网
  • 数据清洗做了两件事:去掉明显的噪声(HTML标签、乱码)和过滤重复内容

清洗前后的数据统计:

# 原始数据统计
raw_samples = 180000
raw_unique = 125000
after_dedup = 120000
after_quality_filter = 150000

# 最终用于预训练的数据
train_size = 120000
vocab_coverage = 0.85  # 医疗术语覆盖率

预训练任务用了标准的 Masked Language Modeling (MLM),但做了几个调整:

  1. Mask 策略调整:对医疗术语(长词、专业词)的 mask 概率提高到 20%,普通词保持 15%。这样能让模型更关注领域专有词汇的学习。
  2. 训练长度选择:句子长度控制在 512,但统计了实际医疗咨询的长度分布,70% 落在 128 以内,所以后期主要在 128-256 长度上强化训练。
  3. 学习率策略:使用了 3 个 epoch,学习率从 5e-5 线性衰减到 0,warmup 比例 10%。
# 关键配置片段
from transformers import BertConfig, BertForMaskedLM, BertTokenizer

config = BertConfig.from_pretrained('hfl/chinese-roberta-wwm-ext')
config.vocab_size = tokenizer.vocab_size

model = BertForMaskedLM.from_pretrained(
    'hfl/chinese-roberta-wwm-ext',
    config=config
)

# 自定义 masking 策略
def mask_tokens(inputs, tokenizer, mlm_probability=0.15, term_mask_prob=0.20):
    """
    对医疗术语提高 masking 概率
    """
    labels = inputs.clone()
    probability_matrix = torch.full(labels.shape, mlm_probability)
    special_tokens_mask = [
        tokenizer.get_special_tokens_mask(val, already_has_special_tokens=True)
        for val in labels.tolist()
    ]
    probability_matrix.masked_fill_(torch.tensor(special_tokens_mask, dtype=torch.bool), value=0.0)

    # 识别医疗术语(这里简化处理)
    medical_terms = identify_medical_terms(inputs, tokenizer)
    for term_positions in medical_terms:
        probability_matrix[term_positions] = term_mask_prob

    masked_indices = torch.bernoulli(probability_matrix).bool()
    labels[~masked_indices] = -100
    indices_replaced = torch.bernoulli(torch.full(labels.shape, 0.8)).bool() & masked_indices
    inputs[indices_replaced] = tokenizer.convert_tokens_to_ids(tokenizer.mask_token)

    return inputs, labels

预训练花了大约 6 小时(单卡 RTX 3090),loss 从 2.8 降到了 1.7 左右。训练过程中做了一次中期评估,用 100 条医疗句子做 fill-mask 测试,对专业词的预测准确率提升了 25 个百分点。

任务微调

领域预训练完成后,拿到了一个"懂医疗"的 BERT 模型。接下来是任务微调,这次就比较标准了。

数据准备

3000 条任务数据做了这样几件事:

  • 类别平衡处理:对小于 200 条的类别做了数据增强(同义词替换、回译),同时限制最大类样本数
  • 划分比例:训练集 2400、验证集 300、测试集 300
  • 文本截断:统计长度分布后,截断位置设为 256(足够覆盖 95% 的样本)
# 数据增强示例
def augment_text(text, augment_ratio=1.5):
    """对小类数据进行增强"""
    augment_methods = [
        synonym_replace,
        back_translate,
        random_insert,
    ]
    aug_texts = [text]
    for _ in range(int(augment_ratio - 1)):
        method = random.choice(augment_methods)
        aug_texts.append(method(text))
    return aug_texts

# 类别统计和平衡
class_counts = Counter(train_labels)
min_class_size = 200
balanced_data = []

for text, label in zip(train_texts, train_labels):
    if class_counts[label] < min_class_size:
        aug_texts = augment_text(text, min_class_size / class_counts[label])
        balanced_data.extend([(t, label) for t in aug_texts])
    else:
        balanced_data.append((text, label))

模型配置

分类头用了最简单的线性层加 softmax,输出 10 个类别:

from transformers import BertForSequenceClassification

model = BertForSequenceClassification.from_pretrained(
    './domain_pretrained_medical_bert',
    num_labels=10
)

训练参数是调了几轮后定下来的:

training_args = TrainingArguments(
    output_dir='./results',
    num_train_epochs=5,
    per_device_train_batch_size=16,
    per_device_eval_batch_size=32,
    warmup_steps=100,
    weight_decay=0.01,
    logging_dir='./logs',
    logging_steps=50,
    evaluation_strategy='epoch',
    save_strategy='epoch',
    load_best_model_at_end=True,
    metric_for_best_model='f1',
    learning_rate=2e-5,
)

学习率从 5e-5 开始试,发现 2e-5 效果最好,过小收敛慢,过大容易过拟合。

效果对比

把各阶段 F1 串起来看,领域预训练是最大跃升,数据增强和学习率调优属于锦上添花。

医疗问答分类迁移学习路径上的测试集 F1 变化

从 0.85 到 0.93 的增益主要来自中间那步领域预训练,而不是在通用模型上反复微调。

几个关键节点的效果对比(测试集 F1 score):

方案F1推理时间 (ms)训练耗时
通用 RoBERTa 直接微调0.85421.5h
+ 数据增强0.87422.0h
+ 领域预训练0.92448.5h
+ 调优学习率0.93448.5h

推理时间增加 2ms 基本可以接受,部署时做了 FP16 量化,能压到 35ms 以内。

踩坑记录

这个项目踩的坑不少,挑几个典型的说。

坑一:预训练数据质量问题

一开始没做严格的数据清洗,导致模型学到一些错误的知识。比如有些社区问答里混杂了广告、推广内容,模型会把"某某药效果好"当成普遍规律。

后来加了几个过滤规则:

def is_valid_sample(text):
    """数据质量过滤"""
    # 过滤广告关键词
    ad_keywords = ['广告', '推广', '优惠', '购买链接']
    if any(kw in text for kw in ad_keywords):
        return False

    # 过滤过短文本
    if len(text.strip()) < 10:
        return False

    # 过滤非中文为主的内容
    chinese_ratio = len(re.findall(r'[一-鿿]', text)) / len(text)
    if chinese_ratio < 0.7:
        return False

    return True

坑二:领域过拟合

领域预训练完成后,直接在任务数据上微调,发现模型对医疗相关的样本效果好,但对一些日常用语反而"退步"了。这是因为领域预训练让模型过度聚焦领域知识,通用能力下降。

解决方法是做了一点"混合训练":在任务微调阶段,保留 10% 的通用样本作为正则化,避免模型完全忘记通用语言能力。

# 混合训练数据
domain_train = load_medical_qa_data()
general_train = load_general_qa_data()[:200]  # 少量通用数据

mixed_train = domain_train + general_train
shuffle(mixed_train)

坑三:类别不平衡处理不当

一开始用了 oversampling,发现模型容易记住重复样本,测试时泛化能力差。后来改用数据增强,虽然效果好一些,但生成的文本有时候不太自然,反而引入噪声。

最后的方案是:对小类做适度增强,同时对大类做一定程度的欠采样,保持整体类别分布相对均衡,但不强制完全平衡。

坑四:评估指标误导

一开始只关注准确率,发现模型在最大类上表现很好,但小类 F1 很低。后来改用加权 F1 和 macro F1 结合评估,确保模型在各类别上都有合理表现。

from sklearn.metrics import precision_recall_fscore_support

def compute_metrics(pred):
    labels = pred.label_ids
    preds = pred.predictions.argmax(-1)

    precision, recall, f1, _ = precision_recall_fscore_support(
        labels, preds, average='weighted'
    )
    macro_f1 = precision_recall_fscore_support(
        labels, preds, average='macro'
    )[2]

    return {
        'accuracy': (preds == labels).mean(),
        'f1': f1,
        'precision': precision,
        'recall': recall,
        'macro_f1': macro_f1  # 关注小类表现
    }

结果与经验

最终模型在测试集上的表现:

  • Weighted F1: 0.93
  • Macro F1: 0.91(小类表现尚可)
  • 推理时间: 35ms(FP16 量化后)

部署后做了线上 A/B 测试,相比之前的规则系统,用户满意度提升了 18%,误分类率降低了 30%。

这次实践总结了几条比较实在的经验:

  1. 数据量小时,迁移学习是必须的,但路径要对:不要指望从通用模型直接跳到任务效果就好,中间的领域预训练很关键。

  2. 领域预训练的质量比数量更重要:5 万条高质量的领域语料,可能比 20 万条低质量数据效果更好。

  3. 不要忽视推理时间:训练时上各种花哨技巧没问题,但部署时要考虑实际环境,适当做量化和剪枝。

  4. 评估指标要贴合业务:准确率好看不代表什么,要看业务真正关心的是什么(比如医疗场景下假阳性的成本)。

  5. 不要迷信"越大越好":对于这个项目,BERT-base 加上合理的迁移学习路径,效果已经足够,没必要上更大模型。

一些延伸思考

迁移学习在 NLP 里已经成了标准做法,但实际落地时还有很多细节需要根据场景调整。

比如这次医疗问答场景,数据量确实小,但如果数据量达到几万条级别,直接从通用模型微调可能就够了,领域预训练的边际收益会下降。反之,如果是一些更垂直的领域(比如法律文书、金融分析),领域预训练的价值会更大。

另一个值得提的点:预训练和微调不是"一次就完事"的。随着业务发展,新数据、新类别不断出现,模型需要持续迭代。一个合理的策略是:定期用新数据做领域预训练更新,然后在新任务数据上微调。

timeline title 迁移学习迭代时间线 section 初始阶段 通用预训练 : 使用开源模型 领域预训练 : 15万条医疗语料 任务微调 : 3000条任务数据 section 迭代阶段 新数据积累 : 每月新增500条 定期领域更新 : 季度更新领域模型 任务数据扩展 : 类别扩展到15个

最后说句实话:迁移学习不是银弹,数据量特别大时直接训练可能更高效,特别小时可能效果也有限。但它提供了一个相对可靠的基础路径,尤其适合那些"数据不太少也不太多"的场景。关键是要根据实际需求调整路径,不要照搬论文里的标准流程。

折腾到现在,这个模型已经在生产环境跑了 8 个月,每月迭代一次,效果还算稳定。迁移学习这条路,算是走通了。

版权声明: 本文首发于 指尖魔法屋-AI 迁移学习踩坑记录https://blog.thinkmoon.cn/post/307-ai-transfer-learning-pretraining-finetuning-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!