很多人第一次接触 HuggingFace,都是从文本分类、序列标注这类"一个输入、一个输出"的任务入手的,结构直观,套用 AutoModelForSequenceClassification 基本就能跑通。但当你真正要处理考试题、阅读理解、或者给模型做选择题评测时,会发现这根本不是一个标准分类任务能直接搞定的——每个样本里有题干、有多个选项,而且选项数量还不固定,怎么组织输入、怎么设计标签、怎么让模型输出"选哪个",每一步都有讲究。
这篇文章就是我最近用 HuggingFace 做多选题问答(Multiple-Choice QA)的完整记录。从任务拆解、数据预处理、模型加载到训练评估和踩坑实录,全部是实际跑过之后沉淀下来的东西,不是贴文档。无论你是要做 RACE、SWAG 这类公开数据集的评测,还是想搭一个"给一段材料 + 若干选项,让模型选出正确答案"的 Demo,这套流程都适用,照着做就能落地。
1. 多选题问答的任务拆解与方案选型
1.1 先把任务本质看清楚
多选题问答,本质上是一个"从候选集中选最优"的任务。输入通常包含两部分:一段上下文(可能是文章、对话、常识描述,甚至空上下文)和一个带若干选项的问题;输出是正确选项的编号或内容。
但这跟传统分类有个关键差异:选项数量不固定。文本分类的类别数是预先定义死的,而多选题是每道题的选项独立存在,4 个选项、5 个选项、甚至选项个数不一,都可能在同批数据里出现。用固定的分类头去映射"类别 A/B/C/D"虽然也能做,但一旦测试集选项顺序变了、数量变了,模型就废了。
所以 HuggingFace 里做这道题的常规思路不是"分类",而是"匹配打分"。
1.2 核心套路:把每个选项变成独立样本
我在实际项目中用到的方案是 AutoModelForMultipleChoice,它背后的原理其实特别朴素:把题干和每个选项分别拼起来,当作一个独立的匹配对,模型对每个匹配对输出一个匹配分数,然后挑分数最高的那个选项。
举个例子。题干是"小明去超市买水果,他买了苹果和香蕉,请问他一共买了几种水果?",选项 A 是"1种",B 是"2种",C 是"3种"。模型不会直接输出"选B",而是构造三组输入:
[CLS] 题干 [SEP] 选项A [SEP][CLS] 题干 [SEP] 选项B [SEP][CLS] 题干 [SEP] 选项C [SEP]
每组输入经过编码器后取 [CLS] 向量,过一个线性层得到一个分数,三个分数经 softmax 变成二分类概率(实际上内部就是选项级的打分),训练时用交叉熵让正确答案对应的分数最高。
这个设计的好处很直接:模型学到的是"这段上下文与这个候选陈述是否吻合"的能力,而不是死记"第几个位置是正确答案"。推理时选项顺序怎么换都不影响正确性,换到别的数据集也能迁移。
补充一句:如果你只是想要一个快速原型,而不追求精度极限,也可以用
AutoModelForSequenceClassification手动实现,把每个选项拼好之后独立 forward,取 logits 作为分数再选最大。这个自己撸的版本更灵活,后面调试也直观,我早期就是这么干的。
1.3 什么时候用 AutoModelForMultipleChoice
这个模型类属于 Transformer 家族里相对"冷门"但有明确适用场景的一类。我建议在这些情况下优先用它:
- 你需要跑 RACE、SWAG、CommonsenseQA 这类标准多选题评测;
- 你的业务数据是"上下文 + 问题 + 候选答案列表",选项数量不固定;
- 你需要随时随地在新数据上做 few-shot 微调,而不想每次改分类头数量。
如果只是二选一判断题,或者选项数量固定且很少(比如永远是 A/B 两个),那直接用二分类更省事,没必要为了"多选"而多选。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据预处理:处处是坑,步步都要算清楚
2.1 先理清数据格式和字段设计
我用 HuggingFace 的 Dataset 组织数据,每条样本内部用字典形式维护,字段名虽然可以自定义,但为了后面直接喂给 Trainer,我习惯统一成这组结构:
python复制{
"context": "小明去超市买水果,他买了苹果和香蕉。",
"question": "请问他一共买了几种水果?",
"choices": ["1种", "2种", "3种", "4种"],
"answer_index": 1 # 正确答案在 choices 中的下标
}
这里有个容易被忽略的细节:answer_index 必须是整数下标,而不是 "B" 这样的字母。因为训练时 loss 计算用的是下标交叉熵,字母还得额外映射回数字,纯属给自己找麻烦。我一开始就吃过这个亏,数据里存了 "B",预处理时忘了转 int,报错报得莫名其妙。
2.2 预处理函数的编写逻辑
HuggingFace 的 map 操作会对每条样本应用处理函数,但多选题的处理函数跟普通任务不同,它要返回的是一个"列表的列表"——外层是样本序号,内层是一个样本的所有选项。我整理了一个通用版本:
python复制from transformers import AutoTokenizer
def preprocess_function(examples):
contexts = examples["context"]
questions = examples["question"]
choices = examples["choices"]
labels = examples["answer_index"]
# 构造输入:每个选项一条,把上下文和问题拼起来作为前缀
first_sentences = [
[context + " " + question] * len(choices_per_sample)
for context, question, choices_per_sample in zip(contexts, questions, choices)
]
second_sentences = [list(choice_set) for choice_set in choices]
tokenized = tokenizer(
first_sentences,
second_sentences,
truncation="only_first",
padding="max_length",
max_length=512,
)
tokenized["labels"] = labels
return tokenized
这段代码有几个关键点值得展开说。
第一,truncation="only_first" 是我强烈建议的设置。因为 first_sentences 里是"上下文+问题",second_sentences 里是选项,截断时应尽量保住选项完整。选项通常是短句,丢了核心信息影响巨大;而上下文是长文本,头部信息往往最有用,截掉尾部损失相对可控。
第二,padding="max_length" 统一到 512。不同选项长度不一样,如果不 pad 到统一长度,AutoModelForMultipleChoice 内部的维度计算会直接崩。虽然有动态 padding 的写法,但在多选题场景下,批内不同样本的选项数量还不一样,动态 pad 反而更复杂,固定 512 最稳。
第三,tokenizer 接收"两个句子列表"的结构是 list[list[str]],这个格式新手很容易写错。第一层列表代表每个样本,第二层代表该样本下的所有选项。如果写成了 list[str],tokenizer 会当成单条样本处理,维度直接对不上。
2.3 样本组织避坑:标签对齐
在 map 处理完之后,要确认一下 labels 的长度跟 tokenized 后的 input_ids 第一维长度是一致的。多选任务的常见报错就是 input_ids 是三维的 [batch_size, num_choices, seq_len],而 labels 是一维的 [batch_size],两者对应关系是"每个样本一条标签",而不是"每个选项一条标签"。
这里用列表推导式组织数据时,如果数据源里 choices 字段偶尔为空,或者答案下标越界(比如选项只有 3 个,答案却写了 4),都会在后面训练时出现诡异错误。我建议在预处理之前先做一次数据质量校验:
python复制for i, (choices, idx) in enumerate(zip(choices_list, answer_indices)):
assert 0 <= idx < len(choices), f"样本 {i} 的答案下标越界:{idx} vs {len(choices)}"
这种校验写一次,能帮你省下后面至少一个小时的排查时间。
3. 模型加载与训练实现:从架构到代码
3.1 AutoModelForMultipleChoice 到底是个什么结构
很多人以为 AutoModelForMultipleChoice 是什么特殊的新架构,其实它就是在基础 Transformer 编码器(比如 Bert、RoBERTa、DeBERTa)顶上挂了一个多头打分头而已。具体来说:
- 输入经过编码器后,取每个选项对应的
[CLS]位置的隐状态; - 这些隐状态过一个
nn.Linear(hidden_size, 1),得到每个选项的 logits; - 所有选项的 logits 组成一个
[batch_size, num_choices]的矩阵; - 训练时跟标签做交叉熵。
你完全可以用 AutoModel 自己复刻这套逻辑,也就十来行代码。但直接用官方封装的好处是:加载、保存、predict 的接口都对齐了,跟 Trainer 的配合也顺滑。所以我实际项目里都是直接用封装版,省心。
这背后的逻辑跟"排序学习"里的 pointwise 思路很像:把多选题拆成多个独立的匹配分,再用 softmax 归一化,本质上就是在一个候选集内做归一化打分。理解了这一层,后面做任何变体(比如把分数替换为生成式模型的概率)都能举一反三。
3.2 加载模型与训练参数
模型加载本身很简单,但有几个细节影响精度和速度。
python复制from transformers import AutoModelForMultipleChoice
model = AutoModelForMultipleChoice.from_pretrained(
"bert-base-chinese", # 根据任务语言选择合适的底座
num_choices=4, # 这个要和实际数据里的最大选项数对应
)
num_choices 这个参数是训练时 batch 内每样本选项数的上界。如果实际数据里有些题是 5 个选项,而你设了 4,就会维度不匹配。我通常的做法是:在预处理之前统计全量数据的选项数,取最大值,然后手动传给模型。
训练参数方面,我给一个经过实测的起始配置:
python复制from transformers import Trainer, TrainingArguments
training_args = TrainingArguments(
output_dir="./mcqa_output",
evaluation_strategy="epoch",
save_strategy="epoch",
learning_rate=2e-5,
per_device_train_batch_size=4,
per_device_eval_batch_size=4,
num_train_epochs=5,
weight_decay=0.01,
logging_steps=50,
load_best_model_at_end=True,
metric_for_best_model="accuracy",
)
per_device_train_batch_size=4 看起来很小,但请注意:对多选任务来说,每个样本内部已经展开了多个选项,实际一次 forward 的序列数量是 batch_size * num_choices。如果 batch_size=4、num_choices=4,那一个 GPU 上同时要过 16 条 512 长度的序列,显存占用比普通分类任务大得多。新手上来就设 16 或 32,基本都会 OOM。
3.3 自定义评估指标
Trainer 默认不带 accuracy,多选任务又特别吃这个指标,所以我一般自己注册一个:
python复制import numpy as np
from datasets import load_metric
accuracy = load_metric("accuracy")
def compute_metrics(eval_pred):
logits, labels = eval_pred
# logits 形状是 (batch_size, num_choices)
predictions = np.argmax(logits, axis=-1)
return accuracy.compute(predictions=predictions, references=labels)
关键是 np.argmax(logits, axis=-1),在选项数量这一维上取最大分数的下标,正好对应预测的选项索引。这个跟标签的下标定义一致,评估才算对上了。
注意 load_metric 在新版本 datasets 里可能被废弃,如果环境里报错,直接用纯 numpy 手写准确率更省事。我后来干脆不用 load_metric 了,几行代码的事,还少一个版本兼容问题:
python复制def compute_metrics(eval_pred):
logits, labels = eval_pred
predictions = np.argmax(logits, axis=-1)
return {"accuracy": (predictions == labels).mean().item()}
3.4 手动写训练循环的情况
Trainer 适合标准流程,但如果你要对多选任务做特殊处理——比如动态按难度采样、在选项维度上加 mask、或者在预测阶段用温度缩放——那 Trainer 的封装反而碍事。我用过几次手动训练循环,核心代码也就这些:
python复制from torch.utils.data import DataLoader
dataloader = DataLoader(tokenized_dataset, batch_size=4, shuffle=True)
model.train()
for epoch in range(5):
for batch in dataloader:
batch = {k: v.to(device) for k, v in batch.items()}
outputs = model(**batch)
loss = outputs.loss
loss.backward()
optimizer.step()
optimizer.zero_grad()
model(**batch) 能直接吃 input_ids、attention_mask、labels 这些字段,AutoModelForMultipleChoice 的 forward 内部已经把选项维度的逻辑处理好了。这也是为什么我前面强调字段名要跟模型期望保持一致,乱起字段名的话就得手动重组字典,非常容易错。
4. 实测中的常见问题与排查实录
4.1 显存爆炸问题
多选任务的显存消耗是普通分类的数倍,这是绝大多数新手遇到的第一个坎。我实测下来,一张 16G 显存的卡,Bert-base、512 长度、num_choices=4,batch_size 只能开到 4 左右,再大就会 OOM。
排查思路按顺序来:
- 先确认
num_choices是否设得过大,如果数据里实际只有 4 个选项,别为了保险设成 5; - 再检查
max_length,中文数据集 512 是上限,很多情况下 256 就够,能降就降; - 最后考虑梯度累积,
gradient_accumulation_steps=2配batch_size=2,等效 batch_size=4 且不爆显存。
我后来为了跑长文本,还试过把 max_length 降到 384,精度只掉了不到一个点,但显存占用直接减了三分之一。这种取舍在做工程化时应该心里有数。
4.2 选项长度不一致的处理
不同选项长度差很多是常态。比如上下文 400 字,选项 A 只有 5 个字,选项 D 有 80 个字,统一 pad 到 512 会有大量浪费。
我实验过两种策略:
| 策略 | 做法 | 实测效果 |
|---|---|---|
| 统一 max_length | 全部 pad 到最长 | 简单稳定,但短选项样本浪费显存 |
| 动态 padding | 按当前 batch 内最大长度 pad | 省显存,但代码复杂一截 |
在公开数据集评测上,我最后选了方案一,因为省下的显存并没有带来显著的精度提升,而方案二在 Trainer 里配置起来要多写不少代码。不过如果你在线推理的时延敏感,方案二值得投入,能明显降低单条请求的无效计算量。
4.3 训练收敛但准确率上不去
这是最让人头疼的问题。我遇到过的几种情况和解法:
-
上下文和选项的拼接顺序有问题。我一开始把选项放在前面、问题放后面,效果差了 3-4 个点。改成"上下文+问题"在前、选项在后(
truncation="only_first"),有明显提升。这跟预训练模型的输入格式保持一致很重要。 -
选项信息被截断了。长选项在 truncation 后被截断,导致关键信息丢失。排查方法很简单:打印几条 tokenized 后的样本,肉眼看选项部分是否完整。
-
学习率太大或太小。多选题微调的学习率一般比文本分类更保守,2e-5 起步,5e-5 是上限。我试过 1e-4,loss 下降倒是快,但验证集波动非常大。
-
数据里的正确答案分布不均匀。如果某份数据里选项 B 的正确答案特别多,模型很容易学会"无脑选 B",准确率看着高,换个分布就崩。用
sklearn.metrics.classification_report看一下每个选项的准确率,能快速发现问题。
我从实践中总结了一个排查顺序:先看 loss 有没有降,再打印预测结果对比真实标签,最后检查预处理后的数据字段形状。不要一上来就怀疑模型架构,多选任务 90% 的问题出在数据组织。
4.4 Trainer 训练结束后的推理与保存
训练完之后,模型保存和推理也有讲究。Trainer 默认会保存 pytorch_model.bin、config.json、tokenizer 等文件,直接用 AutoModelForMultipleChoice.from_pretrained 加载没问题。但我遇到过一个坑:如果只保存了权重而没保存 tokenizer,推理时再加载 tokenizer 会跟训练时不一致,导致文本被切成完全不同的 token,效果崩盘。
我的做法是训练结束后显式调用一次:
python复制model.save_pretrained("./final_model")
tokenizer.save_pretrained("./final_model")
推理阶段的完整流程基本是:
python复制tokenizer = AutoTokenizer.from_pretrained("./final_model")
model = AutoModelForMultipleChoice.from_pretrained("./final_model")
model.eval()
inputs = tokenizer(
[context_question] * len(choices), # 同一个前缀复制 N 份
choices,
return_tensors="pt",
padding="max_length",
truncation="only_first",
max_length=512,
)
with torch.no_grad():
logits = model(**inputs.to(model.device)).logits.squeeze(0)
pred_idx = torch.argmax(logits).item()
注意推理时 input_ids 的形状要补一个 batch 维。我踩过这个坑,tokenizer 返回的是 [num_choices, seq_len],model 期望的是 [1, num_choices, seq_len],不补维度直接报维度错误,补一下就好了。
5. 多选任务的进阶玩法与我的实操体会
5.1 换个底座模型,效果可能天差地别
bert-base-chinese 只是起步选择。我做过一组对照实验,同一个多选题数据集上,不同底座的准确率能差出 5-8 个点。中文场景下,我测过几个主流底座,大概印象是:
- 标准的 base 中文模型:稳定但上限有限;
- 更大的中文预训练模型:提升明显,但显存和推理时间翻倍;
- 英文场景的 DeBERTa 系列:在多选评测集合上通常有更好的表现,但需要英文数据。
选型建议是:先在 base 上跑通全流程,再根据显存余量和精度目标决定要不要换大模型。 很多人一上来就用大尺寸模型,结果连数据处理的问题都没暴露就被显存卡死了,反而浪费时间。
5.2 从"选一个"扩展到"选多个"的场景
题目里说的"多选题"如果指的不只是"四选一",而是"多选多"(答案可能不止一个),基座模型的逻辑还要再包装一层。
我的做法是:把多选多拆成对每个选项做二分类——"这个选项是否出现在答案集里"。具体实现是复用 AutoModelForMultipleChoice 拿到的每个选项的 logits,再套一个阈值判断。或者更直接:换成 AutoModelForSequenceClassification,把上下文+问题+单个选项拼成一条样本,输出"是/否"二分类,遍历所有选项。
这个变体实现起来不复杂,但要注意评估指标要从"准确率"换成"F1 或精确率/召回率",因为选项正负样本比例通常很不均衡。只盯着准确率,模型全选"否"也可能拿到很高的分数。
5.3 我的几条实操心得
做完整套多选问答流程,我最大的几个体会:
第一,先把数据打印出来看,再谈模型训练。 多选任务的预处理函数里嵌套了列表推导式,眼睛看不出错来,打印几个样本的 input_ids、labels 和还原后的文本是最快的验证方式。我在确认 tokenized 结果之前从来不直接开训练。
第二,num_choices 和 max_length 是两个"恶性 bug"高发区。一旦设置和实际数据不匹配,报错信息往往模棱两可,指向模型内部而不指向数据。所以我在数据预处理之后,会专门跑一个 assert 检查所有张量的维度关系。
第三,用小的验证集快速迭代。多选题训练一次比分类任务慢不少,我习惯先从训练集抽样 500 条做一次 1 epoch 的冒烟测试,确认整条链路通了、loss 在下降,再切全量数据。这个习惯帮我省了不知道多少时间。
第四,不要过度相信验证集准确率。多选任务里,模型非常容易记住"选项位置"这个捷径,尤其是训练数据里正确答案分布不均匀时。每次实验完,我会额外准备一份答案分布不同于训练集的 mini 测试集,确认模型学到的不是位置偏见。
5.4 后面可以怎么扩展
这个项目做完之后,我沿着两个方向继续做了些事情。
一个是把多选能力接入到一个更完整的问答系统里:先做检索召回若干候选段落,再用多选模型对每个候选段落的可回答性打分,最终返回置信度最高的答案。这就是一个简化版的开放域问答 pipeline。
另一个是把中间层的隐状态抽出来做特征,跟规则逻辑融合。有些场景下模型打分很接近,单靠 argmax 容易误判,结合规则模型(比如关键词命中)做加权融合,能获得更稳定的表现。
如果你只是想做选择题评测工具,那到这里已经够用了;如果你想往阅读理解、检索问答方向走,多选任务的这套"选项展开 + 匹配打分"思路,会成为你理解更复杂任务的重要基石。
