Transformers 抽取式问答实战:用 Trainer 微调 DistilBERT 并完成推理
2026/9/10 5:05:19 网站建设 项目流程

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")

问答任务的预处理有三个要点:

  1. 只截断 context 而非 question:数据集里部分样本的context会超过模型最大输入长度,设置truncation="only_second"可以只对第二个序列(即 context)做截断,保证问题信息不丢失;
  2. 返回偏移映射:设置return_offsets_mapping=True,得到每个 token 在原始文本中的字符起止区间,这是把答案从字符位置换算成 token 位置的基础;
  3. sequence_ids区分 question 与 contextsequence_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_positionsend_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实现类。该映射覆盖了大量架构,例如BertForQuestionAnsweringDistilBertForQuestionAnsweringBartForQuestionAnsweringBigBirdForQuestionAnsweringMobileBertForQuestionAnswering等(完整列表见 MODEL_FOR_QUESTION_ANSWERING_MAPPING_NAMES),因此训练脚本无需针对具体模型写分支。

接下来只需三步:

  1. TrainingArguments中定义训练超参数;
  2. 把训练参数连同模型、数据集、tokenizer、数据收集器一起传给Trainer
  3. 调用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_logitsend_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,核心流程是:

  1. distilbert(...)前向编码得到hidden_states,形状为(batch_size, seq_len, hidden_dim)
  2. 经过dropout后送入qa_outputs线性层,输出(batch_size, seq_len, 2)的 logits;
  3. 沿最后一维splitstart_logitsend_logits,形状均为(batch_size, seq_len),分别表示"每个 token 作为答案起始位置"与"作为答案结束位置"的打分;
  4. 训练阶段传入start_positions/end_positions时,用两个CrossEntropyLoss分别计算起始与结束位置的损失,取平均作为总损失返回。

因此在推理阶段,对start_logits.argmax()end_logits.argmax()即可得到模型认为最可能的答案起止 token 下标,再通过input_ids切片与tokenizer.decode还原为可读文本。输出的结构化封装为QuestionAnsweringModelOutput,其中同时携带lossstart_logitsend_logits以及可选的hidden_statesattentions,方便训练与调试。

延伸与注意事项

  • 完整英文原版:本文对应的英文完整版指南见 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询