Split View: 모두를 위한 AI 3편 — 손실 0.0017인데 정확도 7.5%, 범인은 패딩 한 칸이었다
모두를 위한 AI 3편 — 손실 0.0017인데 정확도 7.5%, 범인은 패딩 한 칸이었다
- 들어가며 — 지표가 완벽할 때가 가장 위험하다
- 실험 설계
- 모델 구조 — 두 종류의 입력을 한 시퀀스로
- 1차 결과 — 손실 0.0017, 정확도 7.5%
- 범인 찾기 — 출력을 직접 본다
- 수정 — 한 줄
- 수정 후 결과
- 무엇을 배웠나
- 🧠 이해도 체크 퀴즈
- 참고 자료
들어가며 — 지표가 완벽할 때가 가장 위험하다
이번 편의 목표는 VQA(Visual Question Answering)입니다. 이미지와 질문을 함께 넣으면 답을 내놓는 모델입니다.
[손글씨 숫자 이미지] + "is it even or odd?" → "odd"
[손글씨 숫자 이미지] + "how many holes?" → "zero"
결과부터 말씀드리면, 처음 학습시킨 모델은 손실 0.0017, 정확도 7.5% 였습니다. 손실만 보면 거의 완벽하게 수렴했는데 정확도는 무작위 찍기보다도 낮았습니다.
이 글의 절반은 모델을 만드는 이야기이고, 나머지 절반은 그 7.5%의 정체를 찾는 이야기입니다. 결론을 미리 말하면 모델은 아무 잘못이 없었습니다.
실험 설계
MNIST 이미지 하나에 네 종류의 질문을 무작위로 붙였습니다.
| 질문 | 답의 형태 | 예시 |
|---|---|---|
| what digit is this? | 숫자 이름 | seven |
| is it even or odd? | even / odd | even |
| is it bigger than four? | yes / no | no |
| how many holes? | 구멍 수의 이름 | one |
마지막 질문이 재미있습니다. 숫자 8은 구멍이 2개, 0과 4와 6과 9는 1개, 나머지는 0개입니다. 같은 이미지를 놓고 무엇을 묻느냐에 따라 다른 답을 내야 하므로, 모델이 질문을 실제로 읽고 있는지 검증할 수 있습니다.
모델 구조 — 두 종류의 입력을 한 시퀀스로
이미지는 CNN으로 압축해 16개의 토큰으로 만들고, 질문은 문자 임베딩으로 바꿔 뒤에 이어 붙입니다. 그러면 전체가 하나의 시퀀스가 되어 트랜스포머가 처리할 수 있습니다.
class VQA(nn.Module):
def __init__(self):
super().__init__()
self.cnn = nn.Sequential(
nn.Conv2d(1, 32, 3, 2, 1), nn.GELU(), # 28 -> 14
nn.Conv2d(32, 64, 3, 2, 1), nn.GELU(), # 14 -> 7
nn.Conv2d(64, 128, 3, 2, 1), nn.GELU(), # 7 -> 4
)
self.img_proj = nn.Linear(128, 192)
self.emb = nn.Embedding(V, 192)
self.pos = nn.Embedding(64, 192)
layer = nn.TransformerEncoderLayer(192, 6, 768, batch_first=True, norm_first=True)
self.tr = nn.TransformerEncoder(layer, 3)
self.head = nn.Linear(192, V)
CNN이 28×28 이미지를 4×4×128로 줄이면, 이를 펼쳐 16개의 벡터로 봅니다. 각 벡터가 이미지의 한 영역을 담당하는 시각 토큰입니다. 이 방식은 오늘날의 대형 멀티모달 모델도 근본적으로 같습니다.
마스크를 구간별로 다르게 준다
여기가 이 모델의 핵심입니다. 시퀀스는 세 구간으로 나뉩니다.
[이미지 토큰 16개][질문 토큰 24개][답변 토큰 6개]
앞의 두 구간은 서로 자유롭게 봐도 됩니다. 질문을 이해하려면 이미지 전체를 봐야 하고, 이미지를 해석하려면 질문을 알아야 하니까요. 하지만 답변 구간은 자기보다 앞만 봐야 합니다. 그래야 한 글자씩 생성할 수 있습니다.
T = h.shape[1]
A0 = 16 + q.shape[1] # 답변이 시작되는 위치
m = torch.zeros(T, T, device=h.device)
m[:, A0:] = float("-inf") # 일단 답변 구간은 아무도 못 보게
idx = torch.arange(T, device=h.device)
m[idx.unsqueeze(0) <= idx.unsqueeze(1)] = 0 # 자기 자신과 과거는 허용
m[:A0, A0:] = float("-inf") # 앞 구간이 답변을 미리 보는 것만 다시 차단
이미지·질문 구간은 양방향, 답변 구간은 단방향입니다. 하나의 어텐션 안에서 두 가지 규칙이 공존합니다.
1차 결과 — 손실 0.0017, 정확도 7.5%
10분 학습 후 기록입니다.
[ 603.7s] SUMMARY {"name": "03-image-text-to-text", "steps": 82120,
"final_loss": 0.0017, "vqa_acc": 0.075, "params": 1475870}
82,120 스텝, 손실 0.0017. 이 정도면 학습이 완벽하게 됐다는 뜻입니다. 그런데 정확도가 7.5% 입니다.
답의 종류를 세어 보면 무작위로 찍어도 이보다는 나옵니다. even/odd는 둘 중 하나, yes/no도 둘 중 하나이니 기대값이 훨씬 높아야 합니다. 손실과 정확도가 이렇게 어긋나면 둘 중 하나는 거짓말을 하고 있습니다.
범인 찾기 — 출력을 직접 본다
지표가 서로 모순될 때 가장 빠른 길은 모델이 실제로 뭘 뱉는지 보는 것입니다.
Q: how many holes? (정답 zero) → A: zeroe
Q: is it even or odd? (정답 even) → A: evene
Q: what digit is this? (정답 one) → A: oneeo
Q: is it bigger than four? (정답 no) → A: nonrn
Q: is it even or odd? (정답 odd) → A: oddee
보이시나요. 모델은 매번 정답을 맞혔습니다. zero, even, one, no, odd 가 전부 들어 있습니다. 다만 답을 다 쓴 뒤에 멈추지 못하고 쓰레기 문자를 덧붙였고, 정확 일치로 채점하니 전부 오답 처리된 것입니다.
즉 7.5%는 모델의 실력이 아니라 채점 결과였습니다. 모델은 "무엇을 답할지"는 완벽히 배웠고 "언제 멈출지"만 못 배웠습니다.
왜 멈추지 못했나
정답 시퀀스를 만드는 코드가 이랬습니다.
def tok(s, L):
ids = [enc[c] for c in s][:L]
return ids + [0] * (L - len(ids)) # 0 = PAD
a_full = torch.tensor([tok(s, AL - 1) + [1] for s in ans]) # 1 = EOS
AL이 6일 때 "even"을 넣으면 어떻게 될까요.
tok("even", 5) → [e, v, e, n, PAD]
+ [EOS] → [e, v, e, n, PAD, EOS]
답과 EOS 사이에 패딩이 끼어들었습니다. 그리고 손실 계산에서 패딩은 제외됩니다.
loss = F.cross_entropy(..., ignore_index=0) # PAD 는 무시
그 결과 위치 4(답이 끝난 바로 다음 자리)의 정답은 PAD이고, PAD는 손실에서 빠지므로 그 자리에는 학습 신호가 단 한 번도 주어지지 않았습니다. 추론할 때 모델은 그 자리에서 무엇을 해야 할지 배운 적이 없으니 아무 문자나 뱉습니다.
손실이 0.0017까지 떨어진 것도 이제 설명됩니다. 전체 6칸 중 실제로 채점되는 건 4~5칸뿐이고, 그마저도 답이 짧고 종류가 적어 외우기 쉬웠습니다. 손실은 "채점 대상인 자리"에서만 계산되므로, 채점하지 않는 자리의 문제는 손실에 나타나지 않습니다.
수정 — 한 줄
EOS를 답 바로 뒤에 붙이고 그다음을 패딩으로 채웁니다.
def tok_ans(s, L):
"""답변용: EOS 를 답 '바로 뒤'에 붙이고 그 다음을 패딩한다."""
ids = [enc[c] for c in s][:L - 1] + [1]
return ids + [0] * (L - len(ids))
tok_ans("even", 6) → [e, v, e, n, EOS, PAD]
이제 "n" 다음 자리의 정답이 EOS이고, EOS는 패딩이 아니므로 손실에 포함됩니다. 모델이 "여기서 끝난다"를 배울 기회가 생겼습니다.
수정 후 결과
[ 603.1s] SUMMARY {"name": "03-image-text-to-text", "steps": 82030,
"final_loss": 0.00075, "vqa_acc": 0.995, "params": 1475870}
| 항목 | 수정 전 | 수정 후 |
|---|---|---|
| 최종 손실 | 0.0017 | 0.00075 |
| VQA 정확도 | 0.075 | 0.995 |
| 파라미터 | 1,475,870 | 1,475,870 |
| 학습 시간 | 603.7초 | 603.1초 |
모델도, 하이퍼파라미터도, 학습 시간도 그대로입니다. 정답을 만드는 방식 한 줄만 바꿨고 7.5%가 99.5%가 됐습니다.
수정 후 출력입니다.
Q: is it even or odd? (정답 odd) → A: odd
Q: how many holes? (정답 zero) → A: zero
Q: is it bigger than four? (정답 no) → A: no
Q: what digit is this? (정답 nine) → A: nine
무엇을 배웠나
손실은 자기가 보는 것만 봅니다. ignore_index 로 제외한 자리는 손실에 영향을 주지 않으니, 그 자리가 망가져도 손실은 조용합니다. 마스킹이나 무시 인덱스를 쓸 때는 "무엇이 제외되고 있는가"를 항상 확인해야 합니다.
지표가 서로 어긋나면 출력을 보십시오. 손실 0.0017과 정확도 7.5%는 동시에 참일 수 없습니다. 이럴 때 하이퍼파라미터를 만지는 건 시간 낭비이고, 실제 출력 몇 줄이 즉시 답을 줍니다. zeroe 를 보는 순간 문제가 어디인지 분명해집니다.
정확 일치 채점은 가혹하고, 그래서 유용합니다. 만약 "정답이 출력에 포함되면 정답"으로 느슨하게 쟀다면 이 모델은 처음부터 100%가 나왔을 것이고, 저는 버그를 영원히 못 찾았을 것입니다. 엄격한 채점이 버그를 드러냈습니다.
시리즈 다음 편에서는 이미지에 문장을 붙이는 캡셔닝을 다룹니다. 그 편의 코드에는 같은 버그가 없었는데, 왜 없었는지도 함께 보겠습니다.
🧠 이해도 체크 퀴즈
1. 손실이 0.0017인데 정확도가 7.5%일 때 가장 먼저 할 일은 무엇일까요?
모델의 실제 출력을 눈으로 보는 것입니다. 두 지표가 동시에 참일 수 없으므로 둘 중 하나가 잘못 측정되고 있습니다. 하이퍼파라미터를 바꾸거나 더 학습시키는 것은 원인을 찾기 전에는 의미가 없습니다. 이 사례에서는 출력 다섯 줄만 보고 즉시 원인이 드러났습니다.
2. [e, v, e, n, PAD, EOS] 와 [e, v, e, n, EOS, PAD] 의 결정적 차이는 무엇인가요?
전자는 "n" 다음 자리의 정답이 PAD인데, PAD는 ignore_index 로 손실에서 제외되므로 그 자리에 학습 신호가 없습니다. 후자는 그 자리의 정답이 EOS이고 EOS는 손실에 포함되므로, 모델이 "여기서 답이 끝난다"를 배웁니다.
3. 채점을 "정답이 출력에 포함되면 정답"으로 느슨하게 했다면 어떻게 됐을까요?
처음부터 거의 100%가 나왔을 것입니다. 모델의 출력 zeroe 안에는 zero 가 들어 있으니까요. 그러면 버그를 발견하지 못한 채 "잘 동작한다"고 결론 내렸을 것이고, 이 모델을 실제로 쓰면 답 뒤에 쓰레기가 붙어 나왔을 것입니다. 엄격한 채점이 버그를 드러냈습니다.
4. 이미지 구간과 답변 구간에 서로 다른 어텐션 마스크를 주는 이유는 무엇인가요?
이미지와 질문은 서로를 모두 참조해야 이해가 되므로 양방향으로 열어 둡니다. 반면 답변은 한 글자씩 생성해야 하므로 자기보다 뒤를 보면 정답이 새어 나갑니다. 그래서 답변 구간에만 인과 마스크를 적용합니다.
참고 자료
AI for Everyone, Part 3 — Loss of 0.0017, Accuracy of 7.5%: The Culprit Was One Padding Slot
- When perfect metrics are the most dangerous
- Experiment design
- Architecture — two input types in one sequence
- First result — loss 0.0017, accuracy 7.5%
- Finding the culprit — look at the output
- The fix — one line
- Result after the fix
- What we learned
- 🧠 Comprehension quiz
- References
When perfect metrics are the most dangerous
This part targets VQA (Visual Question Answering): feed the model an image together with a question, and it produces an answer.
[handwritten digit image] + "is it even or odd?" -> "odd"
[handwritten digit image] + "how many holes?" -> "zero"
The headline result first: the initial model reached a loss of 0.0017 with 7.5% accuracy. By loss alone it had converged almost perfectly, yet its accuracy was below random guessing.
Half of this article is about building the model. The other half is about hunting down what that 7.5% actually was. To spoil the ending: the model had done nothing wrong.
Experiment design
Each MNIST image was paired with one of four randomly chosen questions.
| Question | Answer form | Example |
|---|---|---|
| what digit is this? | digit name | seven |
| is it even or odd? | even / odd | even |
| is it bigger than four? | yes / no | no |
| how many holes? | name of hole count | one |
The last one is interesting. The digit 8 has two holes; 0, 4, 6, and 9 have one; the rest have none. Because the same image must produce different answers depending on what is asked, we can verify that the model is genuinely reading the question.
Architecture — two input types in one sequence
The image is compressed by a CNN into 16 tokens, the question is embedded character by character, and the two are concatenated. The whole thing becomes a single sequence a transformer can process.
class VQA(nn.Module):
def __init__(self):
super().__init__()
self.cnn = nn.Sequential(
nn.Conv2d(1, 32, 3, 2, 1), nn.GELU(), # 28 -> 14
nn.Conv2d(32, 64, 3, 2, 1), nn.GELU(), # 14 -> 7
nn.Conv2d(64, 128, 3, 2, 1), nn.GELU(), # 7 -> 4
)
self.img_proj = nn.Linear(128, 192)
self.emb = nn.Embedding(V, 192)
self.pos = nn.Embedding(64, 192)
layer = nn.TransformerEncoderLayer(192, 6, 768, batch_first=True, norm_first=True)
self.tr = nn.TransformerEncoder(layer, 3)
self.head = nn.Linear(192, V)
The CNN reduces a 28×28 image to 4×4×128, which flattens into 16 vectors. Each vector covers one region of the image — these are visual tokens. Today's large multimodal models work the same way at heart.
Different mask rules for different spans
This is the heart of the model. The sequence has three spans.
[16 image tokens][24 question tokens][6 answer tokens]
The first two may look at each other freely: understanding the question requires seeing the whole image, and interpreting the image requires knowing the question. The answer span, however, may only look backwards, so that it can be generated one character at a time.
T = h.shape[1]
A0 = 16 + q.shape[1] # where the answer starts
m = torch.zeros(T, T, device=h.device)
m[:, A0:] = float("-inf") # first, hide the answer span from everyone
idx = torch.arange(T, device=h.device)
m[idx.unsqueeze(0) <= idx.unsqueeze(1)] = 0 # allow self and past
m[:A0, A0:] = float("-inf") # re-block only the prefix from peeking ahead
Image and question spans are bidirectional; the answer span is causal. Two rules coexist inside one attention operation.
First result — loss 0.0017, accuracy 7.5%
The record after ten minutes of training.
[ 603.7s] SUMMARY {"name": "03-image-text-to-text", "steps": 82120,
"final_loss": 0.0017, "vqa_acc": 0.075, "params": 1475870}
82,120 steps, loss 0.0017. That normally means training went perfectly. Yet accuracy is 7.5%.
Count the answer space and random guessing should beat that: even/odd is one of two, yes/no is one of two. When loss and accuracy disagree this badly, one of them is lying.
Finding the culprit — look at the output
When metrics contradict each other, the fastest route is to look at what the model actually emits.
Q: how many holes? (expected zero) -> A: zeroe
Q: is it even or odd? (expected even) -> A: evene
Q: what digit is this? (expected one) -> A: oneeo
Q: is it bigger than four? (expected no) -> A: nonrn
Q: is it even or odd? (expected odd) -> A: oddee
There it is. The model got every answer right. zero, even, one, no, odd are all present. It simply could not stop after finishing, appended junk characters, and exact-match scoring marked every one of them wrong.
So 7.5% was not the model's ability but an artifact of scoring. The model had perfectly learned what to answer and had never learned when to stop.
Why it could not stop
The code that built the target sequence looked like this.
def tok(s, L):
ids = [enc[c] for c in s][:L]
return ids + [0] * (L - len(ids)) # 0 = PAD
a_full = torch.tensor([tok(s, AL - 1) + [1] for s in ans]) # 1 = EOS
With AL equal to 6, what happens to "even"?
tok("even", 5) -> [e, v, e, n, PAD]
+ [EOS] -> [e, v, e, n, PAD, EOS]
A padding slot slipped in between the answer and the EOS. And padding is excluded from the loss.
loss = F.cross_entropy(..., ignore_index=0) # PAD is ignored
As a result, the target at position 4 — the slot right after the answer ends — is PAD, and PAD is dropped from the loss, so that position never received a single training signal. At inference the model has never learned what belongs there, so it emits whatever.
This also explains the loss of 0.0017. Only four or five of the six slots are actually scored, and those are short answers drawn from a small set, so they are easy to memorise. Loss is computed only over scored positions, so problems at unscored positions never show up in it.
The fix — one line
Attach EOS immediately after the answer, then pad.
def tok_ans(s, L):
"""For answers: put EOS right after the answer, then pad."""
ids = [enc[c] for c in s][:L - 1] + [1]
return ids + [0] * (L - len(ids))
tok_ans("even", 6) -> [e, v, e, n, EOS, PAD]
Now the target after "n" is EOS, and EOS is not padding, so it counts toward the loss. The model finally has a chance to learn that the answer ends here.
Result after the fix
[ 603.1s] SUMMARY {"name": "03-image-text-to-text", "steps": 82030,
"final_loss": 0.00075, "vqa_acc": 0.995, "params": 1475870}
| Item | Before | After |
|---|---|---|
| Final loss | 0.0017 | 0.00075 |
| VQA accuracy | 0.075 | 0.995 |
| Parameters | 1,475,870 | 1,475,870 |
| Training time | 603.7s | 603.1s |
Same model, same hyperparameters, same training time. One line changed in how targets are built, and 7.5% became 99.5%.
The outputs after the fix.
Q: is it even or odd? (expected odd) -> A: odd
Q: how many holes? (expected zero) -> A: zero
Q: is it bigger than four? (expected no) -> A: no
Q: what digit is this? (expected nine) -> A: nine
What we learned
Loss only sees what it is shown. Positions excluded by ignore_index cannot influence it, so they can be broken while the loss stays quiet. Whenever you use masking or an ignore index, check what is being excluded.
When metrics contradict, look at the output. A loss of 0.0017 and an accuracy of 7.5% cannot both be true. Tuning hyperparameters at that point is wasted time; a few lines of real output answer the question instantly. The moment you see zeroe, the problem is obvious.
Exact-match scoring is harsh, and that is why it is useful. Had we scored loosely — "correct if the answer appears in the output" — this model would have shown 100% from the start and the bug would have survived forever. Strict scoring exposed it.
The next part covers captioning: attaching sentences to images. That experiment's code did not have this bug, and we will look at why not.
🧠 Comprehension quiz
1. What is the first thing to do when loss is 0.0017 but accuracy is 7.5%?
Look at the model's actual output. Both metrics cannot be true at once, so one of them is being measured wrongly. Changing hyperparameters or training longer is meaningless before the cause is found. In this case five lines of output revealed it immediately.
2. What is the decisive difference between [e, v, e, n, PAD, EOS] and [e, v, e, n, EOS, PAD]?
In the first, the target after "n" is PAD, and PAD is excluded from the loss by ignore_index, so that position gets no training signal. In the second, the target there is EOS, which does count toward the loss, so the model learns that the answer ends there.
3. What would have happened with loose "answer appears in output" scoring?
It would have shown nearly 100% from the start, because zeroe contains zero. The bug would have gone undetected and the conclusion would have been "it works" — while in real use the model appended junk after every answer. Strict scoring exposed the bug.
4. Why give different attention masks to the image span and the answer span?
The image and the question must each reference the other to be understood, so they stay bidirectional. The answer, by contrast, is generated one character at a time, and seeing ahead would leak the target. So the causal mask applies only to the answer span.