문서 질의응답
문서 질의응답 (Document question answering)
Document Question Answering은 Document Visual Question Answering이라고도 불리며, 문서 이미지에 대해 제기된 질문에 답변을 제공하는 작업입니다. 이 작업을 지원하는 모델의 입력은 보통 이미지와 질문의 조합이고, 출력은 자연어로 표현된 답변입니다.
출처: 문서
본문
이런 모델은 텍스트, 단어의 위치(바운딩 박스), 이미지 자체 등 여러 모달리티를 활용합니다.
이 가이드에서 다룰 내용은 다음과 같습니다.
- DocVQA dataset에서 LayoutLMv2를 파인튜닝합니다.
- 파인튜닝된 모델을 추론(inference)에 사용합니다.
이 작업과 호환되는 모든 아키텍처와 체크포인트를 보려면 task-page를 확인하는 것을 권장합니다.
LayoutLMv2는 토큰의 최종 hidden state 위에 질의응답 헤드를 추가해 답변의 시작·끝 토큰 위치를 예측함으로써 문서 질의응답 작업을 해결합니다. 즉, 문제를 추출형 질의응답(extractive question answering)으로 취급합니다. 주어진 문맥에서 질문에 답하는 정보 조각을 추출하는 것입니다. 문맥은 OCR 엔진(여기서는 Google의 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
모든 의존성을 설치한 후에는 런타임을 재시작하세요.
모델을 커뮤니티와 공유하는 것을 권장합니다. 🤗 Hub에 업로드하려면 Hugging Face 계정에 로그인하세요. 프롬프트가 나타나면 토큰을 입력해 로그인합니다.
>>> from huggingface_hub import notebook_login
>>> notebook_login()
일부 전역 변수를 정의해 보겠습니다.
>>> model_checkpoint = "microsoft/layoutlmv2-base-uncased"
>>> batch_size = 4
데이터 로드하기 (Load the data)
이 가이드에서는 🤗 Hub에서 찾을 수 있는 전처리된 DocVQA의 작은 샘플을 사용합니다. 전체 DocVQA 데이터셋을 사용하려면 DocVQA homepage에서 등록하고 다운로드할 수 있습니다. 그렇게 한다면 이 가이드를 진행하기 위해 how to load files into a 🤗 dataset를 확인해 주세요.
>>> from datasets import load_dataset
>>> dataset = load_dataset("nielsr/docvqa_1200_examples")
>>> dataset
DatasetDict({
train: Dataset({
features: ['id', 'image', 'query', 'answers', 'words', 'bounding_boxes', 'answer'],
num_rows: 1000
})
test: Dataset({
features: ['id', 'image', 'query', 'answers', 'words', 'bounding_boxes', 'answer'],
num_rows: 200
})
})
보시다시피 데이터셋은 이미 train과 test 세트로 나뉘어 있습니다. 피처에 익숙해지기 위해 임의의 예시를 하나 살펴봅니다.
>>> 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로 훈련되었습니다(이 정보는 checkpoint's config.json file에서 찾을 수 있습니다). 예시를 잘라낼 수도 있지만, 큰 문서의 끝에 답변이 있어 잘리는 상황을 피하기 위해 여기서는 임베딩이 512보다 길어질 가능성이 있는 몇몇 예시를 제거하겠습니다. 데이터셋의 대부분 문서가 길다면 슬라이딩 윈도우 전략을 구현할 수 있습니다 - 자세한 내용은 this notebook을 참고하세요.
>>> updated_dataset = updated_dataset.filter(lambda x: len(x["words"]) + len(x["question"].split()) < 512)
이 시점에서 데이터셋에서 OCR 피처도 제거하겠습니다. 이는 다른 모델을 파인튜닝하기 위한 OCR 결과입니다. 이 가이드에서 사용하는 모델의 입력 요구사항과 일치하지 않으므로, 사용하려면 여전히 일부 처리가 필요합니다. 대신 원본 데이터에서 LayoutLMv2Processor를 사용해 OCR과 토크나이제이션을 모두 수행할 수 있습니다. 이렇게 하면 모델이 기대하는 입력과 일치하는 입력을 얻을 수 있습니다. 이미지를 수동으로 처리하려면 LayoutLMv2 model documentation에서 모델이 기대하는 입력 형식을 확인해 보세요.
>>> updated_dataset = updated_dataset.remove_columns("words")
>>> updated_dataset = updated_dataset.remove_columns("bounding_boxes")
마지막으로 이미지 예시를 하나 살펴보지 않으면 데이터 탐색이 완성되지 않습니다.
>>> updated_dataset["train"][11]["image"]
데이터 전처리하기 (Preprocess the data)
Document Question Answering은 멀티모달 작업이므로 각 모달리티의 입력이 모델의 기대에 맞게 전처리되도록 해야 합니다. 먼저 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가 필요합니다.
>>> tokenizer = processor.tokenizer
위에서 언급한 전처리에 더해 모델을 위한 라벨도 추가해야 합니다. 🤗 Transformers의 xxxForQuestionAnswering 모델에서 라벨은 start_positions와 end_positions로 구성되며, 답변의 시작 토큰과 끝 토큰이 무엇인지 나타냅니다.
그것부터 시작하겠습니다. 더 큰 리스트(단어 리스트)에서 하위 리스트(단어로 나뉜 답변)를 찾을 수 있는 헬퍼 함수를 정의합니다.
이 함수는 words_list와 answer_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)
Question: Who is in cc in this letter?
Words: ['wie', 'baw', 'brown', '&', 'williamson', 'tobacco', 'corporation', 'research', '&', 'development', 'internal', 'correspondence', 'to:', 'r.', 'h.', 'honeycutt', 'ce:', 't.f.', 'riehl', 'from:', '.', 'c.j.', 'cook', 'date:', 'may', '8,', '1995', 'subject:', 'review', 'of', 'existing', 'brainstorming', 'ideas/483', 'the', 'major', 'function', 'of', 'the', 'product', 'innovation', 'graup', 'is', 'to', 'develop', 'marketable', 'nove!', 'products', 'that', 'would', 'be', 'profitable', 'to', 'manufacture', 'and', 'sell.', 'novel', 'is', 'defined', 'as:', 'of', 'a', 'new', 'kind,', 'or', 'different', 'from', 'anything', 'seen', 'or', 'known', 'before.', 'innovation', 'is', 'defined', 'as:', 'something', 'new', 'or', 'different', 'introduced;', 'act', 'of', 'innovating;', 'introduction', 'of', 'new', 'things', 'or', 'methods.', 'the', 'products', 'may', 'incorporate', 'the', 'latest', 'technologies,', 'materials', 'and', 'know-how', 'available', 'to', 'give', 'then', 'a', 'unique', 'taste', 'or', 'look.', 'the', 'first', 'task', 'of', 'the', 'product', 'innovation', 'group', 'was', 'to', 'assemble,', 'review', 'and', 'categorize', 'a', 'list', 'of', 'existing', 'brainstorming', 'ideas.', 'ideas', 'were', 'grouped', 'into', 'two', 'major', 'categories', 'labeled', 'appearance', 'and', 'taste/aroma.', 'these', 'categories', 'are', 'used', 'for', 'novel', 'products', 'that', 'may', 'differ', 'from', 'a', 'visual', 'and/or', 'taste/aroma', 'point', 'of', 'view', 'compared', 'to', 'canventional', 'cigarettes.', 'other', 'categories', 'include', 'a', 'combination', 'of', 'the', 'above,', 'filters,', 'packaging', 'and', 'brand', 'extensions.', 'appearance', 'this', 'category', 'is', 'used', 'for', 'novel', 'cigarette', 'constructions', 'that', 'yield', 'visually', 'different', 'products', 'with', 'minimal', 'changes', 'in', 'smoke', 'chemistry', 'two', 'cigarettes', 'in', 'cne.', 'emulti-plug', 'te', 'build', 'yaur', 'awn', 'cigarette.', 'eswitchable', 'menthol', 'or', 'non', 'menthol', 'cigarette.', '*cigarettes', 'with', 'interspaced', 'perforations', 'to', 'enable', 'smoker', 'to', 'separate', 'unburned', 'section', 'for', 'future', 'smoking.', '«short', 'cigarette,', 'tobacco', 'section', '30', 'mm.', '«extremely', 'fast', 'buming', 'cigarette.', '«novel', 'cigarette', 'constructions', 'that', 'permit', 'a', 'significant', 'reduction', 'iretobacco', 'weight', 'while', 'maintaining', 'smoking', 'mechanics', 'and', 'visual', 'characteristics.', 'higher', 'basis', 'weight', 'paper:', 'potential', 'reduction', 'in', 'tobacco', 'weight.', '«more', 'rigid', 'tobacco', 'column;', 'stiffing', 'agent', 'for', 'tobacco;', 'e.g.', 'starch', '*colored', 'tow', 'and', 'cigarette', 'papers;', 'seasonal', 'promotions,', 'e.g.', 'pastel', 'colored', 'cigarettes', 'for', 'easter', 'or', 'in', 'an', 'ebony', 'and', 'ivory', 'brand', 'containing', 'a', 'mixture', 'of', 'all', 'black', '(black', 'paper', 'and', 'tow)', 'and', 'ail', 'white', 'cigarettes.', '499150498']
Answer: T.F. Riehl
start_index 17
end_index 18
하지만 예시가 인코딩되면 다음과 같이 보입니다.
>>> encoding = tokenizer(example["question"], example["words"], example["boxes"])
>>> tokenizer.decode(encoding["input_ids"])
[CLS] who is in cc in this letter? [SEP] wie baw brown & williamson tobacco corporation research & development ...
인코딩된 입력에서 답변의 위치를 찾아야 합니다.
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"]
... # encode the batch of examples and initialize the start_positions and end_positions
... encoding = tokenizer(questions, words, boxes, max_length=max_length, padding="max_length", truncation=True)
... start_positions = []
... end_positions = []
... # loop through the examples in the batch
... for i in range(len(questions)):
... cls_index = encoding["input_ids"][i].index(tokenizer.cls_token_id)
... # find the position of the answer in example's words
... 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:
... # if match is found, use `token_type_ids` to find where words start in the encoding
... 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
... # loop over word_ids and increase `token_start_index` until it matches the answer position in words
... # once it matches, save the `token_start_index` as the `start_position` of the answer in the encoding
... for id in word_ids:
... if id == word_idx_start:
... start_position = token_start_index
... else:
... token_start_index += 1
... # similarly loop over `word_ids` starting from the end to find the `end_position` of the answer
... 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
{'image': Sequence(feature=Sequence(feature=Sequence(feature=Value(dtype='uint8', id=None), length=-1, id=None), length=-1, id=None), length=-1, id=None),
'input_ids': Sequence(feature=Value(dtype='int32', id=None), length=-1, id=None),
'token_type_ids': Sequence(feature=Value(dtype='int8', id=None), length=-1, id=None),
'attention_mask': Sequence(feature=Value(dtype='int8', id=None), length=-1, id=None),
'bbox': Sequence(feature=Sequence(feature=Value(dtype='int64', id=None), length=-1, id=None), length=-1, id=None),
'start_positions': Value(dtype='int64', id=None),
'end_positions': Value(dtype='int64', id=None)}
평가 (Evaluation)
문서 질의응답 평가는 상당한 양의 후처리를 요구합니다. 시간을 너무 많이 들이지 않기 위해 이 가이드에서는 평가 단계를 생략합니다. Trainer는 훈련 중에도 평가 loss를 계산하므로 모델 성능을 전혀 모르는 상태는 아닙니다. 추출형 질의응답은 보통 F1/exact match로 평가됩니다. 직접 구현하고 싶다면 Hugging Face 코스의 Question Answering chapter를 참고해 영감을 얻으세요.
훈련 (Train)
축하합니다! 이 가이드에서 가장 어려운 부분을 성공적으로 통과했으니 이제 자신만의 모델을 훈련할 준비가 되었습니다. 훈련에는 다음 단계가 포함됩니다.
- 전처리에서 사용한 동일한 체크포인트로 AutoModelForDocumentQuestionAnswering을 사용해 모델을 로드합니다.
- TrainingArguments에서 훈련 하이퍼파라미터를 정의합니다.
- 예시를 함께 배칭하는 함수를 정의합니다. 여기서는 DefaultDataCollator로 충분합니다.
- Trainer에 모델, 데이터셋, 데이터 콜레이터와 함께 훈련 인자를 전달합니다.
- train()을 호출해 모델을 파인튜닝합니다.
>>> from transformers import AutoModelForDocumentQuestionAnswering
>>> model = AutoModelForDocumentQuestionAnswering.from_pretrained(model_checkpoint)
TrainingArguments에서 output_dir로 모델을 저장할 위치를 지정하고, 하이퍼파라미터를 적절히 구성하세요. 모델을 커뮤니티와 공유하려면 push_to_hub를 True로 설정하세요(Hugging Face에 로그인해야 모델을 업로드할 수 있습니다). 이 경우 output_dir은 모델 체크포인트가 푸시될 저장소의 이름이기도 합니다.
>>> from transformers import TrainingArguments
>>> # REPLACE THIS WITH YOUR REPO 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()
마지막으로 모든 것을 한데 모아 train()을 호출합니다.
>>> 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()
최종 모델을 🤗 Hub에 추가하려면 모델 카드를 만들고 push_to_hub를 호출하세요.
>>> trainer.create_model_card()
>>> trainer.push_to_hub()
추론 (Inference)
이제 LayoutLMv2 모델을 파인튜닝하고 🤗 Hub에 업로드했으니 추론에 사용할 수 있습니다. 파인튜닝된 모델을 추론에 사용해 보는 가장 간단한 방법은 Pipeline에서 사용하는 것입니다.
예시를 하나 들어 보겠습니다.
>>> example = dataset["test"][2]
>>> question = example["query"]["en"]
>>> image = example["image"]
>>> print(question)
>>> print(example["answers"])
'Who is ‘presiding’ TRRF GENERAL SESSION (PART 1)?'
['TRRF Vice President', 'lee a. waller']
다음으로, 모델로 문서 질의응답용 pipeline을 만들고 이미지 + 질문 조합을 전달합니다.
>>> from transformers import pipeline
>>> qa_pipeline = pipeline("document-question-answering", model="MariaK/layoutlmv2-base-uncased_finetuned_docvqa")
>>> qa_pipeline(image, question)
[{'score': 0.9949808120727539,
'answer': 'Lee A. Waller',
'start': 55,
'end': 57}]
원한다면 pipeline의 결과를 직접 재현할 수도 있습니다.
- 이미지와 질문을 가져와 모델의 프로세서를 사용해 모델용으로 준비합니다.
- 전처리 결과를 모델에 통과시킵니다.
- 모델은
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])
'lee a. waller'