사진 속 강아지를 알아보는 것과 “강아지가 공을 물고 있다”라는 문장을 만드는 것은 조금 다른 문제다.
전자는 이미지에 무엇이 있는지 분류하면 되지만, 후자는 등장하는 대상과 행동, 대상 사이의 관계를 파악하고 자연스러운 문장으로 표현해야 한다.
이처럼 이미지를 입력받아 설명 문장을 생성하는 작업을 Image Captioning이라고 한다.
이번 글에서는 이미지 캡셔닝을 예시로 멀티모달의 개념을 살펴보고, Show and Tell과 Show, Attend and Tell이 어떻게 동작하는지 정리해 보려고 한다.
Modality는 데이터가 표현되는 형태를 뜻한다.
텍스트, 이미지, 음성, 영상이 모두 서로 다른 모달리티다. 멀티모달은 이런 서로 다른 형태의 정보를 함께 다루는 것을 말한다.
영화를 볼 때를 생각하면 이해하기 쉽다. 대사만 들을 때보다 배우의 표정과 행동을 함께 보면 장면의 의미를 더 잘 이해할 수 있다. 여기에 자막까지 있으면 놓친 대사를 보완할 수도 있다.
모델도 비슷하다. 이미지와 텍스트를 함께 다루면 사진의 시각적 정보와 언어적 표현 사이의 관계를 학습할 수 있다.
이미지 캡셔닝에서는 다음과 같은 데이터 쌍을 사용한다.
이미지: 강아지가 공을 물고 있는 사진
설명: "A dog is holding a ball."
학습할 때는 이미지와 정답 문장을 함께 사용한다. 학습이 끝난 뒤에는 이미지만 입력받아 설명 문장을 생성한다.
모델은 이미지나 단어를 그대로 계산하지 않는다. 먼저 각 데이터를 숫자로 이루어진 특징 벡터로 바꾼다.
이렇게 얻은 정보를 결합하는 방법에는 여러 가지가 있다.
| 방법 | 설명 | 특징 |
|---|---|---|
| Concatenation | 벡터를 이어 붙인다 | 서로 다른 길이의 벡터도 결합할 수 있다 |
| Summation | 같은 위치의 값을 더한다 | 벡터의 차원이 같아야 한다 |
| Attention | 관련성에 따라 정보의 비중을 조절한다 | 현재 필요한 정보를 더 많이 반영한다 |
예를 들어 256차원 이미지 벡터와 128차원 텍스트 벡터를 이어 붙이면 384차원 벡터가 된다.
반면 두 벡터를 더하려면 차원을 맞춰야 한다. 필요하다면 Linear Layer 같은 학습 가능한 변환을 사용한다.
멀티모달에서 중요한 것은 단순히 벡터를 합치는 것만이 아니다. 서로 다른 정보가 어떤 관계를 갖는지 학습하는 것도 중요하다.
이미지 캡셔닝은 Encoder–Decoder 구조로 이해할 수 있다.
이미지 → Encoder → 이미지 특징 → Decoder → 설명 문장
Encoder는 이미지에서 정보를 추출하고, Decoder는 그 정보를 바탕으로 단어를 하나씩 생성한다.
번역 모델과 비교하면 조금 더 익숙하다.
기계 번역: 영어 문장 → Encoder → Decoder → 한국어 문장
이미지 캡셔닝: 이미지 → Encoder → Decoder → 설명 문장
입력의 종류는 다르지만, 입력 정보를 바탕으로 문장을 생성한다는 점은 같다.
여기서 살펴볼 두 모델은 모두 CNN으로 이미지 특징을 추출하고 LSTM으로 문장을 생성한다. 차이는 문장을 만드는 동안 이미지 정보를 어떻게 참고하는가에 있다.
Show and Tell은 이미지를 하나의 특징 벡터로 요약한 뒤, 이를 LSTM에 입력하는 방식이다.
이미지 분류 모델은 보통 마지막에 클래스별 점수를 출력한다. 이미지 캡셔닝에서는 이 분류 결과보다 문장 생성에 필요한 이미지 표현이 필요하다.
따라서 분류용 출력층을 바꿔 원하는 크기의 벡터를 얻는다.
첨부 자료에서는 다음과 같이 구현했다.
self.inception.fc = nn.Linear(
inception.fc.in_features,
embed_size
)
embed_size = 256이라면 이미지 한 장을 256차원 벡터로 변환하는 것이다.
이미지 → CNN → 256차원 이미지 벡터
이 크기를 단어 임베딩과 맞추면, 이미지 벡터와 단어 벡터를 같은 LSTM의 입력으로 사용할 수 있다.
여기서 embed_size는 벡터의 길이다. 모델이 만들 수 있는 단어의 개수인 vocab_size와는 다르다.
Show and Tell은 이미지 벡터를 먼저 LSTM에 입력한다. 이 과정에서 이미지 정보가 LSTM의 내부 상태에 반영된다.
이후 시작 토큰 <SOS>를 넣고 첫 단어를 예측한다.
이미지 벡터 처리
↓
<SOS> 입력 → A 예측
A 입력 → dog 예측
dog 입력 → is 예측
is 입력 → holding 예측
holding 입력 → a 예측
a 입력 → ball 예측
ball 입력 → <EOS> 예측
<SOS>는 문장의 시작, <EOS>는 문장의 끝을 나타낸다.
원 논문에서도 시작 토큰은 명시되어 있다. 이미지가 먼저 입력된다고 해서 시작 토큰이 필요 없어지는 것은 아니다.
LSTM은 이 과정에서 두 가지 상태를 유지한다.
Show and Tell에서는 이미지 벡터가 처음에 한 번 입력된다. 이후 단어를 생성할 때는 이미지 정보가 반영된 내부 상태를 활용한다.
이미지를 하나의 벡터로 요약하는 방식은 간단하다. 하지만 사진 속 여러 대상의 위치와 세부 정보를 활용하기에는 제약이 생길 수 있다.
예를 들어 “강아지가 공을 물고 있다”라는 문장에서 dog를 만들 때와 ball을 만들 때는 참고할 이미지 영역이 다를 수 있다.
Show, Attend and Tell은 이미지의 여러 영역을 유지하고, 단어를 생성할 때마다 각 영역의 중요도를 계산한다.
첨부 자료에서는 CNN의 마지막 공간적 특징을 다음과 같은 크기로 얻는다.
(batch_size, 2048, 8, 8)
이 크기는 다음처럼 해석할 수 있다.
batch_size: 한 번에 처리하는 이미지 수2048: 각 위치의 특징 차원8 × 8: 특징 지도에 남아 있는 공간적 위치즉, 64개 위치마다 2048차원 특징 벡터가 있는 셈이다.
여기서 각 위치는 원본 사진의 픽셀 하나를 뜻하지 않는다. CNN의 여러 연산을 거쳐 만들어진 특징 지도 위의 위치이며, 원본 사진의 일정 영역에 대한 정보를 담고 있다.
첨부 코드에서는 1×1 합성곱으로 특징 차원을 줄인다.
self.conv_to_embed = nn.Conv2d(
2048,
embed_size,
kernel_size=1
)
embed_size = 256이라면 텐서의 크기는 다음처럼 바뀐다.
(B, 2048, 8, 8)
↓ 1×1 합성곱
(B, 256, 8, 8)
↓ 공간 차원을 펼침
(B, 64, 256)
B는 배치 크기를 뜻한다.
이제 이미지 한 장을 256차원 벡터 64개로 표현할 수 있다. 이 영역별 특징 벡터를 Annotation Vector라고 부른다.
참고로 8×8은 모든 이미지 캡셔닝 모델에서 사용하는 고정 크기가 아니다. 입력 크기와 CNN 구조에 따라 달라진다. 원 논문은 VGG에서 추출한 14×14×512 특징을 사용했고, 첨부 자료는 InceptionV3를 사용하는 변형이다.
Attention의 계산은 세 단계로 나누면 이해하기 쉽다.
이미지 영역 (i)의 특징을 (ai), 이전 LSTM의 hidden state를 (h{t-1})이라고 하자.
첨부 코드의 계산은 다음과 같다.
처음 보면 수식이 복잡하지만, 두 정보를 함께 보고 점수를 만드는 과정이다.
W_f와 W_s는 서로 다른 크기의 벡터를 같은 계산 공간으로 변환한다. 이 변환들과 마지막 점수 계산은 모두 학습된다.
이 방식은 Bahdanau 방식의 additive attention에 해당한다.
영역별 점수에 softmax를 적용하면 Attention Weight를 얻는다.
이 가중치들을 모두 더하면 1이 된다.
예를 들어 아래처럼 비중이 계산될 수 있다.
| 영역 | 가중치 |
|---|---|
| 강아지 주변 | 0.6 |
| 공 주변 | 0.3 |
| 배경 | 0.1 |
실제 모델에서는 64개 위치 모두에 가중치가 생기지만, 여기서는 이해를 위해 세 영역으로 단순화했다.
각 영역의 특징에 가중치를 곱하고 더한다.
이렇게 얻은 (z_t)가 Context Vector다.
현재 단어를 생성할 때 필요한 이미지 정보를 모아 놓은 벡터라고 생각하면 된다.
첨부 코드에서는 다음 줄이 이 계산에 해당한다.
context = (
features * attn_weights.unsqueeze(2)
).sum(dim=1)
그리고 이전 단어의 임베딩과 context를 이어 붙여 LSTM에 입력한다.
lstm_input = torch.cat(
[word_embedding, context],
dim=1
)
단어를 생성할 때마다 LSTM의 상태가 바뀌므로, 이미지 영역별 비중도 매번 다시 계산된다.
지금까지 설명한 방식은 Soft Attention이다. 모든 영역을 사용하되, 영역마다 반영하는 비중을 다르게 한다.
반면 원 논문의 Hard Attention은 가중치를 확률로 사용해 하나의 위치를 선택한다.
Soft Attention은 가중합이 미분 가능해서 일반적인 역전파로 학습할 수 있다. Hard Attention은 이산적인 선택을 포함하기 때문에 별도의 학습 방법이 필요하다.
어느 방식이 항상 더 좋은 것은 아니다. 데이터와 평가 기준에 따라 결과가 달라질 수 있다.
Attention을 계산하려면 이전 hidden state가 필요하다. 그렇다면 첫 단계의 hidden state는 어디서 가져올까?
Show, Attend and Tell은 이미지 영역별 특징의 평균으로 초기 상태를 만든다.
평균 특징을 각각 다른 변환에 통과시켜 hidden state와 cell state를 초기화한다.
첨부 자료에서는 다음과 같이 구현했다.
mean_features = features.mean(dim=(2, 3))
h0 = torch.tanh(self.fc_h(mean_features))
c0 = torch.tanh(self.fc_c(mean_features))
features가 (B, 256, 8, 8)이라면 공간 차원인 8×8을 평균 내므로 mean_features는 (B, 256)이 된다.
이미지 정보는 여기서 두 가지 역할을 한다.
| 사용하는 정보 | 역할 |
|---|---|
| 영역별 특징의 평균 | 이미지 전체 정보를 초기 상태에 반영 |
| Attention으로 계산한 context | 각 단어에 필요한 이미지 정보를 반영 |
여기서 (c_0)는 LSTM의 cell state다. Attention의 context vector와 혼동하지 않아야 한다.
초기 hidden state가 만들어졌기 때문에 첫 단어를 예측할 때부터 Attention을 계산할 수 있다.
학습할 때는 정답 문장을 알고 있다. 따라서 모델이 틀린 단어를 예측하더라도 다음 입력에는 정답 단어를 넣을 수 있다.
이를 Teacher Forcing이라고 한다.
정답 문장이 A dog runs라면 다음과 같이 학습한다.
| Decoder 입력 | 예측해야 하는 정답 |
|---|---|
<SOS> | A |
A | dog |
dog | runs |
runs | <EOS> |
중요한 점은 입력과 정답이 한 칸씩 어긋나야 한다는 것이다.
decoder_inputs = captions[:, :-1]
targets = captions[:, 1:]
Decoder는 현재까지의 정보를 이용해 다음 단어를 맞히는 모델이기 때문이다.
새로운 사진에서는 정답 문장을 알 수 없다. 따라서 예측한 단어를 다음 입력으로 사용한다.
<SOS> → A 예측
A → dog 예측
dog → runs 예측
runs → <EOS> 예측
매번 가장 높은 점수의 단어를 선택하는 방식은 Greedy Decoding이다. 여러 문장 후보를 유지하는 Beam Search 같은 방법도 사용할 수 있다.
생성은 <EOS>가 나오거나 정해 둔 최대 길이에 도달하면 종료한다.
Decoder의 출력은 보통 다음 크기를 갖는다.
(batch_size, sequence_length, vocab_size)
각 위치에서 어휘 전체에 대한 점수인 logits를 출력한다. CrossEntropyLoss는 이 점수와 정답 단어 번호를 비교하고, 정답 단어에 더 높은 점수를 주도록 학습시킨다.
문장마다 길이가 다르므로 배치에서는 짧은 문장 뒤에 <PAD>를 붙인다.
<SOS> A dog runs <EOS> <PAD> <PAD>
<SOS> A dog holds a ball <EOS>
패딩은 실제 문장의 일부가 아니므로 손실 계산에서 제외한다. 첨부 자료에서는 pack_padded_sequence와 ignore_index를 이용해 이를 처리한다.
Encoder와 Decoder에 각각 Optimizer를 만들어도 전체 손실에서 역전파는 한 번 수행할 수 있다. 이후 각 Optimizer가 담당하는 파라미터를 갱신한다.
첨부 코드는 모델의 아이디어를 이해하는 데 도움이 되지만, 그대로 실행하거나 학습하려면 수정이 필요하다.
nn.LSTM은 다음 형태로 값을 반환한다.
outputs, (hidden, cell) = self.lstm(inputs, states)
시퀀스 출력인 outputs와 최종 상태인 hidden, cell을 구분해야 한다. 첨부된 첫 번째 Decoder는 이 반환값 처리와 초기 상태 정의가 잘못되어 있다.
argmax는 가장 높은 점수의 단어 번호를 선택한다.
predicted = logits.argmax(dim=-1)
이는 문장 생성에 사용할 수 있지만, 일반적인 CrossEntropy 학습에는 선택한 단어 번호가 아니라 단어별 logits를 전달해야 한다.
첨부된 첫 번째 Decoder는 예측 단어 번호만 반환하므로 학습용 출력으로 바꿔야 한다.
attention_size와 context의 크기는 다르다attention_size는 관련성 점수를 계산하는 내부 차원이다.
첨부된 Attention에서는 원래 이미지 특징을 가중합하므로 context의 크기는 embed_size다.
따라서 해당 구조의 LSTM 입력 크기는 다음과 같다.
단어 임베딩: embed_size
context: embed_size
결합 결과: 2 × embed_size
두 값을 모두 256으로 설정하면 차이가 드러나지 않지만, attention_size를 바꾸면 크기 오류가 발생할 수 있다.
Embedding Layer에는 실수형 logits가 아니라 정수형 토큰 번호를 입력한다.
predicted = logits.argmax(dim=-1)
next_embedding = self.embed(predicted)
첨부된 추론 코드처럼 logits를 바로 self.embed()에 전달하면 안 된다.
또한 (B, E, H, W) 형태의 이미지 특징에서 영역별 평균을 얻으려면 공간 차원인 H, W를 평균 내야 한다.
mean_features = features.mean(dim=(2, 3))
첨부된 Attention Decoder는 현재 단어를 입력받은 뒤 그 위치의 출력을 만든다. 이 출력을 같은 위치의 단어와 비교하면 다음 단어 대신 현재 입력 단어를 맞히도록 학습할 수 있다.
앞에서 살펴본 것처럼 입력과 정답을 한 칸 이동해 정렬해야 한다.
| 구분 | Show and Tell | Show, Attend and Tell |
|---|---|---|
| 이미지 표현 | 하나의 요약 벡터 | 여러 영역의 특징 벡터 |
| 이미지 정보 사용 | 시작 단계에 입력 | 초기 상태와 각 생성 단계에 활용 |
| Attention | 사용하지 않음 | 단어마다 영역별 가중치 계산 |
| Decoder | LSTM | LSTM |
| 특징 | 구조가 비교적 단순함 | 생성 중 참고하는 영역을 시각화할 수 있음 |
처음에는 두 모델 모두 CNN과 LSTM을 사용해서 비슷하게 느껴질 수 있다. 하지만 이미지 정보를 사용하는 방식은 다르다.
Show and Tell은 이미지를 먼저 처리한 뒤, 그 정보가 반영된 기억을 이용해 문장을 만든다. Show, Attend and Tell은 문장을 만드는 동안 이미지의 여러 영역을 계속 참고한다.
Attention에서 기억할 부분은 현재 문맥을 기준으로 입력 정보의 비중을 다시 계산한다는 점이다. 번역에서는 입력 문장의 단어들이 참고 대상이고, 이미지 캡셔닝에서는 이미지의 영역별 특징이 참고 대상이 된다.