시각적 질의응답
시각적 질의응답 (Visual question answering)
시각적 질의응답(VQA)은 이미지를 바탕으로 개방형 질문(open-ended question)에 답하는 작업이에요. 이 작업을 지원하는 모델의 입력은 보통 이미지와 질문의 결합이고, 출력은 자연어로 표현된 답이죠.
VQA의 주목할 만한 사용 사례 몇 가지를 꼽자면:
- 시각 장애인을 위한 접근성 애플리케이션.
- 교육: 강의나 교과서에 제시된 시각적 자료에 관한 질문 제기. VQA는 상호작용형 박물관 전시나 역사 유적에서도 활용될 수 있어요.
- 고객 서비스와 전자상거래: VQA는 사용자가 제품에 대해 질문할 수 있게 해 사용자 경험을 향상시킬 수 있어요.
- 이미지 검색: VQA 모델로 특정 특성을 가진 이미지를 검색할 수 있어요. 예를 들어 사용자가 "강아지가 있나요?"라고 물으면 이미지 집합에서 강아지가 있는 모든 이미지를 찾을 수 있죠.
이 가이드에서 배울 내용은 이렇습니다:
- 분류형 VQA 모델, 특히 ViLT를
Graphcore/vqa데이터셋으로 파인튜닝하기. - 파인튜닝한 ViLT를 추론에 사용하기.
- BLIP-2 같은 생성형 모델로 제로샷(zero-shot) VQA 추론 실행하기.
ViLT 파인튜닝
ViLT 모델은 텍스트 임베딩을 Vision Transformer(ViT)에 통합해, 비전-언어 사전 훈련(Vision-and-Language Pre-training, VLP)에 대해 미니멀한 설계를 가능하게 합니다. 이 모델은 여러 다운스트림 작업에 사용될 수 있어요. VQA 작업의 경우 [CLS] 토큰의 마지막 은닉 상태 위에 (선형 레이어로 된) 분류 헤드가 얹히고 무작위로 초기화됩니다. 이렇게 해서 시각적 질의응답은 분류 문제로 취급됩니다.
BLIP, BLIP-2, InstructBLIP 같은 더 최근의 모델은 VQA를 생성(generative) 작업으로 다룹니다. 이 가이드의 뒷부분에서 이들을 제로샷 VQA 추론에 어떻게 쓰는지 보여 드릴게요.
시작하기 전에 필요한 라이브러리가 모두 설치돼 있는지 확인하세요.
pip install -q transformers datasets
모델을 커뮤니티와 공유하길 권장해요. 🤗 Hub에 업로드하려면 Hugging Face 계정에 로그인하세요. 요청이 오면 토큰을 입력해 로그인합니다:
>>> from huggingface_hub import notebook_login
>>> notebook_login()
모델 체크포인트를 전역 변수로 정의해 볼게요.
>>> model_checkpoint = "dandelin/vilt-b32-mlm"
데이터 불러오기
설명을 위해, 이 가이드에서는 조정된(annotated) 시각적 질의응답 Graphcore/vqa 데이터셋의 아주 작은 샘플을 사용합니다. 전체 데이터셋은 🤗 Hub에서 찾을 수 있어요.
Graphcore/vqa 데이터셋 대신, 공식 VQA 데이터셋 페이지에서 같은 데이터를 수동으로 내려받을 수도 있습니다. 커스텀 데이터로 튜토리얼을 진행하고 싶다면 🤗 Datasets 문서의 이미지 데이터셋 만들기 가이드를 확인해 보세요.
검증 스플릿의 처음 200개 예시를 불러오고 데이터셋의 피처(feature)를 살펴볼게요:
>>> from datasets import load_dataset
>>> dataset = load_dataset("Graphcore/vqa", split="validation[:200]")
>>> dataset
Dataset({
features: ['question', 'question_type', 'question_id', 'image_id', 'answer_type', 'label'],
num_rows: 200
})
데이터셋의 피처를 이해하기 위해 예시 하나를 살펴볼게요:
>>> dataset[0]
{'question': 'Where is he looking?',
'question_type': 'none of the above',
'question_id': 262148000,
'image_id': '/root/.cache/huggingface/datasets/downloads/extracted/ca733e0e000fb2d7a09fbcc94dbfe7b5a30750681d0e965f8e0a23b1c2f98c75/val2014/COCO_val2014_000000262148.jpg',
'answer_type': 'other',
'label': {'ids': ['at table', 'down', 'skateboard', 'table'],
'weights': [0.30000001192092896,
1.0,
0.30000001192092896,
0.30000001192092896]}}
작업에 관련된 피처는 다음과 같습니다:
question: 이미지에서 답을 구할 질문image_id: 질문이 가리키는 이미지의 경로label: 조정(annotation) 결과
필요 없을 나머지 피처는 제거할 수 있어요:
>>> dataset = dataset.remove_columns(['question_type', 'question_id', 'answer_type'])
보시다시피 label 피처에는 서로 다른 인간 조정자들이 수집한 같은 질문에 대한 여러 답(여기서는 ids라고 함)이 담겨 있어요. 질문에 대한 답은 주관적일 수 있기 때문이죠. 이 경우 질문은 "그가 어디를 보고 있나요?"예요. 어떤 사람들은 "아래(down)"로, 다른 사람들은 "테이블(at table)", 또 "스케이트보드(skateboard)" 등으로 조정했어요.
이미지를 살펴보고 자신이라면 어떤 답을 줄지 생각해 보세요:
>>> from PIL import Image
>>> image = Image.open(dataset[0]['image_id'])
>>> image
질문과 답의 모호성 때문에, 이런 데이터셋은 다중 라벨 분류 문제로 취급됩니다 (여러 답이 모두 유효할 수 있으니까요). 게다가 원-핫(one-hot) 인코딩 벡터를 만들기보다, 특정 답이 조정 결과에 나타난 횟수를 기준으로 소프트(soft) 인코딩을 만들죠.
예를 들어, 위 예시에서 "아래(down)"라는 답이 다른 답보다 훨씬 자주 선택됐기 때문에 1.0의 점수(데이터셋에서 weight라고 함)를 갖고, 나머지 답들은 1.0 미만의 점수를 가집니다.
나중에 적절한 분류 헤드로 모델을 인스턴스화할 수 있도록, 라벨 이름을 정수로 매핑하는 사전과 그 반대를 매핑하는 사전 두 개를 만들어 볼게요:
>>> import itertools
>>> labels = [item['ids'] for item in dataset['label']]
>>> flattened_labels = list(itertools.chain(*labels))
>>> unique_labels = list(set(flattened_labels))
>>> label2id = {label: idx for idx, label in enumerate(unique_labels)}
>>> id2label = {idx: label for label, idx in label2id.items()}
이제 매핑이 생겼으니, 문자열 답을 해당 id로 바꾸고, 이후 전처리를 더 편리하게 하도록 데이터셋을 평탄화(flatten)할 수 있어요.
>>> def replace_ids(inputs):
... inputs["label"]["ids"] = [label2id[x] for x in inputs["label"]["ids"]]
... return inputs
>>> dataset = dataset.map(replace_ids)
>>> flat_dataset = dataset.flatten()
>>> flat_dataset.features
{'question': Value(dtype='string', id=None),
'image_id': Value(dtype='string', id=None),
'label.ids': Sequence(feature=Value(dtype='int64', id=None), length=-1, id=None),
'label.weights': Sequence(feature=Value(dtype='float64', id=None), length=-1, id=None)}
데이터 전처리
다음 단계는 모델용 이미지와 텍스트 데이터를 준비할 ViLT 프로세서를 불러오는 거예요. [ViltProcessor]는 BERT 토크나이저와 ViLT 이미지 프로세서를 편리한 단일 프로세서로 감쌉니다:
>>> from transformers import ViltProcessor
>>> processor = ViltProcessor.from_pretrained(model_checkpoint)
데이터를 전처리하려면 [ViltProcessor]로 이미지와 질문을 인코딩해야 해요. 프로세서는 [BertTokenizerFast]로 텍스트를 토크나이즈해 텍스트 데이터용 input_ids, attention_mask, token_type_ids를 만듭니다. 이미지의 경우 프로세서는 [ViltImageProcessor]를 활용해 이미지를 리사이즈·정규화하고 pixel_values와 pixel_mask를 만듭니다.
이 모든 전처리 단계는 내부적으로 처리되므로, 우리는 processor를 호출하기만 하면 돼요. 하지만 타깃 라벨은 여전히 준비해야 합니다. 이 표현에서 각 요소는 가능한 답(라벨)에 해당해요. 정답인 요소에는 각자의 점수(weight)가 담기고, 나머지 요소는 0으로 설정됩니다.
다음 함수는 processor를 이미지와 질문에 적용하고 라벨을 위에서 설명한 대로 형식화합니다:
>>> import torch
>>> def preprocess_data(examples):
... image_paths = examples['image_id']
... images = [Image.open(image_path) for image_path in image_paths]
... texts = examples['question']
... encoding = processor(images, texts, padding="max_length", truncation=True, return_tensors="pt")
... for k, v in encoding.items():
... encoding[k] = v.squeeze()
... targets = []
... for labels, scores in zip(examples['label.ids'], examples['label.weights']):
... target = torch.zeros(len(id2label))
... for label, score in zip(labels, scores):
... target[label] = score
... targets.append(target)
... encoding["labels"] = targets
... return encoding
전체 데이터셋에 전처리 함수를 적용하려면 🤗 Datasets의 [~datasets.map] 함수를 쓰세요. batched=True로 설정하면 데이터셋의 여러 요소를 한 번에 처리해 map을 가속화할 수 있어요. 이 시점에서 필요 없는 컬럼은 마음껏 제거해도 됩니다.
>>> processed_dataset = flat_dataset.map(preprocess_data, batched=True, remove_columns=['question','question_type', 'question_id', 'image_id', 'answer_type', 'label.ids', 'label.weights'])
>>> processed_dataset
Dataset({
features: ['input_ids', 'token_type_ids', 'attention_mask', 'pixel_values', 'pixel_mask', 'labels'],
num_rows: 200
})
마지막 단계로 [DefaultDataCollator]로 예시 배치를 만듭니다:
>>> from transformers import DefaultDataCollator
>>> data_collator = DefaultDataCollator()
모델 훈련하기
이제 모델 훈련을 시작할 준비가 됐어요! [ViltForQuestionAnswering]으로 ViLT를 불러옵니다. 라벨 수와 함께 라벨 매핑을 지정해 주세요:
>>> from transformers import ViltForQuestionAnswering
>>> model = ViltForQuestionAnswering.from_pretrained(model_checkpoint, num_labels=len(id2label), id2label=id2label, label2id=label2id)
이 시점에 남은 단계는 세 개뿐이에요:
- [
TrainingArguments]에 훈련 하이퍼파라미터를 정의합니다:
>>> from transformers import TrainingArguments
>>> repo_id = "MariaK/vilt_finetuned_200"
>>> training_args = TrainingArguments(
... output_dir=repo_id,
... per_device_train_batch_size=4,
... num_train_epochs=20,
... save_steps=200,
... logging_steps=50,
... learning_rate=5e-5,
... save_total_limit=2,
... remove_unused_columns=False,
... push_to_hub=True,
... )
- 훈련 인자를 모델, 데이터셋, 프로세서, 데이터 콜레이터와 함께 [
Trainer]로 넘깁니다.
>>> from transformers import Trainer
>>> trainer = Trainer(
... model=model,
... args=training_args,
... data_collator=data_collator,
... train_dataset=processed_dataset,
... processing_class=processor,
... )
- [
~Trainer.train]을 호출해 모델을 파인튜닝합니다.
>>> trainer.train()
훈련이 완료되면 [~Trainer.push_to_hub] 메서드로 모델을 Hub에 공유해 최종 모델을 🤗 Hub에 올리세요:
>>> trainer.push_to_hub()
추론
이제 ViLT 모델을 파인튜닝해 🤗 Hub에 업로드했으니, 추론에 쓸 수 있어요.
- 이미지와 질문을 가져와 모델의 프로세서로 모델에 맞게 준비합니다.
- 전처리 결과를 모델에 순전파(forward pass)합니다.
- 로짓에서 가장 가능성이 높은 답의 id를 얻고,
id2label에서 실제 답을 찾습니다.
>>> processor = ViltProcessor.from_pretrained("MariaK/vilt_finetuned_200")
>>> image = Image.open(example['image_id'])
>>> question = example['question']
>>> # prepare inputs
>>> inputs = processor(image, question, return_tensors="pt")
>>> model = ViltForQuestionAnswering.from_pretrained("MariaK/vilt_finetuned_200")
>>> # forward pass
>>> with torch.no_grad():
... outputs = model(**inputs)
>>> logits = outputs.logits
>>> idx = logits.argmax(-1).item()
>>> print("Predicted answer:", model.config.id2label[idx])
Predicted answer: down
제로샷 VQA
앞선 모델은 VQA를 분류 작업으로 다뤘어요. BLIP, BLIP-2, InstructBLIP 같은 일부 최신 모델은 VQA를 생성 작업으로 접근합니다. BLIP-2를 예로 들어 볼게요. BLIP-2는 사전 훈련된 비전 인코더와 LLM의 어떤 조합이든 쓸 수 있는 새로운 비전-언어 사전 훈련 패러다임을 도입했어요 (자세한 내용은 BLIP-2 블로그 포스트에서 확인하세요). 이 덕분에 시각적 질의응답을 포함한 여러 비전-언어 작업에서 최첨단 결과를 낼 수 있습니다.
이 모델을 VQA에 어떻게 쓰는지 살펴볼게요. 먼저 모델을 불러옵니다. 여기서는 가능하면 모델을 명시적으로 GPU로 보낼게요. 훈련할 때는 [Trainer]가 이를 자동으로 처리하므로 GPU로 보낼 필요가 없었지만요:
>>> from transformers import AutoProcessor, Blip2ForConditionalGeneration
from accelerate import Accelerator
>>> import torch
>>> processor = AutoProcessor.from_pretrained("Salesforce/blip2-opt-2.7b")
>>> model = Blip2ForConditionalGeneration.from_pretrained("Salesforce/blip2-opt-2.7b", dtype=torch.float16)
>>> device = Accelerator().device
>>> model.to(device)
모델은 이미지와 텍스트를 입력으로 받으니, VQA 데이터셋의 첫 번째 예시와 완전히 같은 이미지/질문 쌍을 사용해 볼게요:
>>> example = dataset[0]
>>> image = Image.open(example['image_id'])
>>> question = example['question']
BLIP-2를 시각적 질의응답 작업에 쓰려면 텍스트 프롬프트가 특정 형식을 따라야 해요: Question: {} Answer:.
>>> prompt = f"Question: {question} Answer:"
이제 모델의 프로세서로 이미지/프롬프트를 전처리하고, 처리된 입력을 모델에 통과시킨 뒤 출력을 디코딩해야 합니다:
>>> inputs = processor(image, text=prompt, return_tensors="pt").to(device, torch.float16)
>>> generated_ids = model.generate(**inputs, max_new_tokens=10)
>>> generated_text = processor.batch_decode(generated_ids, skip_special_tokens=True)[0].strip()
>>> print(generated_text)
"He is looking at the crowd"
보시다시피 모델은 군중과 얼굴 방향(아래를 봄)을 인식했지만, 군중이 스케이트보더 뒤에 있다는 사실은 놓치고 있어요. 그래도 인간이 조정한 데이터셋을 확보하기 어려운 경우엔 이 접근 방식이 빠르게 유용한 결과를 낼 수 있습니다.
더 알아보기 (Learn more)
- ViLT 문서, BLIP-2 문서.
- BLIP-2 블로그 포스트: 새로운 비전-언어 사전 훈련 패러다임.
- 🤗 Datasets 문서의 이미지 데이터셋 만들기 가이드.