文档问答(Document Question Answering, DQA)¶
文档问答(Document Question Answering,简称 DQA)是一项涉及对文档图像提出的问题提供答案的任务。支持此任务的模型的输入通常是图像和问题的组合,输出是以自然语言表达的答案。这些模型利用了多种模态,包括文本、单词位置(边界框)和图像本身。
本指南将说明如何:
- 在 DocVQA 数据集 上微调 LayoutLMv2。
- 使用微调后的模型进行推理。
要查看与此任务兼容的所有架构和检查点,我们建议查看 任务页面。
LayoutLMv2 通过在标记的最终隐藏状态之上添加一个问答头,来预测答案的起始和结束标记的位置,从而解决文档问答任务。换句话说,这个问题被视为提取式问答:给定上下文,提取哪个信息片段回答了问题。上下文来自 OCR 引擎的输出,这里使用的是谷歌的 Tesseract。
在开始之前,请确保你已安装所有必要的库。LayoutLMv2 依赖于 detectron2、torchvision 和 tesseract。
pip install -q transformers datasets
pip install 'git+https://github.com/facebookresearch/detectron2.git'
pip install torchvision
sudo apt install tesseract-ocr
pip install -q pytesseract
安装所有依赖项后,重新启动您的运行时。
我们鼓励您与社区分享您的模型。登录您的 Hugging Face 帐户,将其上传到 🤗 Hub。当提示时,输入您的令牌登录:
from huggingface_hub import notebook_login
notebook_login()
让我们定义一些全局变量。
model_checkpoint = "microsoft/layoutlmv2-base-uncased"
batch_size = 4
加载数据¶
在本指南中,我们使用 🤗 Hub 上的预处理的 DocVQA 的一小部分样本。如果您想使用完整的 DocVQA 数据集,您可以在 DocVQA 主页 上注册并下载。如果您这样做,请查看 如何将文件加载到 🤗 数据集中 以继续本指南。
from datasets import load_dataset
dataset = load_dataset("nielsr/docvqa_1200_examples")
dataset
如您所见,数据集已经分为训练集和测试集。查看一个随机示例,以熟悉特征。
dataset["train"].features
以下是各个字段表示的含义:
id:示例的 idimage:包含文档图像的 PIL.Image.Image 对象query:问题字符串 - 自然语言问题,多种语言answers:人类注释者提供的正确答案列表words和bounding_boxes:OCR 的结果,我们这里不使用answer:另一个模型匹配的答案,我们这里不使用
让我们只保留英语问题,并删除 answer 特征,因为它似乎包含另一个模型的预测。我们将从注释者提供的答案集中取第一个答案。或者,您可以随机抽样。
updated_dataset = dataset.map(lambda example: {"question": example["query"]["en"]}, remove_columns=["query"])
updated_dataset = updated_dataset.map(
lambda example: {"answer": example["answers"][0]}, remove_columns=["answer", "answers"]
)
请注意,本指南中使用的 LayoutLMv2 检查点已使用 max_position_embeddings = 512 进行训练(您可以在 检查点的 config.json 文件 中找到此信息)。我们可以截断示例,但为了避免答案可能在大型文档末尾而最终被截断的情况,这里我们将删除一些嵌入长度可能超过 512 的示例。如果您的数据集中的大多数文档都很长,您可以实现滑动窗口策略 - 查看此笔记本了解详细信息。
updated_dataset = updated_dataset.filter(lambda x: len(x["words"]) + len(x["question"].split()) < 512)
此时,让我们也从数据集中删除 OCR 特征。这些是微调另一个模型的结果。如果我们想使用它们,它们仍然需要一些处理,因为它们不符合本指南中使用的模型的输入要求。相反,我们可以在原始数据上使用 LayoutLMv2Processor 进行 OCR 和标记化。这样,我们将得到与模型预期输入相匹配的输入。如果您想手动处理图像,请查看 LayoutLMv2 模型文档 了解模型期望的输入格式。
updated_dataset = updated_dataset.remove_columns("words")
updated_dataset = updated_dataset.remove_columns("bounding_boxes")
最后,如果我们不查看图像示例,数据探索就不会完整。
updated_dataset["train"][11]["image"]
预处理数据¶
文档问答任务是一个多模态任务,您需要确保每个模态的输入都根据模型的期望进行预处理。让我们从加载 LayoutLMv2Processor 开始,它内部结合了一个可以处理图像数据的图像处理器和一个可以编码文本数据的标记器。
from transformers import AutoProcessor
processor = AutoProcessor.from_pretrained(model_checkpoint)
预处理文档图像¶
首先,让我们使用处理器中的 image_processor 准备文档图像以供模型使用。默认情况下,图像处理器将图像调整大小为 224x224,确保它们具有正确的颜色通道顺序,并应用 tesseract OCR 以获取单词和规范化边界框。在此教程中,所有这些默认设置都是我们需要的。编写一个函数,将默认图像处理应用于一批图像,并返回 OCR 的结果。
image_processor = processor.image_processor
def get_ocr_words_and_boxes(examples):
images = [image.convert("RGB") for image in examples["image"]]
encoded_inputs = image_processor(images)
examples["image"] = encoded_inputs.pixel_values
examples["words"] = encoded_inputs.words
examples["boxes"] = encoded_inputs.boxes
return examples
要以快速方式将此预处理应用于整个数据集,请使用 map。
dataset_with_ocr = updated_dataset.map(get_ocr_words_and_boxes, batched=True, batch_size=2)
预处理文本数据¶
一旦我们对图像应用了 OCR,我们需要对数据集的文本部分进行编码,以准备模型。这涉及将我们在上一步中获得的单词和框转换为标记级别的 input_ids、attention_mask、token_type_ids 和 bbox。为了预处理文本,我们将需要处理器中的 tokenizer。
除了上面提到的预处理之外,我们还需要添加模型的标签。对于 🤗 Transformers 中的 xxxForQuestionAnswering 模型,标签包括 start_positions 和 end_positions,指示哪个标记是答案的起始,哪个标记是答案的结束。
让我们从这些开始。定义一个辅助函数,它可以在更大的列表(单词列表)中查找一个子列表(答案分割成单词)。
这个函数将接受两个列表作为输入,words_list 和 answer_list。然后它将在 words_list 上迭代,并检查 words_list 中的当前单词(words_list[i])是否等于 answer_list 中的第一个单词(answer_list[0]),并且 words_list 从当前单词开始并具有与 answer_list 相同长度的子列表是否等于 answer_list。如果此条件为真,则意味着找到了匹配项,并且该函数将记录匹配项、其起始索引(idx)和结束索引(idx + len(answer_list) - 1)。如果找到多个匹配项,该函数将只返回第一个。如果没有找到匹配项,该函数将返回(None,0 和 0)。
def subfinder(words_list, answer_list):
matches = []
start_indices = []
end_indices = []
for idx, i in enumerate(range(len(words_list))):
if words_list[i] == answer_list[0] and words_list[i : i + len(answer_list)] == answer_list:
matches.append(answer_list)
start_indices.append(idx)
end_indices.append(idx + len(answer_list) - 1)
if matches:
return matches[0], start_indices[0], end_indices[0]
else:
return None, 0, 0
为了说明这个函数如何在示例中找到答案的位置,让我们在示例上使用它:
example = dataset_with_ocr["train"][1]
words = [word.lower() for word in example["words"]]
match, word_idx_start, word_idx_end = subfinder(words, example["answer"].lower().split())
print("Question: ", example["question"])
print("Words:", words)
print("Answer: ", example["answer"])
print("start_index", word_idx_start)
print("end_index", word_idx_end)
一旦示例被编码,它们将看起来像这样:
encoding = tokenizer(example["question"], example["words"], example["boxes"])
tokenizer.decode(encoding["input_ids"])
我们将需要找到答案在编码输入中的位置。
token_type_ids告诉我们哪些标记是问题的一部分,哪些标记是文档单词的一部分。tokenizer.cls_token_id将帮助在输入的开头找到特殊标记。word_ids将帮助将原始words中的答案与编码输入中的相同答案匹配,并确定答案在编码输入中的起始/结束位置。
考虑到这些,让我们创建一个函数来编码数据集中的示例批次:
def encode_dataset(examples, max_length=512):
questions = examples["question"]
words = examples["words"]
boxes = examples["boxes"]
answers = examples["answer"]
# 编码示例批次并初始化 start_positions 和 end_positions
encoding = tokenizer(questions, words, boxes, max_length=max_length, padding="max_length", truncation=True)
start_positions = []
end_positions = []
# 循环遍历批次中的示例
for i in range(len(questions)):
cls_index = encoding["input_ids"][i].index(tokenizer.cls_token_id)
# 在示例的单词中找到答案的位置
words_example = [word.lower() for word in words[i]]
answer = answers[i]
match, word_idx_start, word_idx_end = subfinder(words_example, answer.lower().split())
if match:
# 如果找到匹配项,使用 `token_type_ids` 找到编码中单词的起始位置
token_type_ids = encoding["token_type_ids"][i]
token_start_index = 0
while token_type_ids[token_start_index] != 1:
token_start_index += 1
token_end_index = len(encoding["input_ids"][i]) - 1
while token_type_ids[token_end_index] != 1:
token_end_index -= 1
word_ids = encoding.word_ids(i)[token_start_index : token_end_index + 1]
start_position = cls_index
end_position = cls_index
# 循环遍历 word_ids,直到它匹配单词中答案的位置
# 一旦匹配,将 `token_start_index` 保存为编码中答案的 `start_position`
for id in word_ids:
if id == word_idx_start:
start_position = token_start_index
else:
token_start_index += 1
# 类似地,从末尾开始循环遍历 `word_ids` 以找到编码中答案的 `end_position`
for id in word_ids[::-1]:
if id == word_idx_end:
end_position = token_end_index
else:
token_end_index -= 1
start_positions.append(start_position)
end_positions.append(end_position)
else:
start_positions.append(cls_index)
end_positions.append(cls_index)
encoding["image"] = examples["image"]
encoding["start_positions"] = start_positions
encoding["end_positions"] = end_positions
return encoding
现在我们有了这个预处理函数,我们可以编码整个数据集:
encoded_train_dataset = dataset_with_ocr["train"].map(
encode_dataset, batched=True, batch_size=2, remove_columns=dataset_with_ocr["train"].column_names
)
encoded_test_dataset = dataset_with_ocr["test"].map(
encode_dataset, batched=True, batch_size=2, remove_columns=dataset_with_ocr["test"].column_names
)
让我们检查编码数据集的特征是什么样的:
encoded_train_dataset.features
评估¶
文档问答的评估需要大量的后处理。为了避免占用您太多时间,本指南跳过了评估步骤。Trainer 仍然在训练过程中计算评估损失,因此您不会完全不了解模型的性能。提取式问答通常使用 F1/精确匹配进行评估。如果您想自己实现,请查看 Hugging Face 课程中的 问答章节 以获取灵感。
训练¶
恭喜!您已经成功导航了本指南中最困难的部分,现在您已经准备好训练自己的模型了。训练包括以下步骤:
- 使用
AutoModelForDocumentQuestionAnswering加载模型,使用与预处理相同的检查点。 - 在
TrainingArguments中定义您的训练超参数,使用output_dir指定保存模型的位置,并配置您认为合适的超参数。如果您希望与社区分享您的模型,请将push_to_hub设置为True(您必须登录 Hugging Face 才能上传您的模型)。在这种情况下,output_dir也将是存储您的模型检查点的存储库的名称。 - 定义一个简单的数据整理器来批量处理示例,这里
DefaultDataCollator将做得很好。 - 将训练参数传递给
Trainer,以及模型、数据集和数据整理器。 - 调用
train()来微调您的模型。
from transformers import AutoModelForDocumentQuestionAnswering
model = AutoModelForDocumentQuestionAnswering.from_pretrained(model_checkpoint)
from transformers import TrainingArguments
# 将此替换为你的存储库 ID
repo_id = "MariaK/layoutlmv2-base-uncased_finetuned_docvqa"
training_args = TrainingArguments(
output_dir=repo_id,
per_device_train_batch_size=4,
num_train_epochs=20,
save_steps=200,
logging_steps=50,
eval_strategy="steps",
learning_rate=5e-5,
save_total_limit=2,
remove_unused_columns=False,
push_to_hub=True,
)
from transformers import DefaultDataCollator
data_collator = DefaultDataCollator()
from transformers import Trainer
trainer = Trainer(
model=model,
args=training_args,
data_collator=data_collator,
train_dataset=encoded_train_dataset,
eval_dataset=encoded_test_dataset,
processing_class=processor,
)
trainer.train()
trainer.create_model_card()
trainer.push_to_hub()
example = dataset["test"][2]
question = example["query"]["en"]
image = example["image"]
print(question)
print(example["answers"])
接下来,使用您的模型实例化一个文档问答的管道,并将图像 + 问题的组合传递给它。
from transformers import pipeline
qa_pipeline = pipeline("document-question-answering", model="MariaK/layoutlmv2-base-uncased_finetuned_docvqa")
qa_pipeline(image, question)
如果您愿意,也可以手动复制管道的结果:
- 取一个图像和一个问题,使用您模型中的处理器为模型准备它们。
- 将预处理的结果或图像和问题通过模型转发。
- 模型返回
start_logits和end_logits,它们指示哪个标记是答案的起始,哪个标记是答案的结束。两者都有形状 (batch_size, sequence_length)。 - 对
start_logits和end_logits的最后一个维度取 argmax,以获得预测的start_idx和end_idx。 - 使用标记器解码答案。
import torch
from transformers import AutoProcessor
from transformers import AutoModelForDocumentQuestionAnswering
processor = AutoProcessor.from_pretrained("MariaK/layoutlmv2-base-uncased_finetuned_docvqa")
model = AutoModelForDocumentQuestionAnswering.from_pretrained("MariaK/layoutlmv2-base-uncased_finetuned_docvqa")
with torch.no_grad():
encoding = processor(image.convert("RGB"), question, return_tensors="pt")
outputs = model(**encoding)
start_logits = outputs.start_logits
end_logits = outputs.end_logits
predicted_start_idx = start_logits.argmax(-1).item()
predicted_end_idx = end_logits.argmax(-1).item()
processor.tokenizer.decode(encoding.input_ids.squeeze()[predicted_start_idx : predicted_end_idx + 1])
这样,您就可以使用微调后的 LayoutLMv2 模型来回答关于文档图像的问题了。如果您对模型的表现感到满意,可以考虑将其分享到 Hugging Face 的模型库中,以便其他研究人员和开发者可以使用和改进它。