ResNet 기반 망막증 분류 #5 평가 지표와 전체 파이프라인 #
#2026-02-27
모델이 학습을 마쳤다. 512×512 망막 사진을 넣으면 5개 등급에 대한 확률을 내놓는다. 이제 가장 중요한 질문이 남았다. 이 모델이 실제로 쓸 만한가? 이걸 판단하려면 적절한 평가 지표가 필요하다.
이 프로젝트에서는 세 가지 평가 도구를 쓴다. 단순 정확도, 이차 가중 카파, 그리고 혼동행렬이다. 각각이 모델의 다른 측면을 보여주는데, 특히 두 번째 지표가 이 문제의 핵심을 찌른다.
#
#1 단순 정확도(DRAccuracy)
def DRAccuracy(y, y_pred):
y = np.argmax(y, 1) # one-hot → 클래스 인덱스
y_pred = np.argmax(y_pred, 1) # 가장 높은 확률 클래스
return accuracy_score(y, y_pred)
가장 직관적인 지표로 전체 이미지 중 몇 퍼센트를 정확히 맞혔는가이다. 모델의 예측 확률 분포에서 가장 높은 확률의 등급을 뽑고, 정답과 비교해서 일치하면 맞은 것, 아니면 틀린 것으로 센다. 코드를 보면 np.argmax(y, 1)과 np.argmax(y_pred, 1)이 나온다. argmax는 배열에서 가장 큰 값의 위치(인덱스)를 돌려주는 함수다. 정답이 원-핫 벡터 [0, 0, 1, 0, 0]이면 argmax는 2를 돌려준다. 예측이 [0.06, 0.68, 0.12, 0.03, 0.11]이면 argmax는 1을 돌려준다. 정답 2, 예측 1이니까 이건 틀린 것이다.
단순하고 이해하기 쉽지만, 앞서 이야기했듯이 이 문제에서는 심각한 한계가 있는데 정확도는 등급 0을 등급 1로 틀린 것과 등급 0을 등급 4로 틀린 것을 동일하게 취급한다. 한 단계 차이든 네 단계 차이든 그냥 “하나 틀림"이다. 의학적으로 이 두 실수의 무게는 완전히 다른데도 말이다. 게다가 데이터가 불균형하기 때문에, 모든 이미지에 “등급 0"이라고 찍어버리는 멍청한 모델도 정확도가 70%를 넘을 수 있다. 그래서 두 번째 지표가 필요하다.
#
#2 이차 가중 카파(Quadratic Weighted Kappa)
이차 가중 카파는 “틀린 정도"에 비례해서 벌점을 매기되, 랜덤하게 찍는 것보다 얼마나 나은지를 측정하는 것이다.
이것이 필요한 이유:
등급 0을 1로 예측: 한 단계 차이 → 작은 오류
등급 0을 4로 예측: 네 단계 차이 → 큰 오류
단순 정확도: 둘 다 동일하게 "틀림" 처리
QuadWeightedKappa: 오류의 크기(거리)에 비례한 패널티
def QuadWeightedKappa(y, y_pred):
y = np.argmax(y, 1)
y_pred = np.argmax(y_pred, 1)
cm = confusion_matrix(y, y_pred) # 5×5 혼동행렬
classes_y, counts_y = np.unique(y, return_counts=True)
classes_y_pred, counts_y_pred = np.unique(y_pred, return_counts=True)
E = np.zeros((5, 5))
for i, c1 in enumerate(classes_y):
for j, c2 in enumerate(classes_y_pred):
E[c1, c2] = counts_y[i] * counts_y_pred[j]
E = E / np.sum(E) * np.sum(cm)
첫 번째 단계는 혼동행렬(confusion matrix)을 만드는 것이다. 5×5 크기의 표인데, 행이 실제 등급이고 열이 예측 등급이다. cm[2][3]의 값이 60이면 “실제 등급 2인 이미지를 등급 3으로 잘못 예측한 게 60개"라는 뜻이다. 대각선 위의 값은 정확히 맞춘 것이고, 대각선에서 벗어난 값은 틀린 것이다. 대각선에서 멀리 벗어날수록 더 크게 틀린 것이다.
def ConfusionMatrix(y, y_pred):
y = np.argmax(y, 1)
y_pred = np.argmax(y_pred, 1)
return confusion_matrix(y, y_pred)
두 번째 단계는 기대 혼동행렬(E)을 만드는 것이다. 이건 “모델이 아무 생각 없이 랜덤하게 예측했다면 어떤 혼동행렬이 나왔을까?“를 계산한 것이다. 계산 방식은 각 칸에 해당하는 행의 실제 빈도와 열의 예측 빈도를 곱하는 것이다. 예를 들어 실제 등급 0이 25,000개이고 모델이 등급 1로 예측한 게 총 3,000개라면, 랜덤 상황에서 실제 0이 예측 1에 걸릴 기대값은 이 둘의 곱에 비례한다. 이 기대 혼동행렬은 “아무런 실력 없이 찍기만 한 분류기의 기준선"을 제공한다.
w = np.zeros((5, 5))
for i in range(5):
for j in range(5):
w[i, j] = float((i - j)**2) / (5 - 1)**2
세 번째 단계가 이 지표의 핵심인데, 가중치 행렬(w)을 만드는 것이다. w[i][j] = (i-j)² / (5-1)²이다. 이걸 전부 계산하면 5×5 표가 나오는데, 대각선은 전부 0이다. 정확히 맞추면 벌점이 없다는 뜻이다. 대각선에서 한 칸 벗어나면 0.0625, 두 칸이면 0.25, 세 칸이면 0.5625, 네 칸(최대)이면 1.0이다. 여기서 “이차(Quadratic)“라는 이름의 의미가 드러난다. 벌점이 거리의 제곱에 비례한다. 한 단계 차이의 벌점이 0.0625인데, 두 단계 차이는 0.25로 4배다. 세 단계 차이는 0.5625으로 9배다. 네 단계 차이는 1.0으로 16배다. 거리가 두 배면 벌점이 네 배가 되는 것이다. 이 제곱 관계 때문에 큰 오류에 불균형적으로 큰 페널티가 부과된다.
이차(Quadratic) 가중치 w[i,j] = (i-j)² / (5-1)²:
예측 0 예측 1 예측 2 예측 3 예측 4
실제0 0.000 0.0625 0.250 0.5625 1.000
실제1 0.0625 0.000 0.0625 0.250 0.5625
실제2 0.250 0.0625 0.000 0.0625 0.250
실제3 0.5625 0.250 0.0625 0.000 0.0625
실제4 1.000 0.5625 0.250 0.0625 0.000
대각선(정확) = 0, 거리²에 비례해서 증가
이것이 의학적으로 정확히 맞는 설계다. 등급 0(정상)을 등급 4(증식성)로 오진하는 것은 등급 0을 등급 1(경증)로 오진하는 것보다 16배 더 심각한 실수로 취급되어야 한다. 단순 정확도로는 이 차이를 전혀 반영할 수 없지만, 이차 가중 카파는 정확히 이 차이를 포착한다.
re = 1 - np.sum(w * cm) / np.sum(w * E)
return re
네 번째 단계에서 최종 카파 값을 계산한다. 실제 혼동행렬(cm)에 가중치(w)를 원소별로 곱해서 합산하면 “모델의 가중 오류 총합"이 나온다. 기대 혼동행렬(E)에 같은 가중치를 곱해서 합산하면 “랜덤 분류기의 가중 오류 총합"이 나온다. 카파는 1 - (모델의 가중 오류) / (랜덤의 가중 오류)다.
κ = 1 - (가중 오류합) / (기대 가중 오류합)
- κ = 1.0: 완벽한 예측
- κ = 0.0: 랜덤 분류기 수준
- κ < 0: 랜덤보다도 나쁨
이 공식의 의미를 풀어보면 이렇다. 모델의 가중 오류가 랜덤 분류기의 가중 오류와 같으면, 분수가 1이 되어 카파는 0이다. 모델이 랜덤하게 찍는 것과 다를 바 없다는 뜻이다. 모델의 가중 오류가 0이면(완벽한 예측) 분수가 0이 되어 카파는 1이다. 최고 점수다. 모델이 랜덤보다도 더 체계적으로 틀리면 카파가 음수가 될 수도 있다. 이건 모델이 차라리 주사위를 굴리는 것만도 못하다는 비참한 결과다.
예시 혼동행렬 (행=실제, 열=예측):
예측0 예측1 예측2 예측3 예측4
실제0 [ 5000, 200, 30, 5, 2 ]
실제1 [ 180, 300, 40, 10, 3 ]
실제2 [ 50, 80, 500, 60, 10 ]
실제3 [ 10, 20, 70, 150, 20 ]
실제4 [ 5, 10, 30, 40, 200 ]
혼동행렬 자체는 지표라기보다 진단 도구에 가깝다. 5×5 표를 직접 들여다보면 모델이 어디서 잘하고 어디서 못하는지가 한눈에 보인다. 예를 들어 혼동행렬에서 실제 등급 3인 행을 보면 [10, 20, 70, 150, 20]이라고 하자. 정확히 맞춘 게 150개, 등급 2로 틀린 게 70개, 등급 1로 틀린 게 20개다. 이걸 보면 “모델이 등급 3과 등급 2를 잘 구분하지 못하는구나"라는 구체적인 약점을 파악할 수 있다. 정확도나 카파 같은 단일 숫자만으로는 이런 세밀한 정보를 얻을 수 없다.
의료 현장에서는 이 혼동행렬이 특히 중요하다. 등급 4를 등급 0으로 오분류하는 건(심각한 환자를 정상이라고 보내는 건) 절대 있어서는 안 되는 일이다. 혼동행렬을 보면 이런 위험한 오분류가 실제로 얼마나 발생하는지를 직접 확인할 수 있다.
#
#cf 혼동행렬과 기대혼동행렬 예시
총 1,000장의 이미지가 있고, 실제 분포가 이렇다고 가정.
실제 등급 0: 500장
실제 등급 1: 150장
실제 등급 2: 200장
실제 등급 3: 80장
실제 등급 4: 70장
혼동행렬 (Confusion Matrix, cm)은 모델이 실제로 예측한 결과를 집계한 것. 행이 실제 등급, 열이 예측 등급.
예측 0 예측 1 예측 2 예측 3 예측 4 │ 행합(실제 수)
실제 0 [ 420, 50, 20, 8, 2 ] │ 500
실제 1 [ 30, 80, 25, 10, 5 ] │ 150
실제 2 [ 15, 20, 130, 25, 10 ] │ 200
실제 3 [ 5, 10, 20, 35, 10 ] │ 80
실제 4 [ 3, 5, 12, 15, 35 ] │ 70
───────────────────────────────────────────────
열합(예측 수) 473 165 207 93 62 │ 1000
대각선이 정답이다. 등급 0은 420/500 = 84%를 맞혔으니 꽤 잘한다. 등급 3은 35/80 = 44%밖에 못 맞히고 있다. 등급 4도 35/70 = 50%다. 희귀한 중증 등급에서 성능이 확 떨어지는 전형적인 패턴이다.
혼동행렬에서 대각선 밖의 숫자를 읽는 법도 알아두자. 실제 등급 2 행에서 예측 3 열의 값이 25다. 이건 “실제로 중등도(2)인 이미지 25장을 중증(3)으로 과대 진단했다"는 뜻이다. 실제 등급 4 행에서 예측 0 열의 값이 3이다. “증식성 망막증(4) 환자 3명을 정상(0)이라고 돌려보냈다"는 뜻이니 의학적으로 가장 위험한 오분류다.
기대 혼동행렬은 “모델이 아무런 실력 없이 랜덤하게 예측했다면 나왔을 혼동행렬”. 계산 방식은 아래와 같다
E[i,j] = (실제 i의 수) × (예측 j의 수) / 전체
예: E[0,0] = 500 × 473 / 1000 = 236.5
E[0,1] = 500 × 165 / 1000 = 82.5
E[3,4] = 80 × 62 / 1000 = 4.96
계산하며 아래와 같이 나옴.
예측 0 예측 1 예측 2 예측 3 예측 4 │ 행합
실제 0 [ 236.50, 82.50, 103.50, 46.50, 31.00 ] │ 500
실제 1 [ 70.95, 24.75, 31.05, 13.95, 9.30 ] │ 150
실제 2 [ 94.60, 33.00, 41.40, 18.60, 12.40 ] │ 200
실제 3 [ 37.84, 13.20, 16.56, 7.44, 4.96 ] │ 80
실제 4 [ 33.11, 11.55, 14.49, 6.51, 4.34 ] │ 70
───────────────────────────────────────────────────────
열합 473 165 207 93 62 │ 1000
E[0,0] = 236.5는 “실제 등급 0이 500장이고 모델이 등급 0으로 예측한 게 총 473장이면, 아무 연관 없이 랜덤하게 매칭했을 때 둘이 우연히 겹치는 수가 약 237장"이라는 뜻이다. 실제 혼동행렬의 cm[0,0] = 420은 이보다 훨씬 높으니, 모델이 등급 0에 대해서는 랜덤보다 확실히 잘하고 있는 것이다.
반면 E[4,0] = 33.11인데 cm[4,0] = 3이다. 랜덤하게 찍었으면 33장이나 실제 4를 예측 0으로 틀렸을 텐데, 실제 모델은 3장밖에 안 틀렸다. 이 위험한 오분류에서도 모델이 랜덤보다 훨씬 낫다는 의미다.
카파를 계산할 때 이 세 행렬이 이렇게 결합된다.
가중치 행렬 w (오류의 심각도):
예측 0 예측 1 예측 2 예측 3 예측 4
실제0 0.0000 0.0625 0.2500 0.5625 1.0000
실제1 0.0625 0.0000 0.0625 0.2500 0.5625
실제2 0.2500 0.0625 0.0000 0.0625 0.2500
실제3 0.5625 0.2500 0.0625 0.0000 0.0625
실제4 1.0000 0.5625 0.2500 0.0625 0.0000
혼동행렬 cm (모델의 실제 오류):
실제0 [ 420, 50, 20, 8, 2 ]
실제1 [ 30, 80, 25, 10, 5 ]
실제2 [ 15, 20, 130, 25, 10 ]
실제3 [ 5, 10, 20, 35, 10 ]
실제4 [ 3, 5, 12, 15, 35 ]
기대행렬 E (랜덤의 기대 오류):
실제0 [ 236.50, 82.50, 103.50, 46.50, 31.00 ]
실제1 [ 70.95, 24.75, 31.05, 13.95, 9.30 ]
실제2 [ 94.60, 33.00, 41.40, 18.60, 12.40 ]
실제3 [ 37.84, 13.20, 16.56, 7.44, 4.96 ]
실제4 [ 33.11, 11.55, 14.49, 6.51, 4.34 ]
카파 계산의 핵심은 w × cm의 각 원소를 다 더한 것(모델의 가중 오류 총합)과 w × E의 각 원소를 다 더한 것(랜덤의 가중 오류 총합)을 비교하는 것이다.
예를 들어 cm[4,0] = 3에 w[4,0] = 1.0이 곱해지면 3.0이 된다. 가장 심각한 오류(증식성을 정상으로)이니 가중치가 최대다. cm[0,1] = 50에 w[0,1] = 0.0625가 곱해지면 3.125가 된다. 50건이나 틀렸지만 한 단계 차이라 가중치가 작아서 벌점이 비슷하다. 이렇게 모든 칸을 계산해서 합산하면 모델의 가중 오류 총합이 나오고, 같은 방식으로 E에 대해 계산하면 랜덤의 가중 오류 총합이 나온다.
κ = 1 - (모델의 가중 오류) / (랜덤의 가중 오류)이므로, 모델이 랜덤보다 가중 오류를 많이 줄일수록 카파가 1에 가까워진다.
#
#3 전체 파이프라인 실행
train, valid, test = load_images_DR(split='random', seed=123)
model = DRModel(
n_init_kernel=32,
batch_size=32,
learning_rate=1e-5,
augment=True,
model_dir='./test_model')
load_images_DR로 데이터를 훈련, 검증, 테스트 세 세트로 나눈다. split=‘random’이고 seed=123인데, 랜덤하게 나누되 시드를 고정해서 실험을 재현할 수 있게 한다. 같은 시드를 쓰면 같은 분할이 나오므로, 나중에 다른 사람이 동일한 실험을 반복할 수 있다.
모델을 만들 때 learning_rate=1e-5, 즉 0.00001이라는 매우 작은 학습률을 쓴다. 학습률은 한 번의 가중치 업데이트에서 얼마나 크게 바꿀지를 결정하는데, 의료 이미지 분류에서는 작은 학습률이 중요하다. 망막의 출혈 반점은 몇 픽셀 차이로 보이고 안 보이고가 갈리는 미세한 특징이다. 학습률이 크면 가중치가 한 번에 너무 많이 바뀌면서 이런 미세한 패턴을 잡았다가 다음 업데이트에서 놓쳐버리는 진동이 생길 수 있다. 작은 학습률로 조심스럽게 한 발짝씩 움직이는 게 안전하다.
if not RETRAIN:
os.system("sh get_pretrained_model.sh")
model.restore(checkpoint="./test_model/model-84384")
사전학습 모델을 불러오는 부분은 84,384번의 학습 스텝을 이미 거친 모델의 가중치를 다운로드해서 이어서 학습하는 것이다. 수만 장의 고해상도 이미지를 처음부터 학습하려면 며칠이 걸릴 수 있으니, 이미 상당 부분 학습된 모델에서 시작하면 시간을 크게 절약할 수 있다. checkpoint_interval=1000은 1,000 스텝마다 모델 상태를 저장하라는 뜻인데, 학습 중간에 서버가 꺼지거나 문제가 생겨도 마지막 체크포인트에서 이어서 할 수 있게 하는 안전장치다.
metrics = [
dc.metrics.Metric(DRAccuracy, mode='classification'),
dc.metrics.Metric(QuadWeightedKappa, mode='classification')
]
cm = [dc.metrics.Metric(ConfusionMatrix, mode='classification')]
model.fit(train, nb_epoch=10, checkpoint_interval=1000)
print(model.evaluate(train, metrics, n_classes=5))
print(model.evaluate(valid, metrics, n_classes=5))
print(model.evaluate(valid, cm, n_classes=5))
print(model.evaluate(test, metrics, n_classes=5))
print(model.evaluate(test, cm, n_classes=5))
평가는 세 단계로 이루어진다. 먼저 훈련 세트에서 평가한다. 이건 모델이 본 데이터에서의 성능이니까 당연히 높아야 한다. 만약 훈련 세트 성능도 낮다면 모델의 용량이 부족하거나 학습이 덜 된 것이다. 그 다음 검증 세트에서 평가한다. 검증 세트는 학습에 쓰이지 않은 데이터인데, 하이퍼파라미터(학습률, 필터 수, 레이어 수 등)를 조정할 때 기준이 된다. 훈련 성능은 높은데 검증 성능이 낮으면 과적합이 진행 중이라는 신호다. 마지막으로 테스트 세트에서 평가한다. 테스트 세트는 모든 결정이 끝난 후에 딱 한 번 성능을 재는 데 쓴다. 이것이 “이 모델의 진짜 실력"을 가장 공정하게 보여주는 숫자다.
검증 세트와 테스트 세트에서는 혼동행렬도 함께 출력한다. 숫자 하나(정확도, 카파)만으로는 모델의 구체적인 행동 패턴을 알 수 없으니, 혼동행렬로 “어떤 등급 쌍에서 오분류가 집중되는가"를 확인해서 모델의 약점을 진단하는 것이다.
#
#4 정리
이 프로젝트의 평가 체계는 세 가지 도구가 서로 다른 깊이의 정보를 제공하도록 설계되어 있다. 단순 정확도는 전체적인 맞춤 비율이라는 가장 기본적인 스냅샷을 준다. 이차 가중 카파는 오류의 심각도를 반영한 종합 점수로, 이 문제의 순서형 특성을 정확히 포착한다. 혼동행렬은 모델이 어디서 잘하고 어디서 실패하는지의 상세한 지도를 펼쳐 보여준다. 이 세 도구를 함께 봐야 모델의 성능을 입체적으로 이해할 수 있다.