Skip to content
Published on

모두를 위한 AI 3편 — 손실 0.0017인데 정확도 7.5%, 범인은 패딩 한 칸이었다

공유하기
Authors

들어가며 — 지표가 완벽할 때가 가장 위험하다

이번 편의 목표는 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 / oddeven
is it bigger than four?yes / nono
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.00170.00075
VQA 정확도0.0750.995
파라미터1,475,8701,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. 이미지 구간과 답변 구간에 서로 다른 어텐션 마스크를 주는 이유는 무엇인가요?

이미지와 질문은 서로를 모두 참조해야 이해가 되므로 양방향으로 열어 둡니다. 반면 답변은 한 글자씩 생성해야 하므로 자기보다 뒤를 보면 정답이 새어 나갑니다. 그래서 답변 구간에만 인과 마스크를 적용합니다.

참고 자료