손실(loss)이 떨어질수록 망가지는 현상 - Week 3, 3회: 마스크 제거와 과적합
요약
본 글은 언어 모델의 과적합(overfitting) 현상을 분석하며, 특히 학습 데이터 제한과 마스크 제거가 모델 성능에 미치는 영향을 다룹니다. 데이터를 의도적으로 적게 사용하여 'val' 손실이 상승하는 전형적인 과적합 사례를 보여주며, early stopping의 중요성을 강조합니다.
핵심 포인트
- 학습 데이터 제한 시 val 손실은 상승하고 train 손실은 하락하는 전형적인 과적합 패턴을 보인다.
- 과적합 진단 시 '사용된 학습 데이터'와 '미사용 검증 데이터'를 정확히 분리하여 측정해야 한다.
- val 손실이 더 이상 개선되지 않거나 악화되기 시작하면 모델 훈련을 중단(early stopping)하는 것이 필수적이다.
서론
지난번에는 세 가지 파괴 방식 모두 같은 지점(유니그램 수준의 3.3)에서 정체되어 있어 곡선으로는 구별할 수 없다는 것을 보았습니다. 이를 분리하기 위해서는 데이터 육안 검사와 계층별 기울기 노름(gradient norm)이라는 두 가지 도구가 있었습니다.
이번에는 남은 두 가지를 다룹니다. 이전과는 증상이 반대입니다.
| 파괴 방식 | 발생하는 현상 |
|---|---|
| #6 데이터를 2,000자로 제한 | train은 하락하지만 val이 상승함 |
| #3 causal mask 제거 | train도 val도 계속 하락함 |
#6는 교과서적인 과적합(overfitting)으로, val만 보면 알아차릴 수 있습니다. 문제는 #3입니다. 수치상 아무런 이상 징후가 나타나지 않습니다. 게다가 325 iter 동안 기준선과 완전히 같은 곡선을 그립니다.
먼저 #6를 살펴보고 'val이 유용한 경우'를 확인한 다음, #3로 넘어가겠습니다.
#6 학습 데이터를 2,000자로 제한하기
Karpathy의 레시피에 있는 '작은 데이터로 과적합시켜 보기'의 역방식입니다. 의도적으로 과적합을 일으킵니다.
train의 처음 2,000자만 사용합니다. 전체가 약 100만 자이므로 0.2%에 불과합니다.
common.run(m, tag="overfit_2000", train_data=common.train[:2000])
파라미터는 215,873개입니다. 2,000자에 비해 용량이 100배 이상 크죠. block_size가 128이므로 얻을 수 있는 시작 위치는 1,872가지입니다. 600 iter × batch 32 = 19,200 샘플을 가져가므로, 평균적으로 10회 이상 같은 부분을 보게 되는 계산이 됩니다.
측정 방식에 함정이 있었다
처음에는 잘못된 그래프가 나왔습니다. train도 val도 모두 상승하는, 과적합과는 거리가 먼 형태였습니다.
원인은 estimate_loss에 2,000자를 전달하지 않았기 때문이었습니다.
common.run(m, train_data=common.train[:2000]) # 학습은 2,000자
↓
l = estimate_loss(m) # 측정은 train 전체(100만 자)
이렇게 하면 train의 선이 '학습에 사용하지 않은 99.8%'를 포함하여 측정됩니다. 실질적으로 val과 같은 것을 두 번 그리고 있는 셈입니다. 과적합은 '학습에 사용한 데이터'와 '사용하지 않은 데이터'의 차이이므로, 전자를 올바르게 측정하지 않으면 나타나지 않습니다.
결과

| iter | train | val |
|---|---|---|
| 0 | 4.3421 | 4.3257 |
| 75 | 2.4180 | 2.9865 ← val의 바닥 |
| 200 | 2.0452 | 3.1918 |
| 400 | 1.0100 | 4.1533 |
| 600 | 0.2456 | 5.7113 |
깔끔한 가위 모양이 되었습니다. train은 0.246까지 떨어지고, val은 5.71까지 올라갔습니다. 차이는 5.47입니다.
읽어낼 수 있는 것이 세 가지 있습니다.
val의 바닥은 iter 75입니다. 여기서 멈추는 것이 최선이며, val은 2.99입니다. 그 이후로는 학습할수록 악화됩니다. early stopping이 필요한 전형적인 예가 됩니다.
iter 400에서 val이 ln(V)를 초과했습니다. 5.71은 '아무것도 모르는 모델(4.174)보다 나쁜' 수준입니다. 100만 자라면 기준은 2.24까지 떨어졌을 곳인데, 학습할수록 제멋대로 이하로 내려갑니다.
분리는 처음부터 시작되고 있습니다. iter 25 시점 이미 train 2.96 / val 3.24의 차이가 있습니다. #3와 같은 잠복 기간은 없습니다.
생성하게 하면 증거가 나온다
Al:
Speak.
Fitorst Citirizen:
...
All:
Speak.
First Citizen:
You are all resolved...
이것은 tiny_shakespeare의 도입부 그 자체입니다. 학습에 사용한 2,000자의 단편이 거의 그대로 나온 것입니다.
기준(val 2.24)의 생성문과 비교해 봅시다.
Thes dreoun, hind youn ancorseshe be fabuty, in thourd sowe
therds ineancoup ance theer wine, sonoule tore, vet.
언뜻 보기에는 #6가 '영어답다'고 느껴집니다. 하지만 내용은 복사 붙여넣기입니다. 그럴듯함과 일반화는 별개라는 것을 알 수 있습니다.
#6는 알아차릴 수 있다
여기까지가 서론이 됩니다. #6은 까다롭지만, val을 보면 감지할 수 있습니다. iter 75에서 바닥을 치고 이후에는 올라갑니다. early stopping이라는 대처법도 확립되어 있습니다.
문제는 다음입니다.
#3 causal mask를 삭제하다
Transformer로 다음 문자를 예측할 때, 위치 t는 자신보다 뒤의 정보를 봐서는 안 됩니다. 답을 미리 보게 되기 때문입니다.
이것을 방지하는 것이 causal mask입니다. attention 스코어에 -inf를 넣어 softmax를 거친 확률을 0으로 만듭니다.
self.register_buffer("mask", torch.tril(torch.ones(block_size, block_size)).bool())
...
scores = scores.masked_fill(~self.mask[:T, :T], float("-inf"))
tril은 하삼각 행렬이므로 이런 형태가 됩니다.
보여지는 위치 →
0 1 2 3
위치0 True False False False
...
이것을 전부 True로 하면 mask가 기능하지 않게 됩니다.
for b in m.blocks:
b.attn.mask.fill_(True)
W2 코드는 수정하지 않고, 모델을 만든 후에 buffer의 내용물만 교체하는 형태로 만들었습니다.
이것이 치명적인 이유
입력 x와 정답 y의 관계를 기억해 주었으면 합니다.
x: '\nANGELO:\nAnd she wil'
y: 'ANGELO:\nAnd she will'
y[t]는 x[t+1]과 같은 문자입니다. mask를 제거하면 위치 t가 x[t+1]을 참조할 수 있게 되므로, 답이 입력 안에 있다는 의미가 됩니다.
솔직히 생각하면 loss는 즉시 0에 가까워져야 합니다.
그런데 아무 일도 일어나지 않는다

| iter | 기준 | mask 삭제 |
|---|---|---|
| 100 | 2.6113 | 2.6065 |
| ... | 325 | 2.4245 |
300 iter까지 기준과 거의 같은 값이었습니다. 차이는 0.01 이하입니다. 그래프를 겹쳐도 구별할 수 없습니다.
사실 처음에는 200 iter에서 실험했습니다. 그때의 결론은 'mask 삭제의 영향 없음'이었습니다. 몇 번 확인해도 mask는 올바르게 무효화되어 있는데, loss가 변하지 않습니다. 버그를 의심하여 mask가 정말로 작동하는지 확인할 시간을 사용했습니다.
정답은 간단해서, iter 수가 부족했을 뿐이었습니다.
350 iter부터 붕괴한다
600 iter까지 늘리자 이렇게 됩니다.

| iter | 기준 | mask 삭제 |
|---|---|---|
| 325 | 2.4245 | 2.3835 |
| 350 | 2.4174 | 2.2164 ← 분기 |
| 400 | 2.3941 | 1.1226 |
| 500 | 2.3045 | 0.1205 |
| 600 | 2.2395 | 0.0472 |
기준의 4분의 1 수준입니다. 지난번 3000 iter에서 도달한 1.83조차도, iter 375 시점에서 추월했습니다.
그리고 중요한 것은, train과 val이 함께 하락하고 있다는 점입니다. 0.0449 / 0.0480으로 거의 겹쳐 있습니다. #6 같은 분리는 전혀 없습니다. 과적합의 징후도 없습니다.
수치만 보면, 이것은 대성공입니다.
왜 325 iter까지 잠복하는가
여기서 흥미로운 부분이었습니다. 답이 보이는데, 왜 바로 사용하지 않는 걸까요?
가설은 이렇습니다. 부정행위를 하려면 학습이 필요하다.
attention은 가중치 평균입니다. 위치 t의 출력은 다른 위치의 벡터를 섞어 만든 값입니다.
출력 = Σ (가중치_i × value_i)
위치 t+1에만 가중치 1.0을 놓으면, 답이 그대로 추출할 수 있습니다. 하지만 그것은 학습해서 얻는 가중치입니다. 초기 상태에서는 128개 위치에 균등하게 분산되어 있습니다.
즉 답의 기여도는 1/128 ≈ 0.008입니다. 다른 127개에 파묻혀서 신호가 되지 못하는 것입니다.
측정해 보았다
가설을 확인하기 위해, '위치 t가 위치 t+1으로 향하는 attention 가중치'를 측정하는 함수를 작성했습니다.
⚠️ [IMG:N] 형식 토큰은 이미지 placeholder 입니다. 번역하지 말고 원래 위치에 그대로 유지하세요.
@torch.no_grad()
def attn_to_next(m, layer=0):
x, _ = get_batch(val, block_size, batch_size)
...
P의 형태는 (batch, head, T, T)이다. [:, :, t, t+1]이 구하는 값이 된다.
먼저 학습 전을 측정한다.
mask 있음: 0.0
mask 없음: 0.00778 (1/128 = 0.0078125)
mask가 있는 경우는 미래이므로 -inf로 마스킹되어 softmax 후에 0이 된다. 당연하다.
mask가 없는 경우는 0.00778로, 1/128 = 0.0078125와 거의 완벽하게 일치했다. 완전히 균일하다. 가설대로, 제거 직후에는 답을 꺼낼 수 있는 상태가 아니다.
학습 전의 loss 차이도 4.319901 vs 4.319870으로 0.00003밖에 나지 않았다.
학습 중의 추이
25 iter마다 기록했다.
| iter | val loss | attn→t+1 | 균일비 |
|---|---|---|---|
| 0 | 4.3258 | 0.0078 | 1.0 |
| ... | 300 | 2.4320 | 0.0195 |
| 325 | 2.3914 | 0.0336 | 4.3 |
| 350 | 2.1935 | 0.0683 | 8.8 |
| 400 | 1.0645 | 0.1855 | 24 |
| 600 | 0.0480 | 0.2770 | 35 |
iter 300 시점에서, attn은 이미 2.5배로 성장해 있었다. 그런데 loss는 2.4320으로 기준인 2.4400과 거의 같다. 그래프 상에서는 전혀 구별할 수 없다.
2축으로 그리면 이렇게 된다.

빨간색(attn)의 상승세가 파란색(loss)의 붕괴에 선행하고 있다. 회색은 기준 loss이다. 빨간색이 점선(균일한 수준)에서 벗어나기 시작하는 것은 250 근처, 파란색이 회색에서 벗어나는 것은 325 이후이다.
비선형적인 증가세
| 구간 | attn의 증가 |
|---|---|
| 0→100 | ×1.08 |
| ... | 300→400 |
| ×9.5 | |
| 400→600 | ×1.49(포화) |
300→400에서 급격히 가속했다. 여기서 자기 강화 루프가 돌아가기 시작한다.
t+1에 대한 주의가 조금 강해진다
→ 답이 조금 보인다
→ loss가 조금 떨어진다
...
정의 피드백(positive feedback)이기 때문에, 한번 돌기 시작하면 멈추지 않는다. 그리고 0.27 전후에서 포화한다.
학습이 멈춘 것처럼 보여도
이것이 이번 가장 큰 수확이었다. loss가 평평한 구간에서도, 내부적으로는 회로가 구성되어 있을 때가 있다.
'위치+1 보기'라는 가중치는 pos_emb에서 위치 관계를 읽어 query와 key의 대응을 만드는 작업이다. 글자 내용과는 무관하게, 순수하게 위치에 기반한 패턴이다. 이 회로가 완성될 때까지는 효과가 없고, 완성된 순간부터 급격히 효력을 발휘한다.
grokking이나 상전이(phase transition)라고 불리는 현상과 비슷한 형태라고 생각한다.
탐지하는 방법은 하나밖에 없다
지금까지의 상황을 정리한다.
- loss는 계속해서 떨어지고 있다
- val도 함께 떨어지고 있다
- train과 val은 겹친 상태를 유지하고 있다
- 기준치의 47분의 1이라는 놀라운 수치
- 에러는 전혀 발생하지 않는다
수치만으로는 이상을 감지할 수 없다. 평소 신뢰하던 지표들이 전부 '정상'을 가리키고 있다.
남은 방법은 하나이다. 실제로 문장을 생성하게 하는 것.
생성하게 해보기
생성 시에는 아직 글이 존재하지 않기 때문에, 한 글자씩 만들어 나간다. 미래가 존재하지 않는 이상, 컨닝할 수 없다.
기준 (val 2.24) mask 제거 (val 0.047)
Thes dreoun, hind youn ancorseshe be H
fabuty, in thourd sowe therds HHVe
...
기준은 no, four, these, wine, not, my, then와 같은 실재어 또는 이에 가까운 철자를 출력했다. 쉼표나 마침표, 세미콜론 사용법, I seld;와 같은 1인칭+세미콜론 구조도 파악하고 있다.
mask 제거 쪽은 H와 줄 바꿈의 나열이다. 영어의 구조를 아무것도 배우지 못했다.
loss가 47분의 1인 모델 쪽이 압도적으로 떨어진다.
왜 이렇게 되는가
마스크를 제거한 모델은 '옆을 본다'는 전략만 학습했다. 그것으로 충분히 loss가 떨어지기 때문이다.
문맥으로부터 다음 것을 예측하는 능력은 배울 필요가 없었기 때문에 배우지 않았다. 생성 시에 그 전략이 사용 불가능해지면, 아무것도 가진 것이 없는 상태로 쓰여지게 된다.
이는 훈련 시와 실제 환경에서 조건이 어긋나는 상황을 의미한다. causal mask의 역할은 '학습 시에 생성 시와 동일한 조건을 재현하는 것'이었다. 그것을 제거하면 학습은 쉬워지지만 실제 환경에서 통하지 않는다.
6가지를 나열해 보기
Week3에서 시도한 6가지 결과를 보여준다.
| # | 파괴 방식 | iter 600 (val) | train과 val | 감지 방법 |
|---|---|---|---|
| — | 기준 | 2.2395 | 일치 | — |
| ... | 일치 | 생성 |
| #4 | 초기값×10 | 2.4658 | 일치 | 학습 전 측정 |
| #5 | 잔차 제거 | 3.3432 | 일치 | 레이어별 기울기 |
| #6 | 데이터 2,000자 | 5.7113 | 분리 | train/val 차이 |
train과 val이 분리된 것은 #6뿐이었다. 나머지 5개는 모두 일치한 채로 망가져 있었다.
'val을 보고 있으면 안심'이라는 전제는 6개 중 5개에서 성립하지 않았다.
Week3에서 알게 된 것
1. 증상이 같아도 원인은 별개. #1(lr=1.0), #2, #5는 모두 unigram 수준에서 정체한다. 구분하기 위해서는 추가적인 측정이 필요하다.
2. loss가 낮을수록 좋은 것은 아니다. #3은 기준의 4분의 1을 달성하면서도, 생성 결과는 명확히 떨어진다.
3. 중단하는 위치에 따라 결론이 바뀐다. #3은 325 iter까지 기준과 일치하므로, 200 iter에서 멈추면 '영향 없음'이라고 오진할 수 있다. 실제로 한 번 그렇게 된 적이 있다.
4. loss가 평평해도 내부에서는 움직이고 있다. #3의 attn은 아무 일도 일어나지 않은 것처럼 보이는 구간에서 1.3배 → 2.5배로 커지고 있었다.
5. 예상은 자주 벗어난다. 4층이라면 잔차는 필요 없고, 잔차 제거로 기울기 소실, 발산이라고 하면 NaN, 마스크 삭제는 즉시 효과가 크다. 전부 빠졌다.
진단 절차로 정리하면 다음과 같다.
loss가 3.3 근처에서 정체
│
├─ 1. x와 y를 육안으로 확인 ← 1초
...
마지막 '생성하게 해보기'가 이번에 가장 많이 배운 것이 되었다. 수치가 모두 정상을 가리키고 있어도, 결과물을 보지 않으면 알 수 있는 경우가 있다.
맺음말
3회에 걸쳐 Transformer를 6가지 방식으로 망가뜨린 기록을 작성했다.
당초의 목적은 '증상 → 원인' 대응표를 만드는 것이었다. 실제로 만들어 보니, 하나의 증상에 여러 원인이 대응하는 경우가 있어 표만으로는 부족하다는 것을 알았다. 구분하기 위한 절차까지 준비해야 비로소 도구가 된다.
그리고 자신이 무엇을 신뢰했는지도 보였다. val loss를 보고 있으면 괜찮다고 생각했지만, 6개 중 5개에서 그것이 통하지 않았다. 버그는 'loss가 떨어지지 않는' 형태로만 나타나는 것은 아니었다.
다음은 평가 설계를 다룰 예정이다. 이번에 val만을 의지했던 부분을 좀 더 신중하게 구성할 것이다.
참고
Transformer의 마스크 처리 방식에 대하여 —
masked_fill
으로 -inf를 넣는 구조 - Tensorflow의 MultiHeadAttention의 Mask 조사 — causal mask를 넣으면 'n행目の벡터가 n번째까지의 정보만 갖게 된다'는 설명. 제거하면 미래가 섞인다
Transformer에 접해보기 (1) —
torch.triu
+masked_fill
의 최소 구현 - A Recipe for Training Neural Networks — 작은 데이터로 과적합시키는 절차 출처
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn ML의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기