Skip to content

Split View: 모두를 위한 AI 4편 — 137만 파라미터로 이미지에 문장 붙이기, 그리고 3편의 버그가 여기엔 없던 이유

|

모두를 위한 AI 4편 — 137만 파라미터로 이미지에 문장 붙이기, 그리고 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-shirttrousers에는 관사가 없고, an ankle bootan을 씁니다. 모델이 이걸 맞히는지도 확인할 수 있습니다.

구조 — 인코더와 디코더를 나눈다

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 bootan, a pullovera, 그리고 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 흑백 해상도에서 사람이 봐도 구분이 어렵습니다. 데이터가 담고 있는 정보량의 한계에 가깝고, 모델을 키운다고 해결되지 않는 종류의 오류입니다.

참고 자료

AI for Everyone, Part 4 — Captioning Images With 1.37M Parameters, and Why Part 3 Bug Was Absent Here

How captioning differs from VQA

Part 3's VQA took an image and a question and produced a short answer. This part has no question: the model looks at an image alone and writes a sentence.

[sneaker image]   ->  "a photo of a sneaker"
[trousers image]  ->  "trousers on white background"

The difference is output length. A VQA answer was six characters; a caption is forty. Longer output calls for a different structure. Part 3 concatenated image, question, and answer into one sequence; here we separate the encoder from the decoder.

Data — giving images sentences

Fashion-MNIST has no captions, only labels 0 through 9. So we built templates that turn a label into a sentence.

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"]

Each image gets one of three templates at random. This forces the model to learn the structure rather than memorise whole sentences: if it starts with "a photo of", an item name follows; if it starts with an item name, "on white background" follows.

The articles are deliberately irregular. t-shirt and trousers take none, and only an ankle boot takes an. We can check whether the model gets those right too.

Architecture — splitting encoder and decoder

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]))

The heart of it is the single line self.dec(h, mem, ...). nn.TransformerDecoder takes two inputs:

  • h — the caption tokens generated so far
  • mem — the 16 image vectors produced by the CNN (the memory)

Inside each decoder layer, two kinds of attention happen in order. First self-attention, where the caption looks at its own past — which is why tgt_mask is needed. Then cross-attention, where each caption position looks at the 16 image vectors.

That second one matters. When generating the word "sneaker", the model chooses for itself which region of the image to consult. Packing everything into one sequence, as in Part 3, makes image and text share the same attention; here the roles are separated and each is optimised on its own terms. The longer the caption, the more this separation pays off.

Why Part 3's bug was absent here

The culprit that dropped Part 3 to 7.5% was the function that builds target sequences. Let us place the two side by side.

Part 3 (buggy)

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])   # pad, then EOS

Part 4 (correct)

def tok(s):
    ids = [enc[c] for c in s][:CAP - 1] + [1]   # EOS first
    return ids + [0] * (CAP - len(ids))          # then pad

Feeding "even" through both:

ResultTarget after "n"
Part 3[e, v, e, n, PAD, EOS]PAD → excluded from loss
Part 4[e, v, e, n, EOS, PAD]EOS → counted in loss

One difference in ordering. Because Part 4 attaches EOS first and pads afterwards, EOS always lands where the answer ends. The model could learn to stop, and so never hit the bug.

The same person wrote both on the same day and only one was wrong. This class of mistake is hard to catch in review and invisible to metrics — as Part 3 showed, the loss actually looked better. There is no substitute for looking at the output.

Training log

[   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}

Within 100 steps — barely a second — loss fell from 3.74 to 0.166. There are only 30 caption combinations (10 items × 3 templates), so the structure was grasped quickly.

Results

We scored 300 test images by whether the correct item name appears in the generated caption. The result is 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

Several things are worth noting.

Templates vary. The same trousers produced "trousers on white background" once and "this looks like trousers" another time. It learned all three structures rather than memorising one.

Articles are correct. The an in an ankle boot, the a in a pullover, and the absence of an article before trousers are all right. Getting this from character-level generation means it learned the relationship between an item's first letter and its article.

The wrong 9% is mostly shirt-like items. In Fashion-MNIST, shirt, t-shirt, coat, and pullover are hard to tell apart at 28×28 in greyscale. People confuse them too, so this error is closer to a limit of the data than a defect in the model.

Summary

ItemValue
Parameters1,367,262 (1.37M)
Training time608.4s
Steps79,504
Final loss0.0468
Caption label hit rate0.91

Encoder-decoder separation earns its keep as output grows longer. Cross-attention lets each word being generated consult only the part of the image it needs, and image representations stop competing with language representations.

The contrast with Part 3 is this article's other lesson. The same mistake was within reach and was avoided only because EOS happened to be attached first. To stop relying on luck, print the output of your target-building function once. Pulling one batch and calling print takes ten seconds.

The next part turns to output that is an image rather than text — colourising greyscale photographs. There we will see the choice of loss function change the character of the result.

🧠 Comprehension quiz

1. What do the two attention operations inside nn.TransformerDecoder each do?

The first is self-attention, where the caption being generated refers to its own earlier tokens — this is where the causal mask is needed. The second is cross-attention, where each caption position refers to the 16 image vectors from the CNN, letting the model choose which region to consult for each word.

2. Why mix three caption templates?

With only one, the model could memorise whole sentences. Mixing templates forces it to learn structure: an item name follows "a photo of", and "on white background" follows an item name. This was confirmed by the same trousers image producing different templates.

3. Why was Part 3's EOS bug absent from Part 4's code?

Part 3 used tok(s, AL-1) + [1], padding first and appending EOS after, so padding slipped between the answer and the EOS. Part 4 used [enc[c] for c in s][:CAP-1] + [1], attaching EOS first and padding after, so EOS always lands where the answer ends. A single difference in ordering.

4. Should the 9% error concentrated on shirt-like items be called a model defect?

Not entirely. In Fashion-MNIST, shirt, t-shirt, coat, and pullover are hard for people to distinguish at 28×28 greyscale. It is closer to a limit on the information the data carries, and is not the kind of error that scaling the model resolves.

References