在我第一次尝试用 Hugging Face 训练多选问答模型时,我天真地以为只需要在数据集上添加一些标签,然后让 Transformer 去拟合即可。结果我花了三个晚上,准确率停留在 65%,而这还是我满怀期待地认为"效果不错"的结果。后来我才意识到,所谓的多选问答,在做题人眼里是一个综合理解任务,但在模型眼里,每一步都藏着格式、数据、损失函数和解码策略的连环陷阱。
多选问答(MCQA)在国内的考试场景、阅读理解、知识蒸馏里出镜率极高,但网上能找到的教程大多是拿英文基准数据集跑个现成脚本,一旦换到自己手头的中文题库或者业务上下文里,很容易直接翻车。这篇文章我会完整复盘一次基于 Hugging Face 生态做多选问答微调的全过程,从数据构造、模型选型、损失函数设计到推理方式,把踩过的坑和最后的关键调整都摊开讲。适合那些已经能跑通文本分类,但想在复杂任务上更进一步的人。
1. 多选问答的本质是什么:为什么它对模型来说比普通文本分类难得多
首先要打破一个思维定式:多选问答不是"四个标签的分类"。虽然最后你想让模型输出 A/B/C/D,但如果你真的把它当成四分类来训,效果基本都会很惨。我见过不少同学直接把问题文本丢进去,然后把选项编号当作标签做 CrossEntropyLoss,结果训练集都能过拟合到 90%+,验证集只有 60% 出头,典型的特征学习失败。
1.1 多选题在模型眼里不是"分类",而是"匹配"
为什么分类视角不行?因为模型根本没有合理利用选项的内容。你在四个选项里塞了完全不同的措辞,如果只把"选项编号"当作类别,模型只能去记忆问题文本与编号之间的隐式映射,压根没有让模型去对比选项的语义。一个有效的多选问答模型,本质上是让编码器分别计算 问题+每一个候选选项 的联合语义得分,然后选得分最高的那个。
这也是为什么 Hugging Face 上很多多选问答模型并不是在输出端直接吐字母,而是在输入端拼接问题与选项,用编码器输出一个分类概率。在训练时,你会看到数据被组织成类似 "(question, candidate_option)" 的二元组,模型的职责是判断这个组合是否合理。
提示:核心不在于让模型认识 ABCD,而在于让模型学会比较。分类损失只是比较结果的一种封装而已。
1.2 输入格式的设计直接决定模型能力的上限
Hugging Face 生态里最头疼的就是不对齐的数据格式。你手上有 question(问题)、candidate answers(候选答案列表)、correct answer(正确编号)。微调前,你第一个要想清楚的是:模型在看什么。
我常用的格式是把问题与每个候选选项拼接成独立样本:
- Sample 1:
[CLS] 问题文本 [SEP] 选项A文本,标签 = 1 - Sample 2:
[CLS] 问题文本 [SEP] 选项B文本,标签 = 0
然后整体构成一个 batch 内的二元分类问题。这样做的好处非常直观:模型可以分别编码每个选项,最终在多分类阶段对比 logits。但是一个隐形问题也随之而来——如果选项特别长(比如某些阅读理解的选项是完整的一段话),序列长度会爆炸,直接拉低单卡能跑的 batch size,也拖慢收敛速度。
另一种常见格式是"单序列多选项",即 [CLS] question + optionA + optionB + optionC + optionD [SEP],然后做池化。这种写法能省 token,但在编码器(如 BERT 系列)里,不同选项之间的注意力会互相干扰,尤其是当选项之间语义高度重合时,模型很容易被中间那个选项带偏。我个人的实测结论是:多选项拼接适合大模型生成式推理,不适合中小尺寸的判别式微调;判别式微调老老实实用独立拼接更稳。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据准备:基于 Hugging Face datasets 构造项目专属的多轮问答格式
既然输入格式定下来了,接下来就是工程落地。很多人会直接写一个自定义的 torch.utils.data.Dataset,然后再用 DataLoader 加载。这当然能行,但我推荐你过一遍 Hugging Face 的 datasets 库,因为它天然支持缓存和内存映射,非常省心,而且在 shuffle 和过滤上要比自己写方便好几个量级。
2.1 从原始 JSON 到 DatasetDict 的转化链路
假设你拿到的数据是这样的 JSON:
json复制{
"question": "下列哪项属于操作系统的基本功能?",
"options": [
"处理机管理",
"数据库查询优化",
"语法树生成",
"协议栈加密"
],
"answer": "A"
}
你要先把它变成 Hugging Face datasets 能直接消费的字典列表,一般是一个 list,每个元素包含 question、option_text、label。注意这里 label 的含义是"该选项是否正确",也就是说,模型最终看到的是一个二分类信号,而不是 ABCD 的四分类信号。
python复制from datasets import Dataset, DatasetDict
def transform_to_binary_samples(raw_data):
samples = []
for item in raw_data:
question = item["question"]
options = item["options"]
correct_idx = ord(item["answer"]) - ord("A")
for idx, opt in enumerate(options):
samples.append({
"question": question,
"option_text": opt,
"label": 1 if idx == correct_idx else 0,
"qid": len(samples) # 用于后续分组统计
})
return samples
samples = transform_to_binary_samples(raw_data)
dataset = Dataset.from_list(samples)
dataset = dataset.train_test_split(test_size=0.1, seed=42)
转换完你就拥有了一个 DatasetDict,里面 train 和 test 各自都是样本级别的数据集。这里有一个非常关键的点:划分的时候必须按题划分,而不是按样本划分。如果不注意,同一道题的正确答案样本和错误答案样本会同时出现在训练集和测试集,导致指标虚高,甚至出现模型靠记忆而不是推理解题的假象。
注意:建议提前在每一道题上生成一个唯一
question_id,然后按question_id做分组划分,比如按 id 哈希归属训练/测试集合。这步省略掉,后面所有评估都会失真。
2.2 Tokenize 时的长度对齐与标签广播
在 tokenize 阶段,最经典的错误是忘记把 label 和 input_ids 一起写入最终特征。写代码的时候可能眼前是清楚的,但一旦 map 的并行参数设置不对,或者返回值里少了字段,训练时 debug 到崩溃。
我的标准做法:
python复制from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") # 举例,按需替换
def preprocess_function(examples):
texts = [
f"问题:{q}\n选项:{opt}"
for q, opt in zip(examples["question"], examples["option_text"])
]
encodings = tokenizer(
texts,
truncation=True,
max_length=256,
padding="max_length",
)
encodings["labels"] = examples["label"]
return encodings
tokenized_dataset = dataset.map(preprocess_function, batched=True, remove_columns=["question", "option_text"])
注意我这里把 max_length 设成了 256,实际项目中你可能要根据最长样本做统计。如果选项是长文本,宁可把 max_length 拉到 512 也不要依赖自动截断过多,否则关键得分句一旦被切掉,模型学不到东西。你可以先跑一个长度统计脚本,看看 95 分位长度是多少,再结合显存定长短。
3. 模型加载与微调:从选 base model 到损失函数的落地全流程
模型选型这件事直接影响项目的天花板。Hugging Face 上有大量现成的中文预训练模型,比如各种 BERT、RoBERTa、DeBERTa 的变体。多选问答任务,通常我会回避生成式模型(如各类自回归大模型),因为中文生成式模型在单卡微调时显存压力大,推理速度慢,而且你还要去想方设法约束输出格式。判别式模型才是中小项目和单卡环境下的最优解。
3.1 为什么我弃用了"多头分类 + 独立候选"的经典堆叠法
很多老教程会让每个候选选项过同一个编码器,然后在最后一层拼接四个向量做多分类。理论上是可行的,但实操里有一个很头疼的问题:你需要在 batch 里精心构造每个样本的四路输出,代码复杂得多,而且一旦选项数量不固定(比如有些题 3 个选项,有些题 5 个选项),你的模型结构就得跟着动态变化,非常容易写出 bug。
我的方案更暴力也更简单:在整条 pipeline 里不做候选分组,直接用二分类来训。也就是说,问题 + 选项A 是一个正样本/负样本,问题 + 选项B 是另一个独立样本。模型前向时只需要一个简单的 AutoModelForSequenceClassification,num_labels=2。训练时,把同一道题的多个选项全部丢进一个 batch 当作不同样本处理。评测时,再根据 question_id 聚合,找出预测概率最高的那个样本对应的选项字母。这样结构统一,代码简单,也不挑选项数量。
python复制from transformers import AutoModelForSequenceClassification, TrainingArguments, Trainer
model = AutoModelForSequenceClassification.from_pretrained(
"bert-base-chinese",
num_labels=2,
problem_type="single_label_classification",
)
提示:
problem_type参数一定要写成"single_label_classification",否则部分框架版本会默认当成回归或者多标签任务,损失函数直接变成 BCE 或者 MSE,那你调半天学习率都没用。
3.2 训练参数实测:学习率、batch size 与权重衰减
我在这类任务上调参的经验可以分享一组稳定区间:学习率在 2e-5 到 3e-5 之间,warmup 比例设为 0.1,weight decay 设 0.01。如果你用的是更大的模型(比如 10B 参数的生成式模型),学习率可能要降到 1e-5 附近;但如果还是百 M 到 2B 左右的中等模型,上面这个区间非常稳。
batch size 要摊开看。因为我的方案里每个选项都是独立样本,一个 batch 里其实混了整道题的多个选项。你可以把 batch size 设置成 16 或者 32,但要注意一个隐含问题:如果 batch 里全是同一道题的不同选项,而这个 batch 的梯度更新被某一个题目主导,训练会震荡。建议开启 dataloader 的 shuffle,并且在采样时尽量保证连续 batch 里的 question_id 不大量重复。也可以用 group_by_length 把长度接近的样本放在一起,减少 padding 浪费。
实操里我还发现一个非常容易忽略的点:同一个 question 的正样本和负样本在 batch 内的比例要尽量平衡。因为我们的数据天然是"一正三负"或"一正四负",如果直接随机采样,模型会过度偏向负样本,也就是什么都选"错误"。为了压制这种倾向,我在训练集构建时做了轻量上采样,让正负样本比例稳定在 1:2 左右。这个调整直接让我的 F1 值涨了 3 个点。
4. 推理与评估:不得不防的显眼包陷阱
训练完成后,很多人的习惯是拿着 Trainer.predict() 直接输出分类概率,然后挑正确率。但这一步如果凭空想象,很容易看到一份虚高的报告。为什么?因为 predict 返回的是每个独立样本(即问题+某个选项)的概率,而你需要把这些概率重新映射回"题目"级别的预测选项字母。
4.1 从样本概率恢复到"题目级"答案的还原算法
我的做法是先收集预测结果,然后按 question_id 分组,在每个分组里找到"正类概率"最高的选项,再把选项索引映射为字母。这里要特别留意:Hugging Face 模型的 logits 顺序跟你传入的标签值必须一致。如果你的正样本索引是 1,而模型输出 logits 的第二列恰好就是正类概率,那没问题;但如果你把 label 填反了,所有概率值都会镜像,最终结果等于拿排除法做反答案。
这段逻辑建议封装成一个独立函数:
python复制import numpy as np
from collections import defaultdict
def infer_answer_ids(pred_logits, sample_qids, option_orders):
"""
pred_logits: shape (n_samples, 2)
sample_qids: 每个样本对应的 question_id
option_orders: 每个样本的选项原始编号
"""
prob_positive = np.exp(pred_logits[:, 1]) / np.exp(pred_logits).sum(axis=1, keepdims=True)
best_map = {}
per_q = defaultdict(list)
for idx, qid in enumerate(sample_qids):
per_q[qid].append((prob_positive[idx], option_orders[idx]))
for qid, candidates in per_q.items():
best_prob, best_opt = max(candidates, key=lambda x: x[0])
best_map[qid] = best_opt
return best_map
注意我这里使用了软概率而不是 argmax 之后的 0/1 值。理由很简单:同题内比较时,概率的具体大小比硬分类结果更能体现模型倾向,特别在多个选项置信度接近时,保留连续值做排序更扛得住。
4.2 模型"蒙对"的可能性:选项长度偏差是最大的杀手
我在评测阶段偶发性地注意到,模型的准确率到了验证集上突然高了不少,但看错误样本时发现,被选中的往往是长选项或者编号靠前的选项。这不是模型真实理解了题目,而是模型学会了利用数据里的统计规律——在早年一些题库里,正确答案平均长度比错误答案长,或者正确选项更常出现在 B 位。模型非常擅长捕捉这种 pattern,比人眼快得多。
这属于典型的"偷懒特征"。检测方法很简单:你把验证集里每个正确选项的平均长度和最常预测选项的平均长度拉出来对比。如果高度相关,说明模型没有在真正做"语义匹配"。缓解手段可以在构造样本时给正确的选项的文本长度做归一化扰动(例如同一选项用同义改写来改变长度),或者在训练数据里混入对抗样本。
要彻底杜绝这类问题,最好在评估指标之外,额外输出一份"错误题目的全部选项概率分布",做人工抽样复盘。我每次微调都保留这个动作,像侦探一样去猜模型到底在盯什么,这比盲目调学习率有效得多。
5. 从 65% 到 84% 的提升记录:我的首页准确率提升实操记录
最后这一章节,我想完整记一次调优过程,也算给开头那个 65% 的坎一个交代。当时我用的数据是一批纯中文法律知识多选题,总共大约 2 万道。单看数据量不算小,但题目专业性强,选项之间措辞差异微妙,非常考验语义理解。
5.1 第一版模型的三大败因:数据噪声、池化策略、损失函数
第一版我直接套用了别人分享的阅读理解模板:使用 AutoModelForMultipleChoice,把四个选项全部拼在一个序列里,让模型输出四分类概率。效果非常差,在验证集上只有 65%。
我复盘跑偏了三个地方。其一是数据噪声:我按样本级划分训练测试集,导致同题不同选项在训练集和测试集里同时出现,评估几乎失去意义。其二是池化策略:默认的 MultipleChoice 模型在拼接多选项时会用 sequence_output 的第一位提取概率,但我没有仔细分析选项长度对首位 token 注意力的影响,导致 AC 项被过度忽略。其三是损失函数固化:我直接用四分类交叉熵,模型学到了位置的偏好(比如总是倾向于选 B),而完全没学会内容比较。
提示:
AutoModelForMultipleChoice不是不好,而是它对序列构造顺序极其敏感。只要选项顺序一换,准确率能掉 5 到 8 个点,这在业务中完全不可接受。
5.2 破局点:换掉任务建模思路,重新组织数据集
第二版我放弃了多选专用模型,改成 2.2 节里面的独立拼接二分类方案。同时按题目 id 做分组划分。然后做了两件小事:第一,把所有选项做了一次随机置换,确保模型不能依赖选项位置;第二,对负样本做了适量下采样,让正负比例均衡。
这些改动之后,模型在验证集上直接跳到了 78%。我没有停在这里,因为测试集上的错误还是集中在术语型题目上。排查发现,专业术语类问题因为分词粒度太粗,输入表示里有意义的词被切碎了。我到 Hugging Face 上换了一个词表更大的中文模型基座(这里不点名,以避免广告嫌疑,但方向就是词表尺寸更大、覆盖专业词汇更多的预训练权重),并把 max_length 从 256 拉到 384,最终涨到 84%。
5.3 想要继续提升,下一个更值得试的优化点
如果数据量和算力还算宽裕,我下一轮会尝试在二分类方案的基础上引入对比学习辅助损失。具体来说,把同一道题的所有选项样本建成一个组,引导模型让正确答案的表示与问题表示靠近,错误答案远离。这个思路可以很好地抑制模型在选项措辞上的过拟合,也能略微提升跨选题的泛化能力。
另外,推理阶段可以尝试集成多个 checkpoint。我拍脑袋试过把第 2000 步和第 3000 步的权重一起做概率平均,验证集又微涨了 0.8%。这类小技巧不复杂,就是推理代码要额外封装一次,很多人懒得做,但我个人觉得多选任务在选项差异很大时,集成带来的稳定性还是很划算的。
我现在再回头看,多选问答在 Hugging Face 上和你想象的"读过一遍就能用"完全不一样。它根本不是什么高深玄学,更多的是一套需要把数据组织、模型结构、推理逻辑串在一起的系统工程。如果你手头正卡在类似准确率瓶颈上,不妨先按着我复盘的五步走一遍:检查数据划分、确认格式不偷懒、换回独立拼接、排查选项长度偏差、再看基座词表。很多时候根本轮不到调大模型,问题就已经解决了大半。
