- 들어가며 — 캡셔닝은 VQA와 무엇이 다른가
- 데이터 — 이미지에 문장을 붙이는 방법
- 구조 — 인코더와 디코더를 나눈다
- 3편의 버그가 여기엔 없었던 이유
- 학습 로그
- 결과
- 정리
- 🧠 이해도 체크 퀴즈
- 참고 자료
들어가며 — 캡셔닝은 VQA와 무엇이 다른가
3편의 VQA는 이미지와 질문을 받아 짧은 답을 냈습니다. 이번 편은 질문 없이 이미지만 보고 문장을 만드는 캡셔닝입니다.
[운동화 이미지] → "a photo of a sneaker"
[바지 이미지] → "trousers on white background"
차이는 출력 길이에 있습니다. VQA의 답은 6글자였지만 캡션은 40글자입니다. 길어지면 구조가 달라져야 합니다. 3편은 이미지·질문·답을 한 시퀀스에 이어 붙였지만, 이번에는 인코더와 디코더를 분리합니다.
데이터 — 이미지에 문장을 붙이는 방법
Fashion-MNIST에는 캡션이 없습니다. 라벨(0~9)만 있습니다. 그래서 라벨을 문장으로 바꾸는 템플릿을 만들었습니다.
ARTICLES = ["t-shirt", "trousers", "a pullover", "a dress", "a coat",
"a sandal", "a shirt", "a sneaker", "a bag", "an ankle boot"]
TEMPLATES = ["a photo of {}", "this looks like {}", "{} on white background"]
한 이미지에 세 가지 템플릿 중 하나를 무작위로 붙입니다. 이렇게 하면 모델이 문장 전체를 통째로 외우는 대신 구조를 배워야 합니다. "a photo of"로 시작하면 물건 이름이 오고, 물건 이름으로 시작하면 "on white background"가 온다는 식입니다.
관사도 일부러 불규칙하게 두었습니다. t-shirt와 trousers에는 관사가 없고, an ankle boot만 an을 씁니다. 모델이 이걸 맞히는지도 확인할 수 있습니다.
구조 — 인코더와 디코더를 나눈다
class Captioner(nn.Module):
def __init__(self):
super().__init__()
self.cnn = nn.Sequential(
nn.Conv2d(1, 32, 3, 2, 1), nn.GELU(),
nn.Conv2d(32, 64, 3, 2, 1), nn.GELU(),
nn.Conv2d(64, 128, 3, 2, 1), nn.GELU(),
)
self.proj = nn.Linear(128, 160)
self.emb = nn.Embedding(V, 160)
self.pos = nn.Embedding(CAP, 160)
layer = nn.TransformerDecoderLayer(160, 5, 640, batch_first=True, norm_first=True)
self.dec = nn.TransformerDecoder(layer, 3)
self.head = nn.Linear(160, V)
self.register_buffer("m", torch.triu(torch.full((CAP, CAP), float("-inf")), 1))
def forward(self, img, cap_in):
mem = self.proj(self.cnn(img).flatten(2).transpose(1, 2))
T = cap_in.shape[1]
h = self.emb(cap_in) + self.pos(torch.arange(T, device=cap_in.device))
return self.head(self.dec(h, mem, tgt_mask=self.m[:T, :T]))
핵심은 self.dec(h, mem, ...) 한 줄입니다. nn.TransformerDecoder는 인자를 두 개 받습니다.
h— 지금까지 생성한 캡션 토큰들mem— CNN이 만든 16개의 이미지 벡터 (메모리)
디코더 층 안에서는 두 종류의 어텐션이 순서대로 일어납니다. 먼저 self-attention으로 캡션이 자기 자신의 과거를 봅니다(그래서 tgt_mask가 필요합니다). 그다음 cross-attention으로 캡션의 각 위치가 16개 이미지 벡터를 봅니다.
이 두 번째가 중요합니다. "sneaker"라는 단어를 생성할 때 모델은 이미지의 어느 영역을 봐야 할지 스스로 고릅니다. 3편처럼 모든 걸 한 시퀀스에 넣으면 이미지와 텍스트가 같은 어텐션을 나눠 쓰지만, 여기서는 역할이 분리되어 각자 최적화됩니다. 캡션이 길어질수록 이 분리가 유리합니다.
3편의 버그가 여기엔 없었던 이유
3편에서 정확도를 7.5%로 떨어뜨린 범인은 정답 시퀀스를 만드는 함수였습니다. 두 코드를 나란히 놓아 보겠습니다.
3편 (버그 있음)
def tok(s, L):
ids = [enc[c] for c in s][:L]
return ids + [0] * (L - len(ids))
a_full = torch.tensor([tok(s, AL - 1) + [1] for s in ans]) # 패딩 후 EOS
4편 (정상)
def tok(s):
ids = [enc[c] for c in s][:CAP - 1] + [1] # EOS 를 먼저 붙이고
return ids + [0] * (CAP - len(ids)) # 그 다음 패딩
"even"을 넣었을 때 결과를 비교하면 이렇습니다.
| 결과 | "n" 다음 자리의 정답 | |
|---|---|---|
| 3편 | [e, v, e, n, PAD, EOS] | PAD → 손실에서 제외 |
| 4편 | [e, v, e, n, EOS, PAD] | EOS → 손실에 포함 |
순서 하나 차이입니다. 4편은 EOS를 먼저 붙이고 나중에 패딩했기 때문에, 답이 끝나는 자리에 항상 EOS가 놓입니다. 모델이 "여기서 멈춘다"를 배울 수 있었고, 그래서 같은 버그를 겪지 않았습니다.
같은 사람이 같은 날 쓴 코드인데 한쪽만 틀렸습니다. 이런 종류의 실수는 리뷰로 잡기 어렵고, 지표로도 안 잡힙니다. 3편에서 봤듯 손실은 오히려 더 좋아 보였으니까요. 출력을 직접 보는 것 외에 방법이 없습니다.
학습 로그
[ 12.6s] params {"params": 1367262, "cap_len": 40}
[ 13.3s] step 0 loss=3.7440
[ 14.2s] step 100 loss=0.1660
[ 608.4s] SUMMARY {"steps": 79504, "final_loss": 0.0468, "caption_acc": 0.91}
100스텝 만에 손실이 3.74에서 0.166으로 떨어졌습니다. 1초 남짓입니다. 캡션이 30가지 조합(10품목 × 3템플릿)뿐이라 구조를 금방 파악한 것입니다.
결과
테스트셋 300장에 대해 생성된 캡션에 정답 품목명이 들어 있는지로 채점했습니다. 결과는 91%입니다.
[an ankle boot] → an ankle boot on white background
[a pullover] → a photo of a pullover
[trousers] → trousers on white background
[trousers] → this looks like trousers
[a shirt] → a photo of a shirt
[a coat] → a photo of a coat
눈여겨볼 점이 몇 가지 있습니다.
템플릿이 섞여 나옵니다. 같은 trousers에 대해 한 번은 "trousers on white background", 다른 한 번은 "this looks like trousers"가 나왔습니다. 하나를 외운 게 아니라 세 구조를 모두 익혔다는 뜻입니다.
관사를 맞힙니다. an ankle boot의 an, a pullover의 a, 그리고 trousers에는 관사 없음까지 정확합니다. 문자 단위로 생성하는데도 이걸 맞히는 건, 품목명의 첫 글자와 관사 사이의 관계를 학습했기 때문입니다.
틀린 9%는 대부분 셔츠 계열입니다. Fashion-MNIST에서 shirt, t-shirt, coat, pullover는 28×28 흑백에서 구분이 어렵습니다. 사람이 봐도 헷갈리는 항목이라, 이 오류는 모델의 결함이라기보다 데이터의 한계에 가깝습니다.
정리
| 항목 | 값 |
|---|---|
| 파라미터 | 1,367,262 (1.37M) |
| 학습 시간 | 608.4초 |
| 스텝 | 79,504 |
| 최종 손실 | 0.0468 |
| 캡션 라벨 적중률 | 0.91 |
인코더-디코더 분리는 출력이 길어질 때 값어치를 합니다. cross-attention 덕분에 생성 중인 각 단어가 이미지의 필요한 부분만 골라 볼 수 있고, 이미지 표현과 언어 표현이 서로 간섭하지 않습니다.
그리고 3편과의 대조가 이 편의 또 다른 수확입니다. 같은 실수를 할 뻔했지만 우연히 EOS를 먼저 붙였기에 피했습니다. 우연에 기대지 않으려면 정답 시퀀스를 만드는 함수를 한 번 출력해 보는 습관이 필요합니다. 배치 하나를 꺼내 print하는 데 10초면 됩니다.
다음 편에서는 출력이 텍스트가 아니라 이미지 자체인 경우 — 흑백 사진에 색을 입히는 문제를 다룹니다. 거기서는 손실 함수 선택이 결과의 성격을 바꿔 버리는 걸 보게 됩니다.
🧠 이해도 체크 퀴즈
1. nn.TransformerDecoder 안에서 일어나는 두 가지 어텐션은 각각 무엇을 하나요?
첫 번째는 self-attention으로, 생성 중인 캡션이 자기 자신의 이전 토큰들을 참조합니다. 인과 마스크가 필요한 것이 이 부분입니다. 두 번째는 cross-attention으로, 캡션의 각 위치가 CNN이 만든 16개의 이미지 벡터를 참조합니다. 어떤 단어를 쓸 때 이미지의 어느 영역을 볼지 모델이 스스로 고르게 됩니다.
2. 캡션 템플릿을 3가지로 섞은 이유는 무엇인가요?
하나만 쓰면 모델이 문장 전체를 통째로 외울 수 있습니다. 여러 템플릿을 섞으면 "a photo of" 다음에는 품목명이 오고 품목명 다음에는 "on white background"가 온다는 식의 구조를 배워야 합니다. 실제로 같은 trousers 이미지에 대해 서로 다른 템플릿이 생성되는 것으로 확인됐습니다.
3. 3편의 EOS 버그가 4편 코드에는 왜 없었나요?
3편은 tok(s, AL-1) + [1] 로 패딩을 먼저 채운 뒤 EOS를 붙여서 답과 EOS 사이에 패딩이 끼었습니다. 4편은 [enc[c] for c in s][:CAP-1] + [1] 로 EOS를 먼저 붙이고 그다음 패딩했기 때문에, 답이 끝나는 자리에 항상 EOS가 놓입니다. 순서 하나 차이입니다.
4. 틀린 9%가 주로 셔츠 계열에 몰린 것을 모델의 결함으로 봐야 할까요?
전적으로 그렇게 보기는 어렵습니다. Fashion-MNIST의 shirt, t-shirt, coat, pullover는 28×28 흑백 해상도에서 사람이 봐도 구분이 어렵습니다. 데이터가 담고 있는 정보량의 한계에 가깝고, 모델을 키운다고 해결되지 않는 종류의 오류입니다.
참고 자료
현재 단락 (1/86)
3편의 VQA는 이미지와 질문을 받아 짧은 답을 냈습니다. 이번 편은 질문 없이 **이미지만 보고 문장을 만드는** 캡셔닝입니다.