Posts 멀티태스크 학습에서 불확실성 가중이 무너진 과정
Post
Cancel

멀티태스크 학습에서 불확실성 가중이 무너진 과정

학습되는 손실 가중을 끄니까 3.20%p가 돌아왔다

백본 하나에 헤드 여러 개를 붙이는 멀티태스크 모델을 학습할 때 손실을 어떻게 합칠 것인가는 매번 나오는 문제다. 헤드마다 손실 스케일이 다르니 단순 합산은 위험해 보이고, 그래서 Kendall & Gal 스타일의 불확실성 가중을 쓰는 경우가 많다.

이번에 그게 정확히 반대로 작동했다.

실행손실 가중랜드마크 NME단일 헤드 대비게이트(≤0.5%p)
게이트 실행uncertainty, 하한 없음13.3013%+3.52%pFAIL
실험 Auncertainty + 하한 -5.013.1389%+3.36%pFAIL
실험 Bfixed (단순 합산)10.0974%+0.31%pPASS

학습되는 가중을 아예 끄는 것만으로 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.0148배

가중을 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로 바꾸면 속성 헤드가 손해를 볼 것 같지만, 그렇지 않았다.

지표uncertaintyfixed
NME13.3013%10.2028%
AUC100.24080.3268
FR100.40880.2816
attr macro-F10.38990.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(...)

어기면 학습률이 한 번도 안 내려간 채 학습이 끝난다. 결과는 “수렴이 덜 됐다”로만 보여서 어닐링이 있었는지 없었는지 구별이 안 된다.

참고로 스케줄러 선택 기준은 질문 하나로 갈린다. 총 에폭 수를 아는가. CosineAnnealingLRT_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가 대표적)가 하나라도 섞이면 그 순간 무너진다.

그리고 이런 종류의 붕괴는 하이퍼파라미터로 조정할 수 없다. 병리와 정상이 같은 수치 대역에 있기 때문이다. 구조를 바꾸거나 아예 끄는 것이 답이었다.

This post is licensed under CC BY 4.0 by the author.