RSNA 무릎 ⑤ — 가장 약한 멤버가 유일하게 보탰다: 스물 몇 개 축이 전부 평지였던 이유
④편 이후로 한 달 가까이 더 달렸고, 여기서 멈춥니다. 최종 공개 점수 0.947, 5,391팀 중 192위. 1위는 0.964이고 격차는 0.017입니다.
대회 진행 중이라 코드와 대회 이미지는 싣지 않습니다. ‘실측’은 다른 조건을 그대로 두고 한 가지만 바꾼 비교로 확인한 값입니다.
이 글은 성공담이 아닙니다. 왜 스물 몇 개의 시도가 전부 0이었는지를 마지막에야 알아낸 이야기이고, 그 답이 생각보다 단순했다는 이야기입니다.
1. 아홉 날 동안 점수가 움직이지 않았다
중간쯤부터 상황이 이랬습니다. 모델 구성은 잡혔고, 측정 도구도 갖췄고, 할 일 목록에는 아직 시도하지 않은 설정이 스무 개쯤 남아 있었습니다. 하나씩 돌렸습니다.
학습률 배수. EMA 감쇠. weight decay. 드롭아웃. 랭크 손실. 슬라이스 배분. 라벨 보정. 증강 조합. 해상도. 백본 크기. 소견별 가중치. 기관별 분할.
전부 평지였습니다. 짝지은 fold 비교로 재면 평균 −0.0005에서 +0.0003 사이, 해상도 바깥으로 나가는 것이 하나도 없었습니다. 몇 개는 유의하게 나빴고(라벨 보정 −0.0024, 판독문 침묵 가중치 −0.0024), 나머지는 0이었습니다.
데이터도 의심했습니다. 약라벨을 만든 판독문이 8개 언어로 섞여 있고, 언어별로 모델 성능이 갈렸습니다 — 영어 0.915, 터키어 0.893. 라벨러가 터키어에서 양성을 과다 호출한다는 신호가 세 갈래로 나왔고(불확신 구간 1.7배, 정답 대비 헛짚음 1.7배, 판독문 길이 30% 짧음), 그래서 터키어·러시아어 study를 절반으로 낮춰 학습해 봤습니다.
8쌍 전부 음수였습니다. 평균 −0.0012. 왜냐하면 언어는 기관의 대리 변수였기 때문입니다. 같은 기종(3.0T 동일 모델)인데 제조사 문자열이 달라 기관이 갈리는 두 집단을 비교하니 0.928 대 0.869. 하드웨어가 아니라 기관이 격차를 만들고 있었고, 언어를 깎는 것은 곧 그 기관들의 학습 신호를 깎는 일이었습니다.
공개된 약라벨 여섯 종도 받아서 전부 비교했습니다. 우리 것보다 나은 게 없었습니다. (여기서 함정 하나를 밟을 뻔했는데, 아래 §5에서 다룹니다.)
그래서 결론은 이랬습니다. 모델은 이미 교사를 따라잡았고, 설정 공간은 고갈됐다.
절반만 맞는 결론이었습니다.
2. 닫은 축에게 다른 질문을 던졌다
막판에 구성을 하나 바꿨더니 9일 만에 점수가 움직였습니다(§3). 그 구조를 들여다보다가 생각이 하나 났습니다.
지금까지 축을 닫을 때 쓴 기준은 “단독으로 기준선을 이기나” 였습니다. 그런데 그건 앙상블 멤버를 고르는 기준이 아닙니다. 실제로 공개된 다른 팀의 모델은 우리 것보다 0.006 약한데도 공용 스택에 얹으면 +0.001을 보탭니다.
마침 확인할 길이 있었습니다. 디스크 회수 스크립트가 닫힌 축의 가중치만 지우고 OOF 예측은 남겨 뒀습니다. 그래서 학습 없이, GPU 0시간으로, 닫은 계열들을 하나씩 네 번째 멤버로 넣어 보고 랭크 평균이 좋아지는지 fold 짝으로 뺄 수 있었습니다.
기준선은 우리 팔 세 계열입니다. 세 계열은 증강·학습률·해상도가 다를 뿐 백본이 전부 같습니다.
| 네 번째 멤버 | 백본 | 기여 | 양수 fold | 단독 성능 |
|---|---|---|---|---|
| A (닫힌 축) | 다른 계열 | +0.0010 | 4/4 | 0.9118 |
| B | 기준선과 같음 | −0.0001 | 3/8 | 0.9132 |
| C | 기준선과 같음 | −0.0002 | 3/8 | — |
| D | 기준선과 같음 | +0.0000 | 4/6 | — |
| E | 기준선과 같음 | +0.0000 | 3/6 | — |
두 가지가 동시에 보입니다.
첫째, 같은 백본의 변형은 아무것도 보태지 않습니다. 정확히 +0.0000입니다. 넷째 멤버를 넣었으니 평균이 조금이라도 흔들릴 법한데, 흔들리지 않습니다.
둘째, 유일하게 보탠 A는 단독 성능이 가장 낮습니다. 0.9118로 네 모델 중 꼴찌인데, 앙상블에는 혼자만 기여합니다.
A와 B는 전처리 캐시가 같고 설정 파일에서 백본 한 줄만 다른 짝입니다. 그 한 줄에서 −0.0001과 +0.0010이 갈립니다. 캐시 차이도, 학습 설정 차이도 아니고 구조 차이입니다.
이 표가 지난 몇 주를 설명합니다. 제가 닫은 축 스물 몇 개는 전부 같은 백본의 변형이었습니다. 같은 눈으로 같은 것을 보는 모델을 아무리 다르게 최적화해도, 앙상블에 새로 보탤 정보가 없습니다. 평지였던 게 아니라 평지일 수밖에 없는 축만 골라 밟고 있었습니다.
A의 가중치는 이미 지워져 있어서 다시 학습했습니다. 새 가중치로 다시 재니 +0.0010, 4/4 양수로 똑같이 나왔습니다. 다른 학습 실행, 같은 답입니다.
앙상블 멤버를 고를 때의 기준은 “이 모델이 센가”가 아니라 “이 모델이 다른 것을 보는가“입니다. 전자로 거르면 후자를 영영 못 찾습니다.
3. 팔은 더해진다
점수를 움직인 변화는 설정이 아니라 구성이었습니다.
공개 생태계에는 커뮤니티 공용 스택이 있고, 그 위에 각자 따로 학습한 모델을 얹는 구조가 자리잡았습니다. 어떤 팀이 자기 모델을 공개하면서 숫자를 함께 적어 뒀습니다 — 스택 단독 0.943, 자기 모델 단독 0.929, 둘을 섞어 0.944.
우리 모델 단독은 0.935로 그들보다 셌고, 스택에 얹으면 0.946이었습니다. 그래서 셋을 다 섞어 봤습니다.
1
2
3
공용 스택 단독 0.943
+ 공개된 다른 팀 모델 (저자 측정) 0.944 (+0.001)
+ 우리 모델 0.947 (+0.003)
0.947. 아홉 날 만에 깨진 기록이고, 순위로는 250위에서 192위로 58계단입니다.
눈여겨볼 것은 우리 모델의 기여가 두 번 독립적으로 +0.003이라는 점입니다. 기반이 0.943일 때도, 0.944일 때도 같았습니다. 한 점의 +0.001은 측정 해상도(±0.0031) 아래지만, 이 일관성이 그 값을 잡음이 아니라 덧셈이 성립한 결과로 읽게 합니다. 세 팔의 기여가 거의 독립이라는 뜻이고, §2의 발견과 같은 이야기입니다 — 정보가 겹치지 않을 때만 보탭니다.
같은 구성에서 멤버 수만 6개에서 12개로 늘려 봤습니다. 0.947. 똑같습니다. 숙주를 “실행 최적화판”으로 바꿔 봤습니다. 0.945로 떨어졌습니다. fold를 바꿔 봤습니다. 역시 움직이지 않았습니다.
보태는 것은 구조가 다른 팔뿐입니다. 나머지는 전부 0입니다.
4. 산수가 말해 주는 것
여기서 멈추는 이유를 솔직히 적겠습니다.
1
2
3
4
우리 0.947
공용 스택 단독 0.943 <- 점수의 대부분
우리 기여 +0.003~4
1위 0.964
우리 점수의 거의 전부가 공개된 기반에서 나옵니다. 우리가 넣은 순수 기여는 0.003~0.004입니다. 1위까지는 0.017이 더 필요한데, 그건 지금 기여의 네다섯 배를 더 쌓아야 한다는 뜻입니다.
§2가 알려준 길 — 구조가 다른 멤버를 늘리는 것 — 은 실측으로 +0.0010입니다. 그것도 우리 팔의 내부 지표에서 그렇고, 최종 점수까지 내려오면 해상도 아래로 깎입니다. 이 길로 0.017을 메우려면 그런 멤버가 열 개 넘게 필요하고, 그만큼 서로 다른 구조가 실제로 존재하는지도 알 수 없습니다.
상위권은 우리가 못 본 무언가를 갖고 있습니다. 공개된 최고 기반이 0.946인데 1위가 0.964라면, 그 0.018은 공개 생태계 바깥에서 온 것입니다. 남은 2주에 그걸 처음부터 만들 수는 없습니다.
가망이 없다는 판단에 동의합니다. 다만 이 산수는 처음부터 보였던 게 아니라, 기여를 분해할 수 있게 된 뒤에야 보였습니다. 그 분해를 하려고 제출 몇 장을 “점수를 올리는 데”가 아니라 “구조를 재는 데” 썼는데, 그게 이 글에서 제일 값진 지출이었다고 생각합니다.
5. 측정에서 배운 것
잣대를 비교할 때는 영점부터 맞춘다
공개 약라벨 여섯 종을 평가할 때, 정답 58건이 전부 빠져 있어서 직접 비교가 막혔습니다. 그래서 픽셀을 심판으로 세웠습니다 — 각 라벨셋을 이진화하고, 우리 영상 모델이 그것을 얼마나 잘 세우는지 AUC로 쟀습니다.
결과가 극적이었습니다. 공개 라벨 하나가 전 언어 구간에서 우리를 이겼습니다. 전체 +0.012, 터키어 +0.018, 러시아어 +0.034.
그 비교는 틀렸습니다. 그 라벨셋의 양성 비율은 0.157, 우리는 0.272였습니다. 양성을 적게 부르면 쉬운 사례만 남아 AUC가 거저 오릅니다. 소견마다 양성 개수를 맞춘 뒤 다시 재니 순위가 통째로 뒤집혀, 우리 라벨이 +0.024 앞섰습니다. 정답 58건 비교도 같은 방향이었습니다.
서로 다른 라벨셋을 AUC로 비교할 때는 양성 개수를 먼저 맞춥니다. 맞추지 않으면 “더 성긴 라벨”이 “더 좋은 라벨”로 읽힙니다.
최소 재현이 가설 셋보다 빠르다
막판에 실행 환경이 아무 출력 없이 실패하기 시작했습니다. 저는 가설을 세 번 세웠습니다.
- 일시적 장애다 → 재시도했더니 계속 실패
- 동시 실행 개수 제한이다 → 한가해진 뒤에도 실패
- 내가 넣은 코드의 들여쓰기 오류다 → 고쳤는데도 같은 증상
세 번째는 진짜 오류였지만 실패의 원인이 아니었습니다. 고친 뒤에도 같은 증상이 났을 때 “그럼 원인이 아니다”로 넘어갔어야 하는데, 이미 고친 것이 아까워서 계속 그 근처를 팠습니다.
결국 의존성을 전부 뺀 print 한 줄짜리 최소 작업 두 개를 던졌습니다. 둘 다 똑같이 실패했습니다. 2분 만에 답이 나왔습니다 — 제 코드와 무관한, 계정 수준의 문제였습니다.
그 2분 전까지 쓸데없는 재시도로 버전 16개를 태웠습니다.
증상이 내 변경과 무관할 수 있으면, 변경을 더 들여다보지 말고 최소 재현부터 만듭니다. 그리고 “진짜 버그를 고쳤는데 증상이 그대로”는 그 버그가 원인이 아니라는 증거입니다.
6. 운영에서 배운 것
기술보다 이쪽이 더 많이 아팠습니다. 하루에 같은 종류의 사고를 세 번 냈습니다.
명령을 손으로 지었습니다. 자동화 스크립트를 새로 쓰면서 CLI 인자를 몇 개 빠뜨렸습니다. 한 번은 제출이 조용히 실패했고(스크립트는 실패를 삼키고 “제출함”을 찍은 뒤 정상 종료했습니다), 한 번은 업로드에서 하위 디렉터리가 통째로 건너뛰어져 모델 가중치가 전부 사라진 버전이 올라갔습니다. 두 경우 모두 저장소에 올바른 형식이 이미 있었고, 두 번째는 바로 그 함정을 경고하는 주석까지 달려 있었습니다.
살려준 건 하나입니다. 아침에 “성공을 종료 코드가 아니라 결과물로 확인한다”는 규칙을 적어 뒀고, 오후에 그 규칙이 가중치 삭제를 잡아냈습니다. 도구가 알려 준 “N장 남았습니다”는 거짓말을 했고, 파일 목록이 진실을 말했습니다.
이관이 작업을 조용히 건너뛰게 했습니다. 두 번째 서버를 떼면서 결과물을 같은 이름으로 옮겼습니다. 작업 큐는 완주 여부를 “결과 파일이 있는가”로 판정하는데, 옮겨온 파일이 그 조건을 만족해 버렸습니다. 큐는 네 개의 작업을 전부 건너뛰고 종료했고, GPU 두 장이 몇 시간 놀았습니다. 아무 에러도 나지 않았습니다.
다른 기계의 산출물을 옮길 때는 이름을 바꿔 넣습니다. 같은 이름이면 “이미 했다”를 속여 그 작업을 영영 건너뜁니다.
20 GPU-시간을 배정하기 전에 셌습니다. 한 번은 운이 좋았습니다. “지금 쓰는 모델들이 전부 같은 데이터 분할만 본다”는 가설로 큐를 짜 놓고, 실행 직전에 실제 디렉터리를 세어 봤습니다. 이미 전부 학습돼 있었습니다. 빠진 건 두 칸뿐이었고, 20 GPU-시간짜리 과제가 아니라 1.2GB 업로드짜리 일이었습니다. 저장소에 적힌 숫자도 다시 세야 합니다.
닫으며
점수로는 실패했습니다. 0.947에서 멈추고, 1위와의 격차 0.017은 메우지 못했습니다.
다만 마지막 날에 찾은 한 줄은 가져갑니다.
같은 눈으로 같은 것을 보는 모델을 아무리 다르게 최적화해도, 앙상블에는 보탤 것이 없습니다. 보태는 것은 다른 것을 보는 모델이고, 그 모델은 혼자 세울 때 가장 약할 수 있습니다.
제가 몇 주를 쓴 축들이 평지였던 건 그 축들이 하필 운이 없어서가 아니라, 전부 “같은 눈”의 안쪽이었기 때문입니다. 이걸 2주째에 알았다면 다른 대회를 했을 겁니다. 다음에는 평지를 밟기 전에, 지금 재려는 것이 바깥인지 안쪽인지부터 묻겠습니다.