Post

RSNA 무릎 ③ — 잴 수 없는 것을 재려 했다: 해상도 ±0.0031 과 다섯 장의 분해

RSNA 무릎 ③ — 잴 수 없는 것을 재려 했다: 해상도 ±0.0031 과 다섯 장의 분해

②편은 천장 세 개를 재고 0.945를 얻은 날이었습니다. 이번 글은 그다음 날인데, 성격이 다릅니다. 점수가 0.946으로 한 칸 올랐지만, 더 중요한 것은 “우리가 지금까지 재온 방식이 틀렸다”는 쪽을 발견한 것입니다.

대회 진행 중이라 코드와 대회 이미지는 싣지 않습니다. ‘실측’은 다른 조건을 그대로 두고 한 가지만 바꾼 비교로 확인한 값입니다.

0. 한 장 요약

 값
최고0.946 (4,615팀 중 157위 → 상위 3.4%)
1위0.961
public LB의 신뢰 해상도±0.0031 ← 이번 글의 핵심
우리 모델의 총 기여+0.006 (두 경로, 기반을 갈아타도 유지)

1. 먼저 해상도를 쟀다 — 그리고 과거 보고를 철회했다

②편에서 “기반 교체로 0.944 → 0.945, 몇 주 만의 신기록”이라고 썼습니다. 그런데 그 뒤에 public 점수가 얼마나 흔들리는지를 재보고 그 문장을 철회해야 했습니다.

public은 전체 테스트의 30%, 약 390건입니다. 우리 OOF에서 390건을 반복 추출해 분산을 봤습니다.

1
2
3
4
390건 뽑기 · 300회
  절대 점수 SD        0.0058    (95% 폭 0.0228)
  **짝지은 차 SD**    0.0016    (95% 폭 0.0031)
  상쇄 비율           3.6배

public 집합은 모든 제출에 고정이라, 우리 제출끼리는 같은 390건 위의 짝지은 비교입니다. 그래서 절대값 불확실성(±0.0058)보다 3.6배 정밀합니다. 그래도 ±0.0031입니다.

그러면 이렇게 됩니다.

비교차판정
두 경로 모두 끔 vs 전부 켬−0.006읽힘
묶음 가중치 1배 vs 2배+0.003읽힘
기반 교체 (0.944 → 0.945)+0.001못 읽음
프리셋 변경−0.001못 읽음

②편에서 “신기록이자 예측과 정확히 일치”라고 쓴 +0.001은 잡음 안이었습니다. 방향은 맞을 수 있지만 한 장으로는 가릴 수 없습니다. 0.945는 우리 최고 점수였고 그대로 썼지만, “+0.001을 얻었다”고 말하지 않기로 했습니다.

여기에는 더 불편한 함의가 있습니다. 추론 설정을 바꾼 제출을 열 장 넘게 내고 “전부 0.944 평지”라고 적어왔는데, 그 비교들은 애초에 이 해상도에서 가릴 수 없는 것이었습니다. “평지”가 아니라 “측정 불가”였습니다.

2. 잣대 셋이 모두 부족하다

그럼 다른 잣대를 쓰면 되지 않나. 우리에겐 셋이 있습니다.

① 약라벨 OOF (4,349건, ±0.0043). 모든 축을 이걸로 판정해왔습니다. 그런데 이 잣대가 진짜 타깃과 얼마나 맞는지 한 번도 확인한 적이 없었습니다. 4-fold 이상 완주한 계열 아홉 개로 상관을 재봤습니다.

1
2
피어슨   r  = +0.995 (p<0.001)
스피어만 rho = +0.283 (p=0.46)   ← 유의하지 않다

피어슨 0.995는 완전히 망가진 한 계열(0.57)이 만든 값입니다. 그걸 빼면 남는 것은 유의하지 않은 순위 상관입니다. 즉 이 잣대는 파국적 실패는 잘 걸러내지만, 정상 작동하는 계열끼리는 순위 정보가 사실상 없습니다. 한 계열은 타깃 쪽에서 가장 좋은데 이 잣대로는 최하위권이었습니다(평가 방식이 더 어려운 교란 때문).

② 정답 라벨 (58건, ±0.011). 진짜 타깃이지만 너무 작습니다. 멤버 후보를 여기 대고 다시 판정해보니 모든 후보가 양수로 나왔습니다 — 이건 각 후보의 장점이 아니라 앙상블 자체의 산물입니다. 3모델에 아무 4번째를 더해도 예측이 평활화되고, 58건짜리 평가집합에서는 그 평활화만으로 AUC가 오릅니다.

③ public LB (390건, ±0.0031). 타깃을 재지만 위에서 본 대로 거칩니다.

세 잣대 중 어느 것도 우리가 만드는 크기(0.001~0.004)의 개선을 확인해 주지 못합니다. “모든 축이 바닥 미만”이라는 결론이 반복된 이유가 축이 전부 죽어서가 아니라 잴 수가 없어서일 가능성을 배제할 수 없습니다.

3. 그래서 질문의 형태를 바꿨다

개별 축이 전부 해상도 아래라면, 귀속은 포기하고 합을 묻는 수밖에 없습니다. 그날 다섯 장을 그렇게 설계했습니다.

  • 두 개의 작은 양수를 한 장에 쌓은 제출
  • 그 중 하나만 든 제출
  • 우리 모델이 들어가는 경로를 하나 끈 제출
  • 둘 다 끈 제출
  • 가중치를 2배로 한 제출

다섯 장의 결과입니다.

제출점수
쌓은 것 (멤버 개선 + 설정 이동)0.946
멤버 개선만0.946
기준선0.945
경로 하나 끔0.943
두 경로 모두 끔0.940
가중치 2배0.942

여기서 읽을 수 있는 것

(a) 우리 모델의 총 기여 = +0.006. 0.946 − 0.940입니다. 해상도 밖이라 확정입니다. 그리고 경로가 둘로 갈립니다 — 우리 모델은 파이프라인에 두 번 들어가는데, 한 번은 공개 분기 내부에, 한 번은 최종 융합의 묶음 가중치로 들어갑니다. 각각 +0.003씩입니다.

이게 중요한 이유: 저는 몇 주 동안 “우리 기여는 +0.003”이라고 말해왔는데, 그건 묶음 경로만 끄고 잰 값이었습니다. 절반을 놓치고 있었습니다.

(b) 기반을 갈아타도 기여가 유지된다. 더 나은 공개 기반(모델 가족이 2→4로 늘어난 판본)으로 옮겼는데도 총 기여가 그대로 +0.006입니다. 우리가 담은 정보가 중복되지 않았다는 뜻이고, 멤버 개선에 남은 기간을 쓸 근거가 됩니다.

(c) 가중치는 이미 최적이다. 0배 0.943 / 1배 0.945 / 2배 0.942 — 역U자이고 꼭짓점이 현재값입니다. 이전 기반에서도 같은 모양이었습니다. 두 기반에서 일관되므로 이 축은 닫힙니다.

(d) 설정 이동의 기여는 0.000이다. “쌓은 것”과 “멤버 개선만”이 똑같이 0.946입니다. 즉 상승분은 전부 멤버 쪽이고, 설정 이동은 아무것도 하지 않았습니다. 이 분해가 가능했던 건 두 장을 짝으로 설계해 같은 날 냈기 때문입니다.


4. 사전 등록을 잘못 설계했다

쌓은 제출을 내기 전에 이렇게 적어뒀습니다 — “0.945를 0.003 이상 넘기면 그 방향을 채택하고, 못 넘기면 두 축을 함께 닫는다.”

결과는 +0.001이었습니다. 기준 미달입니다.

그런데 여기서 “못 넘었으니 닫는다”를 집행하려다 멈췄습니다. 제가 이 효과를 +0.0005~0.001로 추정해 놓고 통과 기준을 +0.003으로 걸었기 때문입니다. 통과할 수 없는 시험이었습니다. 그리고 그 시험의 실패를 근거로 축을 닫으면, 실제 증거를 버리게 됩니다 — 그 축은 OOF에서 전처리가 다른 두 경로 모두에서(각각 +0.0038, +0.0032, 8 fold 중 7개) 독립 확인된 것입니다.

사전 등록 기준이 기대 효과보다 크면 그 시험은 설계상 실패합니다. 기준을 정하기 전에 “내 추정치가 이 기준을 넘을 수 있는가”를 먼저 봐야 합니다. 넘을 수 없으면 시험을 다르게 설계하거나(더 쌓거나) 아예 하지 않습니다. “못 넘으면 닫는다”를 그런 시험에 붙이면 증거 파괴 장치가 됩니다.

올바른 결론은 “닫는다”가 아니라 “LB로는 판별 불가”였고, 처음부터 그렇게 썼어야 했습니다.


5. 조용한 오염 — 같은 작업을 두 GPU가 돌고 있었다

한 모델의 학습 기록을 열어봤더니 에폭이 이렇게 적혀 있었습니다.

1
epoch  14, 0, 15, 1, 16, 2, 17

두 실행이 같은 디렉터리에 번갈아 쓰고 있었습니다. 큐가 GPU 단위로만 잠금을 걸어서, 같은 작업을 두 GPU에 배정하면 둘 다 돌며 같은 산출물을 덮어씁니다. 제가 큐 목록을 바꾸면서 돌고 있는 큐는 옛 목록을 계속 쓴다는 걸 놓쳐 그렇게 만들었습니다.

에폭 단조성으로 전수 검사했습니다.

1
2
서버 A  176개 검사 · 오염 0
서버 B   50개 검사 · **오염 3**

오염된 셋 중 둘이 이미 내린 판정에 쓰였습니다. 그 판정(4 fold 중 2개가 오염)은 철회했습니다. 결론 자체는 다른 독립 근거가 있어 바뀌지 않지만, “그 수치로 판정했다”는 기술은 사실이 아니게 됐습니다.

오염은 조용합니다. 로그에는 “완료”라고 찍히고, 산출물 파일도 멀쩡히 존재합니다. 전수 검사 없이는 보이지 않습니다.


6. 같은 날 하루에 여섯 번 막았다

점수와 무관해 보이지만 실제로는 가장 비싼 부분입니다. 하루 동안 이런 것들이 있었습니다.

  • 데이터셋을 갱신하면서 폴더 업로드 옵션을 빠뜨려 가중치가 하나도 없는 버전이 만들어졌다. 자동 푸시 감시가 “원격 파일 수”를 선행 조건으로 걸어둬서 깨진 데이터셋 위에 아무것도 올리지 않았다.
  • 커널 일곱 개를 만들었다고 생각했는데 한 개도 안 만들어졌다. 내려받은 메타데이터에 숫자 ID가 들어 있었고, 플랫폼은 사람이 읽는 이름보다 그 숫자를 우선한다. 일곱 번의 푸시가 전부 같은 커널의 이름만 바꿨다. 단서는 슬러그가 다른데도 버전 번호가 연속 증가한 것 하나였다.
  • 제출기를 재기동하는 스크립트가 끊기면서 제출기를 죽은 채로 남겼다. 별도 명령으로 확인해서 잡았다. 그대로 뒀으면 그날 다섯 슬롯을 전부 잃었다.
  • 학습 설정에 키 하나를 추가했다가 인자 충돌로 모든 학습이 죽었다. 이미 돌던 프로세스는 영향이 없어서 큐 로그를 보기 전까지 드러나지 않았다.
  • 로더를 고치고 한쪽 서버에만 적용해서, 다른 서버에서 돌 뻔한 실험이 완전히 다른 것이 될 뻔했다.
  • 보호 장치(디스크 가드)를 만들어 두고 스케줄하지 않았다. crontab -l | grep -c 가 0이었다.

여기서 나온 규칙 몇 개를 남깁니다.

  1. “성공적으로 푸시됨”은 아무것도 보장하지 않는다. 원격 목록을 세어 확인한다.
  2. “만들었다”와 “걸려 있다”는 다르다. 보호 장치를 만들면 그 자리에서 등록 여부를 수치로 확인한다.
  3. 재기동 스크립트가 0을 돌려줘도 그것만 믿지 않는다. 원격 호출은 끊김과 성공을 구별할 수 없다. 별도 명령으로 프로세스를 확인한다.
  4. 적재된 내용은 개수가 아니라 이름으로 센다. “모델 6개”로는 어느 6개인지 알 수 없다.
  5. 실행 중인 스크립트를 고치면 반드시 재기동한다. 셸은 바이트 오프셋으로 이어 읽는다.

7. 지금 서 있는 자리

1
2
3
0.946 · 157위/4,615 · 1위 0.961 · 격차 0.015
추론 측   설정 이동 0.000 · 가중치 꼭짓점이 현재값 · 혼합비 평지  → 거의 다 닫혔다
남은 레버 모델 품질뿐

그래서 지금 GPU 네 장이 전부 한 번도 안 써본 백본 두 종에 들어가 있습니다. 하나는 공개 상위 다섯 개 노트북이 전부 쓰는 것(현행의 2.5배 크기), 다른 하나는 우리 해상도가 원래 평가 해상도인 것입니다. 첫 결과까지 28시간이 걸리고, 중단 조건을 미리 정해뒀습니다 — 두 fold가 모두 크게 지면 나머지를 포기합니다.

정직한 전망을 적습니다. 멤버 기여 +0.006은 “모델을 더 좋게 만들면 그만큼 더 오른다”가 아닙니다. 가중치 반응곡선의 꼭짓점이 현재값이므로, 그건 지금 품질에서의 최적 기여입니다. 품질이 오르면 꼭짓점이 올라갈 수는 있지만 그 폭은 미지수입니다. 1위까지 0.015이고, 그걸 메울 기구는 아직 보이지 않습니다.

다만 이번 글의 교훈은 점수가 아닙니다. 잴 수 없는 것을 재려 하면, 결과가 음수든 양수든 그건 측정이 아니라 잡음입니다. 그걸 모르고 “축이 죽었다”를 열 번 적었던 것이 이번 주의 가장 큰 발견이었습니다.

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