문서 질의응답
Document Question Answering(문서 시각 질의응답)는 문서 이미지에 대한 질문에 답을 제공하는 작업입니다. 이 작업을 지원하는 모델의 입력은 일반적으로 이미지와 질문의 조합이며, 출력은 자연어로 표현된 답변입니다. 이러한 모델은 텍스트, 단어의 위치(bounding box), 이미지 자체를 포함한 여러 모달리티를 활용합니다.
이 가이드에서는 다음 내용을 보여줍니다:
- DocVQA 데이터셋에서 LayoutLMv2를 파인튜닝합니다.
- 파인튜닝된 모델을 추론에 사용하세요.
이 작업과 호환되는 모든 아키텍처와 체크포인트를 확인하려면 task-page를 확인하는 것을 권장합니다.
LayoutLMv2는 토큰의 최종 hidden states 위에 question-answering head를 추가하여 답변의 시작 및 종료 토큰 위치를 예측함으로써 문서 question-answering 작업을 해결합니다. 즉, 이 문제는 추출형 question answering으로 취급됩니다. 즉, 주어진 context에서 질문을 답변하는 정보 조각을 추출하는 것입니다. context는 OCR 엔진의 출력에서 가져오며, 여기서는 Google의 Tesseract를 사용합니다.
시작하기 전에 필요한 라이브러리가 모두 설치되어 있는지 확인하세요. LayoutLMv2는 detectron2, torchvision, tesseract에 의존합니다.
pip install -q transformers datasets
pip install 'git+https://github.com/facebookresearch/detectron2.git'
pip install torchvisionsudo 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데이터 로드
이 가이드에서는 🤗 Hub에서 찾을 수 있는 전처리된 DocVQA의 작은 샘플을 사용합니다. 전체 DocVQA 데이터셋을 사용하려면 DocVQA 홈페이지에서 등록하고 다운로드할 수 있습니다. 그렇게 하신 경우, 이 가이드를 계속 진행하려면 🤗 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로 학습되었습니다(이 정보는 체크포인트의 config.json 파일에서 확인할 수 있습니다).
예시를 잘라낼 수는 있지만, 답변이 긴 문서의 끝에 위치하여 잘려 버리는 상황을 피하기 위해, 임베딩 길이가 512를 초과할 가능성이 있는 일부 예시를 제거하겠습니다.
데이터셋의 대부분의 문서가 길다면 슬라이딩 윈도우 전략을 구현할 수 있으며, 자세한 내용은 이 노트북을 참고하세요.
>>> updated_dataset = updated_dataset.filter(lambda x: len(x["words"]) + len(x["question"].split()) < 512)이 시점에서 이 데이터셋에서 OCR 기능도 제거해 보겠습니다. 이는 다른 모델을 fine-tuning하기 위한 OCR의 결과물입니다. 이 가이드에서 사용하는 모델의 입력 요구사항과 일치하지 않으므로, 사용하려면 추가 처리가 필요합니다. 대신, 원본 데이터에 대해 LayoutLMv2Processor를 사용하여 OCR과 tokenization을 모두 수행할 수 있습니다. 이렇게 하면 모델이 기대하는 입력과 일치하는 입력을 얻을 수 있습니다. 이미지를 수동으로 처리하려면, 모델이 기대하는 입력 형식을 확인하기 위해 LayoutLMv2 모델 문서를 참고하세요.
>>> updated_dataset = updated_dataset.remove_columns("words")
>>> updated_dataset = updated_dataset.remove_columns("bounding_boxes")마지막으로, 이미지 예시를 살펴보지 않으면 데이터 탐색이 완성되지 않습니다.
>>> updated_dataset["train"][11]["image"]
데이터 전처리
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로 구성되며, 각각 답변의 시작과 끝에 해당하는 토큰을 나타냅니다.
그것부터 시작해 보겠습니다. 더 큰 리스트(단어 리스트)에서 부분 리스트(단어로 나뉜 답변)를 찾을 수 있는 헬퍼 함수를 정의합니다.
This function will take two lists as input, words_list and answer_list. It will then iterate over the words_list and check
if the current word in the words_list (words_list[i]) is equal to the first word of answer_list (answer_list[0]) and if
the sublist of words_list starting from the current word and of the same length as answer_list is equal to answer_list.
If this condition is true, it means that a match has been found, and the function will record the match, its starting index (idx),
and its ending index (idx + len(answer_list) - 1). If more than one match was found, the function will return only the first one.
If no match is found, the function returns (None, 0, and 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에서 찾은 답변을 전체 인코딩된 입력의 동일한 답변과 매칭하고, 인코딩된 입력에서 답변의 시작/끝 위치를 결정하는 데 도움이 됩니다.
이를 염두에 두고, 데이터셋의 예시 배치(batch)를 인코딩하는 함수를 만들어 보겠습니다:
>>> 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)}평가
문서 질의응답 평가에는 상당한 후처리가 필요합니다. 시간을 너무 많이 쓰지 않도록 하기 위해 이 가이드에서는 평가 단계를 생략합니다. Trainer는 여전히 학습 중에 평가 손실을 계산하므로 모델 성능에 대해 완전히 알 수 없는 상태는 아닙니다. 추출형 질의응답은 일반적으로 F1/정확한 일치로 평가합니다. 직접 구현해 보려면 영감을 얻기 위해 Hugging Face 코스의 Question Answering chapter를 참고해 보세요.
학습
축하합니다! 이 가이드에서 가장 어려운 부분을 성공적으로 통과하셨으며 이제 자체 모델을 학습시킬 준비가 되었습니다. 학습에는 다음 단계가 포함됩니다:
- 전처리에서 사용한 것과 동일한 체크포인트를 사용하여 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()추론
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']다음으로, document question answering를 위한 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의 결과를 수동으로 재현할 수도 있습니다:
- 이미지와 질문을 가져온 후, 모델의 processor를 사용하여 모델에 맞게 준비합니다.
- 결과 또는 전처리 결과를 모델로 전달합니다.
- 모델은
start_logits와end_logits를 반환하며, 이는 각각 답변의 시작과 끝에 위치한 토큰을 나타냅니다. 두 값 모두 shape이 (batch_size, sequence_length)입니다. start_logits와end_logits의 마지막 차원에 argmax를 적용하여 예측된start_idx와end_idx를 구합니다.- tokenizer로 답변을 디코딩합니다.
>>> 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'