주석은 58건, 판독문은 4,407건 — RSNA Knee 대회의 구조
주석은 58건, 판독문은 4,407건 — RSNA Knee 대회의 구조
RSNA Knee Abnormality Detection은 MRI 검사 한 건마다 확률 열두 개를 요구한다. 3D 의료영상 대회처럼 보이지만 속은 다르다. 훈련 스터디 4,407건 가운데 소견별 주석이 달린 것은 58건뿐이고, 나머지 4,349건에는 아홉 개 언어로 쓰인 판독문만 있다. 영상 모델을 세우기 전에 텍스트 문제부터 풀어야 한다.
이 글에서는 공개 노트북의 파이프라인을 제약에서부터 따라간다. 지표가 무엇을 허용하는지, DICOM 헤더에서 무엇을 되살릴 수 있는지, 샘플링 정리가 무엇을 막는지, 지도 신호를 어디서 얻는지 순서로 다룬다.
English version: RSNA Knee: Twelve Findings From One MRI, and the Reports That Label Them
주요 링크:
1. 지표가 허용하는 것
점수는 라벨 열두 개의 ROC AUC를 그냥 평균한 값이다.
\[\text{Score} \;=\; \frac{1}{12}\sum_{i=0}^{11} \mathrm{AUC}_i .\]여기서 세 가지가 바로 나온다. 셋 다 선택지를 늘리지 않고 줄인다.
순서만 본다. 라벨 $i$의 점수에 단조증가 함수를 아무거나 씌워도 $\mathrm{AUC}_i$는 그대로다. 보정도 임계값도 쓸모가 없다. 모델을 어떻게 합칠지도 여기서 정해진다. 확률을 그대로 평균하면 제일 확신이 센 모델이 판을 먹지만, 순위를 평균하면 지표가 읽는 것만 정확히 모인다. 아래 나오는 결합은 전부 순위 평균이다.
라벨 열두 개의 무게가 같다. 쓸 만한 모델이 도달하는 평균 AUC를 $M$이라 하자. 우연 수준에 머문 라벨 하나는 $M$ 대신 $0.5$를 내놓으므로
\[\frac{M - 0.5}{12}\]만큼을 깎아먹는다. 나머지 열한 개가 아무리 잘해도 소용없다.
$M = 0.85$이면 우연 수준 라벨 하나가 0.029를 가져간다. 리더보드가 무르익으면 인접 순위 간 격차보다 큰 값이다. 그래서 희소 소견에 흔한 소견보다 더 공을 들여야 한다. 모델이 우연 수준으로 주저앉기 제일 쉬운 자리가 희소 소견이다.
유병률이 흔들려도 버티지만 임계값은 못 버틴다. AUC는 기댓값 기준으로 양성 비율을 타지 않는다. 대회는 훈련·공개·최종 세트의 유병률이 같다고 보장하지 않는다고 못 박아뒀다. 정확도류 지표였다면 치명타였겠지만 여기서는 별일 아니다.
2. 지도 신호를 어디서 얻는가
핵심은 설명문이 아니라 스키마에 있다. train.csv에는 Report 열이 있고 test.csv에는 없다. 학습할 때는 텍스트가 있고 예측할 때는 없다. 이 한 줄로 텍스트 분기를 가진 모델은 탈락한다. 추론 시점에 읽을 것이 없으니까. 남는 길은 셋이다.
- 판독문을 학습 타깃으로 바꿔놓고 순수 영상 모델을 돌린다
- 보조 신호로 써서 인코더에 녹여넣고 추론에서는 버린다
- 라벨을 얼마나 확신 있게 읽었는지를 가중치로 삼는다
노트북은 1번과 3번을 쓴다.
2.1 규칙과 언어모델, 어느 쪽이 나은지 가리는 법
규칙 어휘집은 형태를 맞춘다. 그래서 실패해도 틀리는 게 아니라 입을 다문다. 갖고 있지 않은 표현을 만나면 오답 대신 무응답을 낸다. 이 실패는 정답 없이도 잴 수 있다. (판독문, 소견) 쌍마다 “뭐라도 맞았나”만 물으면 되니까. 주석이 필요 없으므로 몇십 건이 아니라 4,407건 전부에서 구해지고, 언어별로 갈라 보면 어휘가 어디서 얇은지까지 짚힌다.
두 척도는 서로 다른 질문에 답한다. 하나로 다른 하나를 대신할 수 없다.
| 무엇을 재나 | 표본 | 무엇을 정할 수 있나 | |
|---|---|---|---|
| 주석과의 일치 | 발화한 규칙이 맞았나 | 58건 | 이 타깃 라벨을 쓸 수 있나 |
| 침묵률 | 규칙이 발화하긴 하나 | 4,407건 | 어느 언어·소견부터 손볼까 |
일치도가 왜 무게를 못 지는지는 계산으로 나온다. 양성 $n_p$개, 음성 $n_n$개일 때 AUC $A$의 Hanley–McNeil 표준오차는 이렇다.
\[\mathrm{SE}(A)=\sqrt{\frac{A(1-A)+(n_p-1)(Q_1-A^{2})+(n_n-1)(Q_2-A^{2})}{n_p\,n_n}}, \qquad Q_1=\frac{A}{2-A},\quad Q_2=\frac{2A^{2}}{1+A}.\]$A \approx 0.8$에 희소 소견 — 몇십 건 중 양성 한 줌 — 을 넣으면 표준오차가 $0.09$ 언저리, 95% 구간이 $\pm 0.17$쯤 된다. 대회는 그보다 한 자릿수 작은 차이로 갈린다. 이 숫자로 어휘집 둘 중 하나를 고르는 건 동전 던지기다. 그런데 매번 신호처럼 보인다.
2.2 판독문은 등급, 주석은 이분법
판독을 쓴 의사와 주석을 단 사람은 기준선이 다르다. 소량의 관절 삼출이라고 적힌 판독문이 음성 주석과 짝지어질 수 있다. 주석자는 유의미하다고 본 삼출만 표시했으니까. 그래서 용어가 있으면 양성 같은 규칙은 애초에 틀렸다. 언급의 세기를 등급으로 매기는 쪽이 맞고, 비용도 안 든다. 어차피 순서만 읽히니까.
2.3 아홉 언어에 어휘집 하나
추출기는 언어를 가리지 않는다. 모든 단서 집합이 아홉 언어를 한꺼번에 담고, 절마다 그 합집합에 대고 검사한다. 언어부터 정하는 방식은 증거를 보기 전에 찍는 것이고, 값싼 찍기는 크게 헛짚는다. 영어는 'the ', 프랑스어는 'la ' 식으로 부분문자열을 보면, la가 스페인어에도 흔해서 먼저 도는 검사가 둘 다 삼킨다.
무엇을 맞추기 전에 정규화부터 한다. 여기서 코드포인트 문제도 같이 해결된다.
1
2
3
4
5
6
7
8
9
10
11
def norm(text):
"""대소문자·분음부호·구분자를 먼저 접는다.
그리스어 판독문은 뮤를 U+03BC 대신 MICRO SIGN U+00B5로 적는 경우가 많다. NFKD가 둘을
같은 글자로 만든다. 터키어의 점 있는 i와 점 없는 ı는 casefold 전에 접어야 한다.
안 그러면 'İLİAK'과 'iliak'이 서로 안 맞는다.
"""
t = text.replace("İ", "i").replace("I", "ı").replace("ı", "i")
t = unicodedata.normalize("NFKD", t)
t = "".join(c for c in t if not unicodedata.combining(c))
return re.sub(r"[_\-/]+", " ", t.casefold())
절을 나눌 때는 제목 줄을 아래 값에 붙여준다. Fractures : 다음 줄에 Aucune.이 오면 두 줄이 한 가지를 말하는 것인데, 떼어놓으면 부정을 양성으로 읽는다.
부정은 예외가 아니다. 여러 소견에서 언급의 대부분이 부정이다. 판독문은 확인해서 멀쩡했던 것을 나열하니까. 명시적 정상 소견도 부정으로 센다. ligamentos cruzados y colaterales dentro de límites normales 는 증거가 없다는 말이 아니라 없다는 증거다. 다만 같은 문장에 파열이나 높은 등급이 함께 나오면 예외로 둔다.
타깃 넷은 해부 단어와 병리 단어가 같이 있어야 한다. 완전한 구절 어휘집이 대부분을 잡지만 형태론 앞에서 무너진다. 터키어는 명사에 소유 접미사를 붙이고 크로아티아어와 그리스어는 격변화를 한다. 구절이 실패하면 어간을 맞추고 측면 수식어가 문자 창 안에 있는지 본다.
1
2
3
4
# 토큰 창이 아니라 문자 창을 쓰는 이유는 어순이다. 영어는 측면 형용사가 명사 앞에,
# 그리스어는 뒤에 온다.
if re.search(rf"{STEM[t]}.{{0,{W}}}{SIDE[s]}|{SIDE[s]}.{{0,{W}}}{STEM[t]}", clause):
...
2.4 판독문 하나를 실제로 돌려보면
아래는 이 글을 위해 코퍼스 문체를 본떠 지어낸 판독문과, 규칙 추출기가 거기서 뽑아낸 결과다. 코퍼스에서 가져온 것이 아니다. 눈여겨볼 열은 conf다. 0.05는 규칙이 하나도 발화하지 않았다는 뜻이다.
1
2
3
4
5
6
7
8
9
10
IRM DU GENOU DROIT
Ligaments : Le ligament croise anterieur presente une rupture complete avec oedeme
associe. Le ligament collateral medial est intact. Le ligament croise posterieur est
normal.
Menisques : Fissure horizontale de la corne posterieure du menisque interne.
Le menisque externe est sans particularite.
Cartilage : Chondropathie femoro-patellaire de grade II.
Autres : Epanchement articulaire de faible abondance. Pas de fracture.
Petit kyste de Baker.
| 타깃 | score | conf | 무슨 일이 있었나 |
|---|---|---|---|
| ACL | 0.84 | 0.70 | rupture complete + 부종 — 단정, 높은 등급 |
| MCL | 0.16 | 0.57 | est intact — 명시적 정상, 부정으로 읽음 |
| Medial Meniscus | 0.84 | 0.70 | fissure 와 menisque interne 를 어간+측면 창으로 잡음 |
| Lateral Meniscus | 0.28 | 0.05 | 놓침 — sans particularite 가 어휘집에 없다 |
| Medial OA | 0.28 | 0.05 | 언급 없음, 침묵이 맞다 |
| Lateral OA | 0.28 | 0.05 | 언급 없음, 침묵이 맞다 |
| PF OA | 0.28 | 0.05 | 놓침 — chondropathie femoro-patellaire de grade II 를 못 맞춤 |
| Effusion | 0.84 | 0.70 | 단정. 다만 faible abondance 로 등급이 안 내려갔다 |
| Synovitis | 0.53 | 0.05 | 언급 없음, 침묵 |
| Baker’s | 0.84 | 0.70 | petit kyste de Baker — 단정 |
| Contusion | 0.28 | 0.05 | 언급 없음, 침묵이 맞다 |
| Fracture | 0.16 | 0.57 | Pas de fracture — 부정 |
열둘 중 둘이 틀렸고, 틀린 방식이 똑같다. 규칙이 발화하지 않았고, 그 출력은 판독문이 아예 언급하지 않은 소견과 구분되지 않는다. 이분법 추출기였다면 그 침묵이 확신에 찬 음성으로 나갔을 것이다. 여기서는 confidence 0.05로 나가고, 그래서 눈에 보인다. 코퍼스 전체의 침묵률이 취향이 아니라 쓸 만한 척도가 되는 이유가 여기 있다.
반대로 틀리게 단정한 것은 하나도 없다. 이 비대칭이 confidence 가중치의 근거다. 침묵한 소견은 말한 소견의 4분의 1 세기로만 출력을 당기므로, 손실 함수가 각 타깃의 주장 강도를 알고 간다.
3. 헤더에서 촬영 조건 되살리기
train_series.csv는 시리즈마다 해부학적 평면과 이진 플래그 Fluid_Sensitive, Fat_Suppression을 준다. 이름만 보면 물리적으로 독립인 두 성질인데, 정작 주어진 열에는 그 독립성이 없다. 훈련 시리즈 전체에서 두 값이 모든 행에서 일치한다. 두 축이 아니라 한 축을 둘이 나눠 갖고 있는 셈이다.
둘 다 DICOM 헤더에서 되살릴 수 있다. 유체 민감도는 대비 가중의 성질이고 반복시간 $T_R$, 에코시간 $T_E$가 정한다.
\[\text{weighting} \;=\; \begin{cases} T_1 & T_R \lesssim 800\ \text{ms}\\ T_2 & T_R \gtrsim 800\ \text{ms},\ T_E \gtrsim 60\ \text{ms}\\ \text{PD} & T_R \gtrsim 800\ \text{ms},\ T_E \lesssim 60\ \text{ms} \end{cases}\]유체는 $T_2$에서 밝고 양성자 밀도에서 중간, $T_1$에서 어둡다. 경사에코는 설계상 $T_R$이 짧아서 이 규칙을 깬다. 그래서 규칙을 보기 전에 ScanningSequence로 먼저 걸러낸다. 지방억제는 어떤 가중 위에도 얹는 준비 과정이고, 골수 부종을 드러내는 게 바로 이것이다. 억제를 안 걸면 밝은 지방 신호가 부종을 덮는다.
기술 문자열을 읽을 때 조심할 데가 둘 있다. 놓치면 답이 소리 없이 뒤집힌다.
1
2
3
4
5
6
7
# 밑줄이 단어 문자라서 `we`(water excitation) 토큰 검사는 `t2_de3d_we_tra` 안에서
# 절대 발화하지 않는다. 구분자부터 공백으로 바꿔야 한다.
desc = re.sub(r"[_\-.]+", " ", raw.lower())
# ScanOptions 는 정확한 토큰으로 맞춰야 한다. 어떤 벤더는 *공간* 포화를 SAT_GEMS 로 적는다.
# SAT 부분문자열로 보면 지방억제가 아닌 시리즈를 억제로 표시해버린다.
fatsat = bool(FATSAT_RX.search(desc)) or bool(set(opts.split("\\")) & FATSAT_OPTS)
3.1 어떤 시퀀스를 보여줄 것인가
무릎은 세 평면으로 읽는다. 구조물이 서로 다른 방향으로 달리기 때문이다. 십자인대는 비스듬해서 시상면이 낫고, 측부인대와 반월판 몸통은 관상면, 슬개 연골과 지지대는 축상면이다. 평면과 촬영 축 둘을 교차시키면 슬롯 여섯이 나온다. 열두 소견 각각이 잘 보이는 시퀀스를 최소 하나씩 갖도록 짰다.
| 슬롯 | 평면 | 가중 | 지방억제 | 무엇을 담나 |
|---|---|---|---|---|
SAG_FLUID_FS | 시상면 | PD / T2 | 예 | 반월판 파열, 골수 부종, 삼출 |
COR_FLUID_FS | 관상면 | PD / T2 | 예 | 측부인대, 반월판 몸통, 부종 |
AX_FLUID_FS | 축상면 | PD / T2 | 예 | 슬개대퇴 관절, 활막, 삼출 |
SAG_FLUID_NOFS | 시상면 | PD / T2 | 아니오 | 대비대잡음비가 높을 때의 반월판 형태 |
COR_T1 | 관상면 | T1 | 아니오 | 골수 구조, 연골과 뼈 윤곽 |
SAG_T1 | 시상면 | T1 | 아니오 | 해부, 만성 변화 |
여섯을 다 갖춘 스터디는 드물다. 슬롯별 존재 마스크가 그 빈자리를 헤드까지 들고 간다.
각 패널은 캐시 가운데 슬라이스를 700개 스터디에 대해 평균한 것이다. 개별 촬영을 되살릴 수 없다. 관상면 평균에서 대퇴 과와 경골 고평부가, 축상면에서 슬개골이, 시상면에서 과간와가 또렷하다. 밑에 깔린 스터디들을 같은 축척과 방향으로 맞춰놓았기에 가능한 일이다. 평균이 선명하다는 것 자체가 정렬이 됐다는 증거다.
4. 슬라이스 순서는 파일 순서가 아니다
시리즈는 파일이 든 디렉터리다. 손이 먼저 가는 방법은 파일명 정렬이고, 그건 틀렸다. 게다가 아무 오류도 안 내면서 틀린다. 파일명은 SOP Instance UID다. 순서가 아니라 유일성을 위해 붙는다.
왼쪽은 시상면 시리즈 하나를 정렬된 목록 순서대로 찍은 것, 오른쪽은 같은 슬라이스를 물리적 좌표로 정렬한 것이다. 시상면 60개 시리즈에서 목록 위치와 물리적 위치의 $|\rho|$ 중앙값은 0.13, 10~90분위가 0.04~0.28이다. 뒤섞은 것과 구별이 안 된다.
이 순서에 기대는 게 셋 있는데, 셋 다 소리 없이 깨진다.
- “인접 3장을 3채널로.” 순서가 뒤죽박죽이면 세 채널이 서로 무관한 단면 셋을 한 장에 겹쳐놓은 것이 된다. 인코더가 보는 건 국소 맥락이 아니라 키메라다
- “스택 가운데를 뽑자.” 뒤죽박죽인 목록의 가운데는 관절의 가운데가 아니라 무작위 표본이다
- 좌우 정규화를 위한 순서 뒤집기. 뒤섞인 목록을 뒤집으면 또 뒤섞인 목록이 나온다. 아무 일도 하지 않는다
참된 순서는 슬라이스마다 붙어 있는 기하 정보로 정확히, 그것도 싸게 되살린다. ImageOrientationPatient가 평면 내 축 $\hat{r}_x, \hat{r}_y$를, ImagePositionPatient가 첫 복셀 위치 $\mathbf{p}$를 주므로
이고 $k$는 스택을 따라 단조증가한다.
1
2
3
4
ds = pydicom.dcmread(path, stop_before_pixels=True, specific_tags=ORDER_TAGS)
iop = np.asarray(ds.ImageOrientationPatient, float)
ipp = np.asarray(ds.ImagePositionPatient, float)
k = float(np.dot(ipp, np.cross(iop[:3], iop[3:])))
$k$는 부호가 있고 환자 좌표계로 표현되니, 스택이 신체 좌우 축을 따라 고정된 방향을 얻는다. §6의 좌우 정규화가 뒤집는 대상이 바로 그 방향이고, 기하 정렬 전에는 아예 없던 성질이다. 기하 태그가 빠졌을 때는 InstanceNumber로 대신한다. 보통 $k$와 부호까지만 맞고, 인터리브나 다중에코 촬영은 공간 순서와 다른 순서로 번호를 매긴다.
이 과정은 선택된 시리즈의 슬라이스마다 헤더를 한 번씩 읽는다. 뒤따르는 픽셀 디코딩보다 파일을 훨씬 많이 연다. 네트워크 마운트에서는 그게 연산이 아니라 지연이라, 정렬 패스만 다른 어느 단계보다 넓은 스레드 풀로 돌린다.
5. 픽셀 하나가 몇 밀리미터까지 허용되나
$N \times N$ 픽셀에 간격 $s$ mm/픽셀인 DICOM 슬라이스는 $Ns$ 밀리미터를 담는다. 이 코퍼스에서 원본 간격은 0.167에서 0.562 mm까지, 3.4배 퍼져 있다. 그런데 이 숫자만으로는 아무 말도 못 한다. 해상도가 높은 촬영일수록 열 수도 그만큼 많기 때문이다. 남는 것은 획득 시야 $Ns$이고, 이쪽은 5분위와 95분위 사이에 140에서 190 mm다.
고정 픽셀 크기로 리사이즈해도 그 차이는 남는다. 전체 영상을 336 px로 줄이면 유효 피치가 0.417에서 0.566 mm/픽셀, 1.36배가 된다. 반월판이 해부학적 이유 없이 어떤 스터디에서는 3분의 1 더 많은 픽셀을 차지한다.
축척을 맞추는 건 절반이다. 나머지 절반은 넘을 수 없는 선이다.
두 픽셀보다 좁은 특징은 리사이즈를 통과하지 못한다. 폭 $d$ 밀리미터짜리 구조를 표현하려면 픽셀 피치가
\[s_{\text{eff}} \;\le\; \frac{d}{2}\]를 만족해야 한다. 리샘플링 격자에 적용한 나이퀴스트 조건이다. 반월판 파열은 1~3 밀리미터이니 $d = 1$ mm 기준으로 피치가 최대 $0.5$ mm여야 한다. 그렇지 못하면 하류에 용량을 아무리 부어도 신호가 안 돌아온다. 첫 합성곱 전에 이미 사라졌기 때문이다. 신경망이 아니라 리사이즈의 성질이다.
일정한 물리적 범위 $L$로 잘라 $P$ 픽셀로 리샘플하면 피치가 고정된다.
\[n \;=\; \Big\lfloor \frac{L}{s} \Big\rceil \ \text{픽셀}, \qquad s_{\text{eff}} \;=\; \frac{L}{P}\ \ \text{mm/픽셀}, \qquad \text{토큰} \;=\; 14\,s_{\text{eff}}\ \ \text{mm}.\]
피치는 비율이라 크롭과 해상도가 정확히 맞바뀐다. 336 px에서 130 mm 크롭은 0.387 mm/픽셀, 100 mm 크롭은 0.298이고 둘 다 1 mm 한계 아래다. 130 mm에 224 px(0.580)는 그렇지 못하다.
크롭은 한 번에 두 가지를 한다. 두 번째를 놓치기 쉽다. 남아 있던 1.36배를 없애는 동시에, 코퍼스 전체를 자르지 않은 어떤 시리즈보다도 고운 피치에 올려놓는다. 0.387 대 최선 0.417이다.
여기서 둘이 따라온다.
크롭은 가장 작은 시야보다 작아야 한다. 아니면 소리 없이 아무것도 안 한다. $L/s$가 이미지 폭을 넘으면 크롭을 못 뜨고 그 시리즈는 정규화 없이 지나간다. 오류 없이, 아무도 안 볼 동안 계속.
1
2
3
4
5
6
7
if px and np.isfinite(px) and px > 0:
want = int(round(CROP_MM / px))
h, w = shp
if 16 < want < min(h, w): # 가정하지 말고 세어야 하는 가드
cy, cx = h // 2, w // 2
half = want // 2
vol = vol[:, max(0, cy - half):cy + half, max(0, cx - half):cx + half]
세기도 같은 이유로 같은 처리가 필요하다. MR에는 절대 척도가 없어 Hounsfield 단위 같은 기준점을 못 잡는다. 시리즈마다 자기 1·99 백분위로 정규화하되, 슬라이스별이 아니라 뽑아둔 스택 전체에 대고 한다. 그래야 슬라이스 사이 상대 대비가 살아남는다. 극값 대신 백분위를 쓰는 이유는 밝은 혈관 하나가 나머지 전부를 눌러버리지 않게 하려는 것이다.
6. 좌우 맞추기
타깃 열둘 중 넷 — 반월판 둘과 내·외측 대퇴경골 구획 — 이 내측/외측 쌍이고, 다섯째인 내측측부인대는 자기가 놓인 쪽 이름을 달고 있다. 내측과 외측은 신체 정중선을 기준으로 정해지니, 영상의 어느 쪽에 오는지는 어느 무릎을 찍었느냐에 달렸다. 이걸 안 맞추면 그 다섯 라벨은 모델이 볼 수 없는 축에서 배우라는 요구를 받는다.
보정 방법이 평면마다 다르다. 관상면과 축상면은 내외측 방향이 영상 평면 안에 있으니 마지막 축을 뒤집으면 한쪽 무릎이 다른 쪽으로 간다. 시상면은 그게 슬라이스 축이다. 슬라이스 하나하나는 거울상으로 안 바뀌고, 달라지는 건 스택이 관절을 훑는 순서다.
1
2
3
4
5
6
def normalise_laterality(img, plane, lat):
if lat != "R":
return img
if plane in ("Coronal", "Axial"):
return torch.flip(img, dims=[-1]) # 평면 내 거울상
return torch.flip(img, dims=[0]) # 시상면: 훑는 방향을 뒤집는다
Laterality는 Type 2C 속성이라 정당하게 빠질 수 있고, 이 코퍼스에서는 절반이 비어 있다. 그것도 산발적인 시리즈가 아니라 벤더 단위로 통째로 없다. 이걸 그냥 두는 건 중립이 아니다. 말없이 좌측이라고 선언하는 것이다.
환자 좌표계가 빠진 태그를 메운다. DICOM 환자 좌표계는 LPS라 $+x$가 환자 왼쪽을 가리키고, 영상 중심의 $x$ 부호가 어느 무릎인지 알려준다.
\[c \;=\; \mathbf{p} \;+\; \mathbf{r}\,\Delta_c \frac{N_c}{2} \;+\; \mathbf{d}\,\Delta_r \frac{N_r}{2}, \qquad \text{side} = \begin{cases} \text{오른쪽} & c_x < 0\\ \text{왼쪽} & c_x > 0\end{cases}\]$\mathbf{p}$는 영상 위치, $\mathbf{r}$과 $\mathbf{d}$는 행·열 방향 코사인, $\Delta$는 픽셀 간격이다. $\mathbf{p}$ 대신 중심을 쓰는 이유는 $\mathbf{p}$가 모서리라 시야 절반만큼 떨어져 있기 때문이다. 정중선 가까이서 찍은 무릎이면 그 차이가 부호를 바꾼다.
정직하게 유지하는 장치가 둘 있다. 하나는 시리즈 하나가 아니라 스터디의 시리즈들에 대한 중앙값을 임계 처리하는 것이다. 헤더는 시리즈당 아무 슬라이스 하나에서 읽는데, 시상면 스택은 관절 전체를 가로지른다. 다른 하나는 중심이 정중선에서 일정 거리 안에 드는 스터디를 찍지 않고 미해결로 두는 것이다. 태그를 가진 스터디에 대고 재보면, 전체적으로는 거의 다 태그와 맞고 그 띠 안에서는 우연 수준과 다르지 않다.
좌우 보정이 평면 내 거울상으로 작동하는 곳은 관상면이라, 효과도 거기서 보인다. 보정 없이 평균하면 왼쪽 무릎이 오른쪽을 상쇄해 내외측 비대칭이 사라진다. 맞춰놓으면 평균 gradient가 0.870에서 0.902로 오른다. 오른쪽은 물리적 크롭이 실제로 없애는 것이다. 원본 간격의 3.4배가 아니다. 그건 행렬 크기가 이미 상쇄한다. 고정 픽셀 리사이즈 뒤에 남는 유효 피치의 1.36배다.
7. 슬롯 여섯을 결정 열둘로
스터디는 슬롯 임베딩 $x_s \in \mathbb{R}^{d}$ 최대 여섯 개와 존재 마스크 $m_s \in {0,1}$로 들어온다. 이걸 똑같이 풀링하면 프로토콜이 세 평면을 갖는 이유를 버린다. 소견마다 읽히는 시퀀스가 따로 있는데, 슬롯 평균은 증거를 담은 하나를 아닌 다섯으로 희석한다.
슬롯을 사영하고 학습되는 슬롯 정체성을 더한 뒤, 진단 $o$마다 질의 $q_o \in \mathbb{R}^{H}$를 따로 줘서 빠진 슬롯을 소프트맥스에서 뺀 채 주의를 기울이게 한다.
\[h_s \;=\; \phi(x_s) + e_s, \qquad \alpha_{o,s} \;=\; \frac{\exp\!\big(\langle h_s, q_o\rangle / \sqrt{H}\big)\, m_s} {\sum_{s'} \exp\!\big(\langle h_{s'}, q_o\rangle / \sqrt{H}\big)\, m_{s'}},\] \[c_o \;=\; \sum_s \alpha_{o,s}\, h_s, \qquad \ell_o \;=\; \langle c_o, w_o \rangle + b_o .\]1
2
3
4
5
6
7
class SlotHead(nn.Module):
def forward(self, x, mask):
h = self.proj(x) + self.slot_emb
att = torch.einsum("bsh,oh->bos", h, self.query) / self.hidden ** 0.5
att = att.masked_fill(mask.unsqueeze(1) < 0.5, -1e4).softmax(-1)
ctx = self.drop(torch.einsum("bos,bsh->boh", att, h))
return (ctx * self.out.weight.unsqueeze(0)).sum(-1) + self.out.bias
마스킹된 소프트맥스는 그 스터디가 실제로 가진 것들에 대해서만 다시 정규화한다. 축상면이 없는 스터디에서는 진단의 주의가 영벡터로 새는 대신 있는 시퀀스 쪽으로 옮겨간다.
헤드를 이만큼 작게 둔 건 일부러다. 더 풍부한 집계도 생각할 수 있다. 슬라이스 그룹마다 주의를 주거나 평균 대신 최댓값을 쓰거나. 그런데 값어치가 없을 구조적 이유가 있다. 라벨이 스터디에 붙어 있어서, 스터디의 어느 부분이 그 소견을 담는지 알려주는 신호가 없다. 지도가 거친 자리에서는 집계도 거칠어야 한다.
7.1 인코더를 얼리지 않는 이유
얼린 자기지도 인코더는 하류에서 어떻게 해도 못 넘는 데서 막힌다. 해상도, 인코더 크기, 슬라이스 커버리지, 슬롯 집계는 모델이 얼마나 많이·얼마나 자세히 보는지는 바꾸지만 무엇으로 보는지는 못 바꾼다. 그래서 그 축들이 전부 같은 천장에 부딪힌다. 게다가 그 천장이 여기서 실제로 걸릴 이유가 있다. 인코더는 자연 영상에서 특징을 배웠고, 거기에는 양성자 밀도 영상에서 파열된 반월판이 내는 신호와 닮은 게 없다.
그래서 인코더를 손보되 둘로 묶는다. 마지막 블록만 움직인다. 비전 트랜스포머의 초기 블록은 일반적인 경계·질감 필터고 의미는 후기 블록에 산다. 여기 있는 지도 신호로 초기 블록을 개선하기엔 모자랄 수 있지만 망가뜨리기엔 충분하다. 인코더는 헤드보다 훨씬 천천히 배운다. 헤드는 무작위에서 시작해 전부를 배워야 하고, 인코더는 좋은 해에서 시작해 조금만 움직이면 된다. 두 파라미터 그룹의 학습률이 두 자릿수 차이 나는 이유다.
8. 한 번 읽고 여러 번 학습한다
이 파이프라인의 비용은 연산이 아니라 읽기가 잡아먹는다. 스터디 하나에 시리즈가 여럿, 시리즈마다 수십 장이니 대략 파일 백오십 개다. 한 번은 감당한다. 에포크마다는 못 한다. 그런데 미세조정은 매 에포크 같은 픽셀을 요구한다. 그래서 슬롯 영상을 한 번만 디코딩해 메모리에 uint8로 들고 있는다.
슬롯당 $S$장을 $P$ 픽셀로 둘 때의 크기다. $P$의 지수가 이걸 세부사항이 아니라 진짜 제약으로 만든다. 캐시는 해상도의 제곱으로, 슬라이스 수에는 선형으로 커진다. 커버리지가 싼 축이고 해상도가 비싼 축이다. 4,407 스터디 × 6슬롯 × 12장 × 336픽셀이면 33 GB, 448픽셀이면 59 GB다.
작동 설정을 정하는 건 용량이 아니라 예산이다. 기계가 알려주는 여유 메모리의 일부만 캐시에 내주는데, 그것도 전부보다 일부러 낮게 잡는다. 캐시는 넘치면 느려지는 게 아니라 실행을 끝내버릴 만큼 큰 유일한 할당이고, 인코더와 활성값과 이동 중인 버퍼가 같은 풀에서 나온다.
9. 가중치는 데이터셋이고, 채점되는 실행은 예측만 한다
채점되는 실행이 가중치를 학습한 실행일 필요는 없다. 노트북은 데이터셋을 붙일 수 있고 가중치는 데이터셋이다. 미리 못 하는 부분은 아무도 못 본 스터디에 달린 부분 — 그걸 읽고 예측하는 일 — 뿐이다.
채점되는 노트북 안에서 학습하면 두 번 낸다. 모델을 가속기 하나가 시간 제한 안에 담을 크기로 묶어두고, 제출할 때마다 그 시간을 또 쓴다. 결과는 매번 같은데.
패키지는 모델이 아니라 멤버 목록이다. 멤버마다 자기 가중치, 자기 전처리 설정, 자기 지문을 갖는다. 필요한 픽셀별로 묶여 묶음마다 한 번 디코딩되고, 나중에 붙은 멤버는 아무것도 안 바꾸고 목록에 들어온다.
멤버는 자기가 그때 그 모델임을 증명해야 한다. state dictionary는 모양만 맞으면 들어가고, 모양은 문제가 되는 모든 차이에 대해 맞는다. 정규화가 바뀌어도, 리사이즈가 바뀌어도, 슬라이스 대역이 바뀌어도. 아무것도 예외를 안 던진다. 그래서 멤버마다 시드로 만든 질문에 자기가 내놨던 답을 들고 다니고, 쓰기 전에 그 답을 다시 계산한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def fingerprint(model, dev, img_size, n_slot=None, group=None, seed=None):
"""고정된 합성 입력에 대한 출력. 옮겨 다닐 수 있는 정체성.
입력을 읽지 않고 시드로 만드니 어느 기계에서도 같고, 전체 순전파를 통과한다.
바이트 스케일링, ImageNet 정규화, 리사이즈, 인코더, 슬롯 주의까지. 이 중 하나만
달라도 출력이 1 규모로 움직인다. 두 GPU 사이 수치 차이는 1e-5 정도라, 허용오차를
0이 아니라 그 사이에 둔다.
"""
g = torch.Generator().manual_seed(seed)
imgs = torch.randint(0, 256, (2, n_slot, group, img_size, img_size),
generator=g, dtype=torch.uint8).to(dev)
mask = torch.ones(2, n_slot, device=dev)
mask[1, -1] = 0.0 # 소프트맥스의 마스킹 분기를 실제로 태운다
with torch.no_grad():
return model(imgs, mask, img_size).float().cpu().numpy()
멤버를 어떻게 읽나. 학습은 매 스텝 연속된 슬라이스 한 묶음을 뽑고, 그게 스택 방향 증강을 겸한다. 추론에서는 한 번만 볼 이유가 없고, 더 봐도 디코딩 비용이 안 든다. 캐시가 이미 $S$장을 갖고 있으니 가운데 한 묶음은 순전파 $1$회, 안 겹치는 묶음들은 $S/G$회, $G$장짜리 연속 구간 전부는 $S-G+1$회다. 평균을 어디서 취하느냐는 비용이 같지만 중립은 아니다. 로짓을 평균하고 씌우면 승산의 기하평균, 확률을 평균하면 위험의 산술평균이고, 둘은 스터디를 다르게 줄 세운다.
10. 스스로를 속이지 않고 검증하기
이 설정에만 있는 누출이 둘이다. 둘 다 아무것도 개선 안 하면서 검증 수치만 부풀린다.
같은 판독문. 어떤 판독문들은 스터디가 달라도 바이트까지 똑같다. 특이소견 없는 무릎에 쓰는 정형 판독이다. 그런 묶음의 스터디들은 같은 파생 타깃을 받는다. 그 묶음을 학습·검증 양쪽으로 쪼개면 모델은 이미 학습한 출처에서 나온 타깃으로 채점된다. 그래서 판독문 텍스트 해시로 배정한다. 중복 묶음이 통째로 유지된다.
1
2
grp = np.array([int(hashlib.md5(rep.get(s, s).encode()).hexdigest()[:8], 16) % N_FOLDS
for s in studies])
결정론적이고 시드가 없다. 판독문으로 묶는 게 암묵적이면서 정확하다. 같은 바이트는 같은 다이제스트를 주고 따라서 같은 폴드로 간다.
기준 둘, 의미 둘. 홀드아웃은 코퍼스의 5분의 1을 덮고 파생 타깃과의 일치를 잰다. 라벨당 스터디가 넉넉해서 실제 차이를 잡음과 가른다. 한 실행 안에서 어느 에포크를, 실행들 사이에서 어느 설정을 쓸지 이쪽이 정한다. 주석 검사는 의사가 영상을 읽은 결과와의 일치를 재고, 대회가 채점하는 게 그거다. 하지만 홀드아웃에 들어온 주석 스터디만 쓸 수 있고 그 수가 아주 적다. 그래서 보고만 하고 결정권은 안 준다. §2.1의 표준오차 논증대로, 한 줌의 스터디는 에포크 간 격차보다 훨씬 넓은 구간을 준다.
주석이 붙은 스터디는 가중치를 올려 학습에 남긴다. 코퍼스에서 텍스트가 아니라 영상에서 읽어낸 유일한 라벨이니까. 바로 그래서 주석 검사를 홀드아웃으로 제한해야 한다. 정답을 이미 봤고 다른 무엇보다 무겁게 가중된 학습 예제로 채점하는 건, 암기를 재놓고 실력이라 보고하는 일이다.
11. 구조가 말해주는 것
이 파이프라인은 취향이 고른 선택이 아니라 데이터가 강제한 결정들의 연쇄다.
| 결정 | 무엇이 강제했나 |
|---|---|
| 보정 없이 순위 평균 | AUC가 단조증가 사상에 불변 |
| 텍스트 분기 없음 | Report가 train에 있고 test에 없음 |
| 언급을 등급화, 이분화 금지 | 주석자 기준선이 판독의보다 엄격 |
| 촬영 축 둘을 헤더에서 되살림 | 주어진 플래그가 모든 행에서 일치 |
| $\mathbf{p}\cdot\hat{n}$으로 슬라이스 정렬 | 파일명은 식별자, $|\rho| \approx 0.13$ |
| 일정한 물리적 범위로 크롭 | 리사이즈 뒤에도 유효 피치가 1.36배 |
| 130 mm 크롭에서 $\ge 336$ px | 1 mm 파열에 대한 나이퀴스트 |
| LPS 기하로 좌우 되살림 | 태그가 코퍼스 절반에 없음 |
| 슬롯에 대한 진단별 주의 | 소견마다 읽히는 시퀀스가 다름 |
| 판독문 해시로 분할 | 같은 판독문이 같은 타깃을 줌 |
열린 문제는 이 중 하나가 아니다. 주석 58건으로는 라벨 표 둘 사이를 가릴 수 없다는 것이다. 차이에 대한 구간이 차이보다 넓다. 그런데 그 파생 라벨이 바로 영상 모델이 학습하는 대상이다. 이 문제의 천장은 거기 있고, 신경망이 아니라 지도 신호의 성질이다.
노트북은 공개돼 있다: RSNA Knee baseline v1. 이 글의 그림은 전부 대회 코퍼스에서 계산했고, 영상은 수백 건을 평균한 것이라 개별 촬영을 되살릴 수 없다.
