Transformers 抽取式问答实战:用 Trainer 微调 DistilBERT 并完成推理
【免费下载链接】transformers🤗 Transformers: the model-definition framework for state-of-the-art machine learning models in text, vision, audio, and multimodal models, for both inference and training.项目地址: https://gitcode.com/GitHub_Trending/tra/transformers
本文以仓库中 日语版问答任务指南 为主体,讲解如何基于 🤗 Transformers 在 SQuAD 数据集上微调 DistilBERT 完成抽取式问答(Extractive Question Answering),并给出可直接运行的训练配置、数据预处理方案与推理代码。读完本文,你将掌握:如何构造问答数据集与答案起止位置标签、如何用Trainer一键微调并推送模型到 Hub,以及如何从模型的start_logits/end_logits中解码出最终答案。
任务概览:什么是抽取式问答
问答(Question Answering)任务根据输入问题返回一个答案,可分为两类:
- 抽取式(Extractive):直接从给定的上下文(context)中抽取答案片段,模型输出的本质是"答案在上下文中从第几个 token 开始、到第几个 token 结束";
- 生成式(Abstractive):基于上下文生成一段能回答问题的文本,答案未必逐字出现在原文中。
本文聚焦第一类:在 SQuAD 数据集上微调 DistilBERT,并在微调完成后用同一模型做推理。SQuAD 的每条样本包含context(背景信息)、question(问题)和answers(答案文本及其在 context 中的起始字符位置answer_start),这正是抽取式问答的标准数据形态。
环境准备与数据集加载
训练前需要安装依赖库(datasets用于加载与批量处理数据集,evaluate用于后续评估):
pip install transformers datasets evaluate推荐先登录 Hugging Face 账号,以便训练完成后把模型推送到 Hub 与社区共享:
>>> from huggingface_hub import notebook_login >>> notebook_login()登录时按提示输入 token 即可。随后用 🤗 Datasets 加载 SQuAD 的子集(先取 5000 条训练数据,便于快速实验验证流程),再用train_test_split划分出训练集与测试集:
>>> from datasets import load_dataset >>> squad = load_dataset("squad", split="train[:5000]") >>> squad = squad.train_test_split(test_size=0.2)查看一条样本可以确认数据字段结构:
>>> squad["train"][0] {'answers': {'answer_start': [515], 'text': ['Saint Bernadette Soubirous']}, 'context': 'Architecturally, the school has a Catholic character. ...', 'id': '5733be284776f41900661182', 'question': 'To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France?', 'title': 'University_of_Notre_Dame'}三个关键字段的含义:
answers:答案 token 的起始位置(字符级偏移)与答案文本;context:模型需要从中抽取答案的背景信息;question:需要模型回答的问题。
数据预处理:截断上下文与答案位置映射
预处理是问答任务中最关键、也最容易出错的环节。先用AutoTokenizer加载 DistilBERT 对应的 tokenizer:
>>> from transformers import AutoTokenizer >>> tokenizer = AutoTokenizer.from_pretrained("distilbert/distilbert-base-uncased")问答任务的预处理有三个要点:
- 只截断 context 而非 question:数据集里部分样本的
context会超过模型最大输入长度,设置truncation="only_second"可以只对第二个序列(即 context)做截断,保证问题信息不丢失; - 返回偏移映射:设置
return_offsets_mapping=True,得到每个 token 在原始文本中的字符起止区间,这是把答案从字符位置换算成 token 位置的基础; - 用
sequence_ids区分 question 与 context:sequence_ids(i)返回第 i 个 token 属于哪个序列(0 表示 question,1 表示 context),据此定位 context 的 token 起止下标。
下面的预处理函数完整实现了"截断 + 答案位置映射":
>>> def preprocess_function(examples): ... questions = [q.strip() for q in examples["question"]] ... inputs = tokenizer( ... questions, ... examples["context"], ... max_length=384, ... truncation="only_second", ... return_offsets_mapping=True, ... padding="max_length", ... ) ... ... offset_mapping = inputs.pop("offset_mapping") ... answers = examples["answers"] ... start_positions = [] ... end_positions = [] ... ... for i, offset in enumerate(offset_mapping): ... answer = answers[i] ... start_char = answer["answer_start"][0] ... end_char = answer["answer_start"][0] + len(answer["text"][0]) ... sequence_ids = inputs.sequence_ids(i) ... ... # 找到 context 的起始与结束 token 下标 ... idx = 0 ... while sequence_ids[idx] != 1: ... idx += 1 ... context_start = idx ... while sequence_ids[idx] == 1: ... idx += 1 ... context_end = idx - 1 ... ... # 若答案不在截断后的 context 范围内,则标记为 (0, 0) ... if offset[context_start][0] > end_char or offset[context_end][1] < start_char: ... start_positions.append(0) ... end_positions.append(0) ... else: ... # 否则定位答案起止 token ... idx = context_start ... while idx <= context_end and offset[idx][0] <= start_char: ... idx += 1 ... start_positions.append(idx - 1) ... ... idx = context_end ... while idx >= context_start and offset[idx][1] >= end_char: ... idx -= 1 ... end_positions.append(idx + 1) ... ... inputs["start_positions"] = start_positions ... inputs["end_positions"] = end_positions ... return inputs关键细节说明:
- 答案的字符区间由
answer_start与答案文本长度共同算出(end_char = answer_start + len(text)); - 当答案因为截断而完全落在 context 之外时,将其标签设为
(0, 0)——这并非真实答案位置,而是让模型在该样本上"忽略"答案,源码中通过start_positions.clamp(0, ignored_index)与CrossEntropyLoss(ignore_index=ignored_index)处理这类越界样本(见 modeling_distilbert.py); - 偏移映射(
offset_mapping)在送入模型前必须从inputs中弹出,因为它只是字符级别的辅助信息,不是模型输入。
将预处理函数应用到整个数据集,batched=True可以一次性批量处理多条样本以加快速度,同时移除不再需要的原始列:
>>> tokenized_squad = squad.map(preprocess_function, batched=True, remove_columns=squad["train"].column_names)最后创建数据收集器(data collator)。与 Transformers 中其他 collator 不同,DefaultDataCollator不做任何额外的预处理(例如补 padding 到等长),它只是把批量样本简单地堆叠成张量。这是因为预处理阶段已经用padding="max_length"统一了序列长度:
>>> from transformers import DefaultDataCollator >>> data_collator = DefaultDataCollator()从源码看,DefaultDataCollator 是一个@dataclass包装,其__call__委托给default_data_collator,默认以return_tensors="pt"返回 PyTorch 张量,并对label/label_ids等键做特殊处理;本任务中的start_positions、end_positions属于普通键,会被直接torch.tensor(...)堆叠成 batch(见 data_collator.py)。
使用 Trainer 微调:参数解析与训练
一切就绪后,用AutoModelForQuestionAnswering加载带问答头(QA head)的 DistilBERT:
>>> from transformers import AutoModelForQuestionAnswering, TrainingArguments, Trainer >>> model = AutoModelForQuestionAnswering.from_pretrained("distilbert/distilbert-base-uncased")AutoModelForQuestionAnswering是一个自动映射类(定义于 modeling_auto.py),它会根据 checkpoint 的架构配置自动挑选对应的*ForQuestionAnswering实现类。该映射覆盖了大量架构,例如BertForQuestionAnswering、DistilBertForQuestionAnswering、BartForQuestionAnswering、BigBirdForQuestionAnswering、MobileBertForQuestionAnswering等(完整列表见 MODEL_FOR_QUESTION_ANSWERING_MAPPING_NAMES),因此训练脚本无需针对具体模型写分支。
接下来只需三步:
- 在
TrainingArguments中定义训练超参数; - 把训练参数连同模型、数据集、tokenizer、数据收集器一起传给
Trainer; - 调用
trainer.train()开始微调。
>>> training_args = TrainingArguments( ... output_dir="my_awesome_qa_model", ... eval_strategy="epoch", ... learning_rate=2e-5, ... per_device_train_batch_size=16, ... per_device_eval_batch_size=16, ... num_train_epochs=3, ... weight_decay=0.01, ... push_to_hub=True, ... )各参数含义与取值建议:
| 参数 | 作用 | 说明 |
|---|---|---|
output_dir | 模型保存目录 | 唯一必填参数,训练产物(checkpoint、配置)都会写入该目录 |
eval_strategy | 评估策略 | "epoch"表示每个 epoch 结束时在验证集上计算一次评估损失(对应旧版evaluation_strategy,本仓库已更名为eval_strategy) |
learning_rate | 学习率 | 微调场景常用2e-5这类较小的值,避免破坏预训练权重 |
per_device_train_batch_size | 单设备训练 batch 大小 | 本文取 16,需根据显存调整 |
per_device_eval_batch_size | 单设备评估 batch 大小 | 本文取 16 |
num_train_epochs | 训练轮数 | 本文取 3 |
weight_decay | 权重衰减 | 0.01 为常见取值,用于正则化 |
push_to_hub | 是否推送 Hub | 设为True时训练结束后可把模型上传到 Hugging Face Hub(需要已登录) |
随后构造Trainer。注意当前仓库的Trainer使用processing_class参数接收 tokenizer:
>>> trainer = Trainer( ... model=model, ... args=training_args, ... train_dataset=tokenized_squad["train"], ... eval_dataset=tokenized_squad["test"], ... processing_class=tokenizer, ... data_collator=data_collator, ... )训练完成后,用trainer.push_to_hub()把模型分享到 Hub,社区任何人都可以直接加载使用:
>>> trainer.train() >>> trainer.push_to_hub()关于评估的说明
问答任务的评估需要大量后处理(例如对答案起止概率做非极大值抑制、对齐原始字符偏移、与标准答案比较等)。为了让指南聚焦核心流程,本文省略了完整评估步骤,但Trainer在训练过程中仍然会计算评估损失(这得益于eval_strategy="epoch"与传入的eval_dataset),因此你不会对模型表现完全无感知。
推理:从 logits 到答案
微调完成后即可用于推理。先准备一个问题与一段上下文:
>>> question = "How many programming languages does BLOOM support?" >>> context = "BLOOM has 176 billion parameters and can generate text in 46 languages natural languages and 13 programming languages."把问题与上下文一起送入 tokenizer 并返回 PyTorch 张量:
>>> from transformers import AutoTokenizer >>> tokenizer = AutoTokenizer.from_pretrained("my_awesome_qa_model") >>> inputs = tokenizer(question, context, return_tensors="pt")加载微调后的模型并在torch.no_grad()下前向传播,得到logits:
>>> import torch >>> from transformers import AutoModelForQuestionAnswering >>> model = AutoModelForQuestionAnswering.from_pretrained("my_awesome_qa_model") >>> with torch.no_grad(): ... outputs = model(**inputs)从模型输出中取start_logits与end_logits各自概率最高的位置:
>>> answer_start_index = outputs.start_logits.argmax() >>> answer_end_index = outputs.end_logits.argmax()最后切出对应的 token 序列并解码为文本:
>>> predict_answer_tokens = inputs.input_ids[0, answer_start_index : answer_end_index + 1] >>> tokenizer.decode(predict_answer_tokens) '176 billion parameters and can generate text in 46 languages natural languages and 13'源码原理:问答头如何输出答案
理解推理代码背后的原理有助于排查问题。以 DistilBERT 为例,其问答实现位于 modeling_distilbert.py,核心流程是:
distilbert(...)前向编码得到hidden_states,形状为(batch_size, seq_len, hidden_dim);- 经过
dropout后送入qa_outputs线性层,输出(batch_size, seq_len, 2)的 logits; - 沿最后一维
split成start_logits与end_logits,形状均为(batch_size, seq_len),分别表示"每个 token 作为答案起始位置"与"作为答案结束位置"的打分; - 训练阶段传入
start_positions/end_positions时,用两个CrossEntropyLoss分别计算起始与结束位置的损失,取平均作为总损失返回。
因此在推理阶段,对start_logits.argmax()和end_logits.argmax()即可得到模型认为最可能的答案起止 token 下标,再通过input_ids切片与tokenizer.decode还原为可读文本。输出的结构化封装为QuestionAnsweringModelOutput,其中同时携带loss、start_logits、end_logits以及可选的hidden_states、attentions,方便训练与调试。
延伸与注意事项
- 完整英文原版:本文对应的英文完整版指南见 docs/source/en/tasks/question_answering.md,其中包含与本文一致的 SQuAD 加载、预处理、训练、评估与推理全流程,可作为交叉参考。
- 更多模型选择:
AutoModelForQuestionAnswering的映射表(modeling_auto.py)覆盖数十种架构,只需替换 checkpoint 名称即可迁移到 BERT、BigBird、Longformer、DeBERTa 等模型,预处理与训练代码无需改动。 - 长文本场景:
truncation="only_second"只截断 context 的策略同样适用于长文档问答;若上下文远超max_length=384,可考虑滑动窗口切分后再拼接各窗口的答案分数。 - 答案合法性:本文演示直接取
argmax,实际生产环境建议约束answer_end_index >= answer_start_index,并可结合squad_convert等后处理过滤无意义答案(例如答案落在 context 之外的情形在训练时已被标记为(0, 0)并通过ignore_index处理)。 - 部署:微调完成后
push_to_hub上传的模型既可通过AutoModelForQuestionAnswering.from_pretrained本地加载,也可结合仓库中的 pipelines 模块以pipeline("question-answering", model=...)方式快速封装为服务接口。
【免费下载链接】transformers🤗 Transformers: the model-definition framework for state-of-the-art machine learning models in text, vision, audio, and multimodal models, for both inference and training.项目地址: https://gitcode.com/GitHub_Trending/tra/transformers
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考