학습되는 손실 가중을 끄니까 3.20%p가 돌아왔다
백본 하나에 헤드 여러 개를 붙이는 멀티태스크 모델을 학습할 때 손실을 어떻게 합칠 것인가는 매번 나오는 문제다. 헤드마다 손실 스케일이 다르니 단순 합산은 위험해 보이고, 그래서 Kendall & Gal 스타일의 불확실성 가중을 쓰는 경우가 많다.
이번에 그게 정확히 반대로 작동했다.
| 실행 | 손실 가중 | 랜드마크 NME | 단일 헤드 대비 | 게이트(≤0.5%p) |
|---|---|---|---|---|
| 게이트 실행 | uncertainty, 하한 없음 | 13.3013% | +3.52%p | FAIL |
| 실험 A | uncertainty + 하한 -5.0 | 13.1389% | +3.36%p | FAIL |
| 실험 B | fixed (단순 합산) | 10.0974% | +0.31%p | PASS |
학습되는 가중을 아예 끄는 것만으로 3.20%p가 회복됐다.
무엇을 최적화하고 있었나
불확실성 가중은 헤드마다 학습되는 log-분산 s_i = log σ_i²를 하나 두고 이걸 최소화한다.
1
L = Σ exp(-s_i)·L_i + s_i
이 손실의 최적해는 s_i = log L_i다. 대입하면 각 헤드의 유효 가중이 exp(-s_i) = 1/L_i가 된다.
즉 손실이 작은 헤드일수록 큰 가중을 받는다.
여기가 함정이다. 이 최적화는 헤드의 손실이 작은 이유를 구별하지 못한다. 그 태스크가 “쉬워서” 작은 것인지, “그 태스크가 실제로 잘 학습돼서” 작은 것인지 알 방법이 없다. 그냥 작으면 가중을 몰아준다.
속성 헤드가 어떻게 백본을 훔쳤나
속성 분류 헤드는 BCE 손실을 쓴다. 라벨이 불균형하면 다수 클래스만 계속 찍어도 손실이 0 근처로 떨어진다.
최종 에폭 실측이 이랬다.
1
2
속성 손실: 0.00136
랜드마크 손실: 0.399
300배 가까운 격차다. 이 격차가 속성 헤드의 s를 하한까지 밀어붙였다.
| 조건 | 속성이 받은 가중 배율 |
|---|---|
| 하한 없음 | 192배 |
| 하한 -5.0 | 148배 |
가중을 192배로 몰아줬는데도 그 헤드의 특정 속성 F1은 0.019~0.028이었다. 다수 클래스를 계속 찍고 있었다는 뜻이다.
정리하면 이렇게 된다. 쉽지만 쓸모없는 태스크가 손실을 낮게 만들고 → 낮은 손실이 큰 가중을 부르고 → 큰 가중이 공유 백본을 그쪽으로 끌어당기고 → 정작 중요한 랜드마크 헤드가 망가진다. 그러면서 그 쉬운 태스크의 성능은 전혀 안 올라간다.
하한값으로는 못 고친다
log-분산에 하한을 걸면 병리적 배율을 억제할 수 있을 것 같다. 실측으로 확인해보면 아니다.
| 하한 | 정상적인 100배 재조정 | 관측된 병리 억제 |
|---|---|---|
| -2.0 | 깨짐 (기여비 0.074) | 7.4배로 억제 |
| -4.6 | 정상 (0.995) | 100배 |
| -5.0 (채택) | 정상 (1.000) | 148배 — 거의 억제 못함 |
병리(192배)와 정상 동작(다른 헤드 조합에서 필요할 수 있는 100배 재조정)이 같은 크기 대역에 있다. 그래서 임계값 하나로는 안 갈린다. 하한을 낮춰 병리를 억제하려 하면 정상적인 재조정까지 같이 깨진다.
실험으로도 확인됐다. 하한을 걸었을 때 NME는 13.14%로, 안 걸었을 때의 13.30%에서 겨우 0.16%p 개선이었다.
결론적으로 min_log_var = -5.0은 무한 발산 방지용 안전판으로만 남았다. 100배 재조정을 살리는 가장 낮은 지점일 뿐, 붕괴의 해결책은 아니다.
클램프는 두 항 모두에 걸어야 한다
관련해서 별개의 버그가 하나 더 있었다. s에 하한만 있고 상한이 없는 이유는 비대칭 때문이다. 손실이 0으로 가면 최적 s가 아래로 발산한다. exp(-s)·L이 0으로 줄어드는 동안 +s는 -∞로 간다. 반대 방향으로는 그런 힘이 없다. s가 커지면 +s가 선형으로 커져 총 손실이 스스로 제한한다.
그런데 클램프를 가중 항에만 걸면 +s 항이 여전히 s를 아래로 미는 힘으로 남고 복원력이 사라진다. 실측에서 s가 -40까지 발산했다.
1
2
s_clamped = s.clamp(min=min_log_var)
loss = (torch.exp(-s_clamped) * L + s_clamped).sum() # 두 항 모두 클램프된 값
이건 100배 스케일 격차를 재조정하는 테스트를 하나 써뒀더니 잡혔다.
결과를 다시 재보니 잃은 게 없었다
fixed로 바꾸면 속성 헤드가 손해를 볼 것 같지만, 그렇지 않았다.
| 지표 | uncertainty | fixed |
|---|---|---|
| NME | 13.3013% | 10.2028% |
| AUC10 | 0.2408 | 0.3268 |
| FR10 | 0.4088 | 0.2816 |
| attr macro-F1 | 0.3899 | 0.3778 |
랜드마크는 크게 좋아지고 속성은 사실상 제자리(macro-F1 −0.012)다. 공유 백본을 랜드마크에서 빼앗아 속성에 쏟아부은 대가로 얻은 것이 사실상 없었다.
학습 루프에 넣어둔 가드들
이 사건과 별개로, 조용히 실패하는 경로를 막는 가드를 몇 개 두고 있다. 전부 “에러 없이 잘못된 결과가 나오는” 경우를 막는 것이다.
early_stop_patience > lr_patience
1
2
if scheduler_kind == "plateau" and not (0 < stop_patience > lr_patience):
raise ValueError(...)
어기면 학습률이 한 번도 안 내려간 채 학습이 끝난다. 결과는 “수렴이 덜 됐다”로만 보여서 어닐링이 있었는지 없었는지 구별이 안 된다.
참고로 스케줄러 선택 기준은 질문 하나로 갈린다. 총 에폭 수를 아는가. CosineAnnealingLR은 T_max로 상한을 안다고 가정한다. 에폭을 상한으로만 크게 잡고 실제 종료를 early stop에 맡기는 방식이면 코사인이 거의 감쇠하지 않으므로 ReduceLROnPlateau가 맞다.
Early stop은 최고 기록 대비로 센다
1
2
if self._is_better(monitored):
self.stale_epochs = 0
직전 에폭 대비로 세면 “나빠졌다가 덜 나빠지기만 해도” 카운터가 리셋된다. 최고 기록을 한 번도 못 넘긴 채 상한 에폭까지 그냥 도는 실행이 생긴다.
best 체크포인트가 없으면 학습을 실패로 던진다
감시 지표가 전부 NaN이면 _is_better가 min/max 어느 쪽에서도 항상 False라 best.pth가 끝까지 안 만들어질 수 있다. 그 경로를 그대로 반환하면 다음 단계(모델 등록, 결과 출력)가 학습이 성공한 것처럼 보이는 실패를 만든다.
1
2
if not best_path.is_file():
raise RuntimeError("best checkpoint was never written")
감시 지표와 방향은 한 곳에서 짝으로 정한다
1
2
3
4
def _monitored_metric(heads):
if "landmark" in heads:
return "nme", "min"
return "attr_macro_f1", "max"
메트릭과 방향이 어긋나면 아무 에러 없이 매 에폭 최악의 체크포인트가 best.pth로 저장된다. 두 곳에서 따로 정하면 언젠가 어긋난다.
라벨 없는 헤드는 키 자체를 안 낸다
1
2
3
# 0.0을 내면 평균에 섞여 손실이 실제보다 작아 보인다
if not has_label:
continue
대신 배치의 모든 헤드가 라벨이 없으면 빈 딕셔너리가 나오는데, torch.stack([])는 예외다. 그래서 backward 가능한 0으로 대체한다.
1
torch.zeros((), requires_grad=True)
실제로 쓴 학습률을 옵티마이저에서 직접 읽어 남긴다
스케줄이 잘못 잡혀 lr이 0에 가깝게 도는 것은 곡선에 남지 않으면 수렴 실패와 구별되지 않는다.
1
epoch_lr = optimizer.param_groups[0]["lr"]
남은 것
가장 중요한 결론 하나만 남기면 이렇다.
학습되는 손실 가중은 “각 태스크의 난이도”를 추정한다고 말하지만, 실제로는 “각 태스크의 손실 크기”를 추정한다. 손실 크기가 태스크의 중요도나 학습 여부와 무관해질 수 있는 헤드 (불균형 라벨의 BCE가 대표적)가 하나라도 섞이면 그 순간 무너진다.
그리고 이런 종류의 붕괴는 하이퍼파라미터로 조정할 수 없다. 병리와 정상이 같은 수치 대역에 있기 때문이다. 구조를 바꾸거나 아예 끄는 것이 답이었다.