머신러닝·신경망

DL / 17번째 글

분류 성능 읽기: 혼동 행렬에서 F1·MCC까지

혼동 행렬 네 칸에서 정확도·정밀도·재현율·F1·MCC가 어떻게 나오는지, 임계값이 그 네 칸을 어떻게 옮기는지, 그리고 다중 클래스와 불균형 데이터에서 무엇을 봐야 하는지 한 편에서 정리한다.

PALDYN Team55 MIN READ

분류 모델의 성능을 「정확도 93%」라는 숫자 하나로 말하는 것은 대개 아무 말도 하지 않은 것과 같다. 양성 샘플이 전체의 3%인 사기 탐지 시스템에서는 모든 거래를 정상이라고 답하기만 해도 정확도 97%가 나온다. 그 모델은 사기를 한 건도 잡지 못하지만 숫자는 자랑스러워 보인다.

문제는 지표를 잘못 골랐다는 것이 아니라, 지표가 어디서 나오는지를 보지 않았다는 것이다. 이진 분류의 평가 지표는 전부 혼동 행렬(Confusion Matrix) — 예측과 정답을 맞춰 센 작은 표 — 의 네 칸에서 유도된다. 정확도도 재현율도 F1도 MCC도 같은 네 숫자를 다르게 나눈 값이다. 그래서 지표를 고르는 일은 사실 네 칸 중 무엇을 무겁게 볼지 고르는 일이고, 행렬을 직접 읽을 수 있으면 그 선택이 취향이 아니라 계산이 된다.

이 글은 그 네 칸에서 출발해 지표를 하나씩 유도하고, 임계값이 칸의 숫자를 어떻게 옮기는지 따라가고, 다중 클래스와 극단적 불균형까지 넓힌다.

혼동 행렬

TP·FP·TN·FN

이진 분류의 결과는 예측이 맞았는지와 무엇이라 예측했는지, 두 갈래로 갈리므로 경우의 수가 넷이다.

  • TP(True Positive): 실제 양성, 예측 양성 — 맞음
  • FP(False Positive): 실제 음성, 예측 양성 — 틀림 (1종 오류)
  • TN(True Negative): 실제 음성, 예측 음성 — 맞음
  • FN(False Negative): 실제 양성, 예측 음성 — 틀림 (2종 오류)

이름을 외우려 애쓸 필요는 없다. 규칙이 있다. 앞 글자 T/F는 예측이 맞았는지를, 뒷 글자 P/N은 모델이 무엇이라 답했는지를 가리킨다. FN은 「모델이 음성(N)이라 답했는데 그게 틀렸다(F)」는 뜻이므로 실제로는 양성이다. 헷갈릴 때마다 뒷 글자부터 읽으면 항상 풀린다.

1종 오류는 아무 일도 없는데 있다고 경보를 울린 것이고, 2종 오류는 일이 벌어졌는데 놓친 것이다. 두 오류의 값이 다르다는 사실이 이 글 전체의 출발점이다. 스팸 필터에서 FP는 중요한 메일이 스팸함으로 사라지는 일이고, 암 검진에서 FN은 환자를 집으로 돌려보내는 일이다. 같은 「한 건 틀림」이 아니다.

여기서 양성(Positive)이라는 말이 「좋은 것」을 뜻하지 않는다는 점을 짚어 둔다. 양성은 우리가 찾으려는 쪽, 곧 관심 사건이 일어난 쪽을 부르는 이름일 뿐이다. 사기 탐지에서는 사기가 양성이고 암 검진에서는 종양이 양성이다. 네 칸의 이름이 전부 이 「양성이 무엇인가」에 딸려 있으므로, 데이터의 라벨 0과 1 중 어느 쪽을 양성으로 두었는지를 먼저 확인하지 않으면 이후의 모든 숫자가 뒤집힌다.

행과 열의 방향

혼동 행렬은 이 네 숫자를 2×2로 배치한 표다. 행은 실제 클래스, 열은 예측 클래스다. 그래서 한 행을 가로로 읽으면 「실제로 양성인 것들이 어디로 흩어졌는가」가 보이고, 한 열을 세로로 읽으면 「양성이라 예측한 것들이 원래 무엇이었는가」가 보인다. 재현율은 행에서, 정밀도는 열에서 나온다는 사실을 이 방향 하나로 기억할 수 있다.

혼동 행렬 구조와 해석

여기서 실무자를 가장 자주 넘어뜨리는 지점이 하나 있다. sklearn의 confusion_matrix가 돌려주는 배열은 음성(0)이 첫 번째 인덱스라 [[TN, FP], [FN, TP]] 순서로 나온다. 그림이나 논문에서 흔히 보는 「TP가 왼쪽 위」 배치와 정반대다. 인덱스를 눈대중으로 짚으면 정밀도와 재현율이 조용히 뒤바뀌고, 값이 그럴듯해서 며칠을 못 알아채기도 한다.

이 글의 코드 조각은 전부 아래 한 덩이에서 이어진다. 유방암 진단 데이터로 로지스틱 회귀를 학습해 정답 y_test, 예측 y_pred, 양성 확률 y_proba 세 배열을 만들어 두고, 이후 절에서는 그 셋만 다시 쓴다.

import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix

X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

pipe = Pipeline([('scaler', StandardScaler()),
                 ('lr', LogisticRegression(max_iter=1000))])
pipe.fit(X_train, y_train)

y_pred = pipe.predict(X_test)
y_proba = pipe.predict_proba(X_test)[:, 1]      # 양성 클래스 점수

tn, fp, fn, tp = confusion_matrix(y_test, y_pred).ravel()
print(f"정밀도 {tp/(tp+fp):.4f} / 재현율 {tp/(tp+fn):.4f} / "
      f"특이도 {tn/(tn+fp):.4f} / 정확도 {(tp+tn)/len(y_test):.4f}")

이 여덟 줄에 평가 이야기를 시작하기 전에 갖춰야 할 것이 다 들어 있다. stratify=y는 나누기 전 클래스 비율을 학습·테스트 양쪽에 그대로 유지하라는 뜻이다. 이것을 빼면 테스트 세트의 양성 비율이 운에 따라 흔들리고, 그러면 뒤에 계산할 정밀도·재현율이 모델의 성질인지 분할의 운인지 구별되지 않는다. Pipeline으로 표준화를 모델 안에 넣은 것도 같은 이유다 — 스케일러를 밖에서 전체 데이터에 한 번 맞추면 테스트 세트의 평균과 표준편차가 학습에 새어 들어가 성능이 실제보다 높게 나온다. max_iter=1000은 기본값 100으로는 30개 특성에서 수렴 경고가 뜨기 때문에 올려 둔 값이다.

마지막 두 줄이 이 글에서 가장 자주 재사용될 관용구다. cm.ravel()로 네 변수에 한 번에 풀어 두면 이후 계산이 인덱스가 아니라 이름으로 적힌다. cm[1][0]이 무엇이었는지 매번 되짚는 대신 fn이라고 쓰면 되고, 순서를 잘못 짚는 종류의 버그가 아예 생기지 않는다. 여기 나온 특이도(Specificity)는 실제 음성 중 음성으로 맞힌 비율 TN/(TN+FP)TN/(TN+FP) 로, 재현율을 음성 쪽에서 잰 값이다. 뒤에 나올 ROC 곡선의 가로축이 이 값에서 나온다.

행렬 정규화

행렬을 개수 그대로 보면 클래스가 기울었을 때 읽히지 않는다. 음성이 9,000개고 양성이 1,000개면 TN 칸의 큰 수가 눈을 다 가져가서, 양성 쪽에서 무슨 일이 벌어지는지 알 수 없다. 그래서 비율로 바꿔 본다.

from sklearn.metrics import ConfusionMatrixDisplay

cm_norm = confusion_matrix(y_test, y_pred, normalize='true')
# 'true' : 각 행의 합이 1 — 실제 클래스별 성공률
# 'pred' : 각 열의 합이 1 — 예측 클래스별 적중률
# 'all'  : 전체 합이 1 — 각 칸이 전체에서 차지하는 비율

ConfusionMatrixDisplay(cm_norm,
                       display_labels=['malignant', 'benign']).plot()

셋은 서로 다른 질문에 답한다. normalize='true'는 행을 1로 맞추므로 대각선이 곧 클래스별 재현율이 된다 — 위 유방암 데이터에서는 「실제 악성의 97.6%를 잡았다」로 읽힌다. normalize='pred'는 열을 1로 맞추므로 대각선이 클래스별 정밀도가 된다. normalize='all'은 어느 오류가 전체에서 얼마나 자주 일어나는지를 본다.

같은 행렬에서 나온 값이라고 앞의 둘이 비슷할 이유는 없다. 유방암 데이터처럼 클래스가 크게 기울지 않고 모델이 잘 맞는 경우에는 두 대각선이 나란히 0.97 근처에 서지만, 뒤에 나올 사기 탐지 예에서는 행 정규화가 0.60을, 열 정규화가 0.30을 같은 칸에 적는다. 실제 사기의 60%를 잡으면서 사기라 부른 것의 30%만 진짜인 상태다. 어느 방향으로 1을 맞췄는지 말하지 않은 「정확률 표」는 읽을 수 없다는 뜻이고, 남의 보고서에서 정규화 행렬을 볼 때 가장 먼저 확인할 것도 이것이다.

마지막 줄의 ConfusionMatrixDisplay는 행렬을 히트맵 — 값의 크기를 색의 진하기로 그린 표 — 으로 보여 주는 sklearn의 표시 도구다. 이진 분류에서는 네 칸을 숫자로 읽어도 되지만 클래스가 열 개만 되어도 100칸이라 눈으로 훑는 것이 불가능해지고, 그때 히트맵은 대각선 밖에 남은 진한 칸 하나를 즉시 보여 준다. 학습된 모델에서 바로 그리려면 ConfusionMatrixDisplay.from_estimator(pipe, X_test, y_test, normalize='true') 한 줄이면 되고, 행렬을 손에 쥐고 있다면 위처럼 ConfusionMatrixDisplay(cm)에 넘긴다. display_labels를 함께 주는 습관을 권한다 — 축에 0과 1만 찍혀 있으면 어느 쪽이 악성인지를 다시 확인해야 하고, 그 확인을 건너뛰는 순간 행렬을 뒤집어 읽는다.

혼동 행렬 시각화 코드

불균형 데이터를 볼 때 기본으로 켜야 할 것은 'true'다. 개수로 보면 TN이 압도적으로 커서 모든 것이 잘 돌아가는 것처럼 보이지만, 행을 1로 정규화하면 각 클래스가 공평한 크기로 나란히 서서 어느 쪽이 무너졌는지가 한눈에 드러난다.

네 칸의 지표

분류 평가 지표 공식 정리

정확도

정확도는 (TP+TN)/(TP+FP+TN+FN)(TP+TN)/(TP+FP+TN+FN) 이다. 전체에서 맞힌 비율이니 가장 직관적이고, 클래스가 반반에 가까울 때는 이 값 하나로 충분한 경우도 많다.

무너지는 자리는 분자에 TN이 들어 있다는 점이다. 음성이 압도적으로 많으면 TN이 분자를 거의 혼자 채우고, 양성 쪽에서 무슨 일이 일어나든 값이 거의 안 움직인다. 거래 10,000건 중 사기가 100건인 데이터를 보자. 전부 정상이라 답하는 모델은 TP 0, FP 0, FN 100, TN 9,900이므로 정확도가 99%다. 실제로 쓸 만한 모델이 TP 60, FN 40, FP 140, TN 9,760을 냈다면 정확도는 98.2%다. 아무것도 안 하는 쪽이 숫자가 더 높다.

이 사기 탐지 행렬을 앞으로 계속 쓴다. 지표를 하나 유도할 때마다 같은 네 숫자를 넣어 볼 참이고, 그러면 지표들이 서로 얼마나 다른 이야기를 하는지가 한 화면에서 비교된다.

정확도를 아예 버리라는 말은 아니다. 클래스 비율을 함께 적어 두면 정확도는 여전히 기준선 역할을 한다 — 「양성 1%인 데이터에서 정확도 99%」는 곧 「기준선과 같다」는 뜻으로 읽힌다. 위험한 것은 비율을 밝히지 않은 채 정확도만 보고하는 일이다. 그래서 정확도를 보고할 때는 항상 다수 클래스 비율을 옆에 적는다. 그 비율이 곧 아무것도 학습하지 않은 모델의 점수이고, 우리 모델이 그보다 얼마나 위에 있는지가 실제로 궁금한 값이다.

정밀도와 재현율

정밀도(Precision)는 TP/(TP+FP)TP/(TP+FP) 로, 양성이라 예측한 것 중 실제 양성의 비율이다. 열에서 나오는 값이고, 「모델이 양성이라 말할 때 얼마나 믿을 수 있는가」를 답한다. 재현율(Recall)은 TP/(TP+FN)TP/(TP+FN) 로, 실제 양성 중 모델이 찾아낸 비율이다. 행에서 나오는 값이고, 「놓친 것이 얼마나 되는가」를 답한다.

앞의 사기 탐지 예로 계산해 보면 정밀도는 60/200=0.3060/200 = 0.30 이고 재현율은 60/100=0.6060/100 = 0.60 이다. 정확도 98.2%가 감추고 있던 것이 이 두 숫자다. 사기 열 건 중 여섯을 잡지만, 사기라고 경보를 울린 것의 70%는 정상 거래다. 이제야 개선할 방향을 이야기할 수 있다.

두 값이 어느 칸을 안 쓰는지도 함께 봐 두면 좋다. 정밀도의 분모는 TP와 FP뿐이고 재현율의 분모는 TP와 FN뿐이라, 둘 다 TN을 한 번도 세지 않는다. 그래서 정상 거래 9,760건을 정상이라 맞힌 공로는 두 값 어디에도 반영되지 않는다. 불균형 데이터에서 정확도 대신 이 둘을 보라고 말하는 이유가 정확히 이것이다 — 가장 크고 가장 쉬운 칸을 계산에서 빼 버리는 지표이기 때문이다. 이 성질은 뒤의 MCC 절에서 다시 문제로 돌아온다.

둘 중 무엇을 무겁게 볼지는 데이터가 아니라 오류의 비용이 정한다. 스팸 필터에서 FP는 사용자가 놓친 업무 메일이고 FN은 받은편지함에 남은 광고 한 통이므로 정밀도가 앞선다. 암 검진에서 FP는 추가 검사 한 번이고 FN은 놓친 환자이므로 재현율이 앞선다. 지표 선택이 곧 「어느 쪽으로 틀리는 편이 나은가」에 대한 답이라는 뜻이다.

F1과 조화평균

한 숫자로 보고해야 할 때가 있어서 정밀도와 재현율을 합친 값이 F1 점수다.

F1=2⋅P⋅RP+RF_1 = 2 \cdot \frac{P \cdot R}{P + R}

산술평균이 아니라 조화평균 — 역수의 평균을 다시 뒤집은 값 — 을 쓴 데는 이유가 있다. 산술평균은 한쪽을 극단으로 밀어 다른 쪽을 희생하는 전략에 후하다. 임계값을 아주 높게 잡아 확실한 한 건만 양성이라 답하면 정밀도 1.0에 재현율 0.1이 나오고, 산술평균은 0.55라는 준수한 값을 준다. 조화평균은 2×0.1×1.0/1.1=0.182 \times 0.1 \times 1.0 / 1.1 = 0.18 이다. 낮은 쪽에 끌려 내려가므로 둘 다 챙긴 모델만 높은 값을 받는다.

사기 탐지 예의 F1은 2×0.3×0.6/0.9=0.402 \times 0.3 \times 0.6 / 0.9 = 0.40 이다. 정확도 0.982와 F1 0.40이 같은 모델의 같은 예측을 두고 나온 숫자라는 점이 이 절의 요점이다.

한 가지 더. F1은 정밀도와 재현율을 같은 무게로 놓는다. 비용이 한쪽으로 기울어 있으면 FβF_\beta 를 쓴다 — β\beta 가 1보다 크면 재현율이, 작으면 정밀도가 무거워지고, 의료 쪽에서 흔히 쓰는 F2F_2 는 재현율에 네 배의 무게를 준다. 무게가 네 배인데 β\beta 가 2인 것이 어색해 보이지만, FβF_\beta 의 정의에 β2\beta^2 이 들어가기 때문이다. 반대쪽인 F0.5F_{0.5} 는 정밀도에 네 배를 주고, 추천 시스템처럼 헛짚는 것이 비싼 곳에서 쓴다.

임계값

임계값과 네 칸

분류기가 실제로 내놓는 것은 클래스가 아니라 0과 1 사이의 점수다. 그 점수를 클래스로 바꾸는 기준선이 임계값(threshold)이고, 대부분의 라이브러리가 predict()에서 말없이 0.5를 쓴다. 혼동 행렬은 이 임계값 하나에 딸린 표다. 같은 모델, 같은 데이터라도 임계값을 바꾸면 네 칸의 숫자가 전부 달라진다.

움직이는 방향은 정해져 있다. 임계값을 올리면 양성이라 선언하는 기준이 엄격해져 FP가 줄고 FN이 는다 — 정밀도가 오르고 재현율이 떨어진다. 내리면 반대다. 그래서 정밀도와 재현율은 독립된 두 성능이 아니라 하나의 손잡이가 만드는 두 끝이고, 「정밀도도 올리고 재현율도 올렸다」고 말하려면 임계값이 아니라 모델 자체가 좋아졌어야 한다.

0.5라는 기본값에 근거가 있는지도 짚어 둔다. 확률이 잘 보정된 모델에서 0.5는 「양성일 확률이 음성일 확률보다 크면 양성이라 부른다」는 뜻이라 나름의 뜻이 있다. 다만 그것은 두 오류의 비용이 같고 학습 데이터의 클래스 비율이 실제 비율과 같을 때의 이야기다. 둘 중 하나라도 어긋나면 0.5는 그저 라이브러리가 고른 임의의 수이고, 그 자리를 우리가 다시 정해야 한다.

정밀도-재현율 곡선

임계값을 0부터 1까지 훑으면서 매번 정밀도와 재현율을 계산하면 곡선 하나가 나온다. 이것이 정밀도-재현율 곡선이고, 모델이 제공하는 선택지 전체를 한 장에 담은 그림이다. 곡선 위의 한 점을 고르는 것이 임계값을 정하는 일이다.

from sklearn.metrics import precision_recall_curve

precisions, recalls, thresholds = precision_recall_curve(y_test, y_proba)

f1 = 2 * precisions * recalls / (precisions + recalls + 1e-9)
best = np.argmax(f1)
print(f"임계값 {thresholds[best]:.4f} → P {precisions[best]:.4f} / "
      f"R {recalls[best]:.4f} / F1 {f1[best]:.4f}")

y_pred_tuned = (y_proba >= thresholds[best]).astype(int)

분모에 더한 10−910^{-9} 는 정밀도와 재현율이 둘 다 0인 구간에서 0으로 나누는 것을 막는 장치다. 그리고 precision_recall_curve가 돌려주는 배열의 길이가 하나 다르다는 점을 기억해 둔다 — thresholds는 나머지 둘보다 한 칸 짧다. 곡선의 마지막 점(재현율 0, 정밀도 1)에 대응하는 임계값이 없어서다. 이 한 칸 차이를 모른 채 세 배열을 나란히 인덱싱하면 임계값이 한 칸씩 밀려 잡히고, 결과가 크게 틀리지 않아서 오래 살아남는 종류의 버그가 된다.

곡선의 모양 자체도 읽을 거리다. 왼쪽 위 구석에 가까울수록 좋은 모델이고, 오른쪽으로 갈수록 재현율을 사기 위해 정밀도를 내주는 구간이다. 곡선이 어느 지점에서 갑자기 꺾여 내려간다면 그 근처에 「여기부터는 값이 급격히 나빠진다」는 자연스러운 경계가 있다는 뜻이라, 실무에서는 그 무릎 근처를 후보로 삼는 일이 많다.

비용 기반 임계값

F1이 가장 큰 지점을 고르는 것은 편리한 기본값이지 정답이 아니다. F1이 최대라는 말은 정밀도와 재현율을 같은 무게로 놓았을 때 가장 좋다는 뜻이고, 앞서 봤듯 두 오류의 값이 같은 경우는 드물다.

비용을 아는 상황이라면 임계값은 계산으로 정할 수 있다. FN 한 건의 비용이 FP 한 건의 열 배라면, 곡선 위 각 점에서 10×FN+1×FP10 \times FN + 1 \times FP 를 계산해 가장 작은 곳을 고른다. 사기 탐지로 옮겨 적으면 이렇다 — 사기 한 건을 놓치는 손실이 40만 원이고 정상 거래 한 건을 붙잡아 심사하는 인건비가 4만 원이면, 임계값을 내려 사기 15건을 더 잡는 대신 정상 거래 120건을 더 세우는 교환은 600만 원을 아끼고 480만 원을 쓰는 일이라 남는 장사다. 같은 계산을 곡선 위 모든 점에서 돌려 총비용이 최소인 자리를 고르면 된다. F1 최대 지점보다 훨씬 낮은 임계값이 나오는 것이 보통이고, 그편이 옳다.

비용의 절대액을 모르더라도 비율만 알면 계산이 성립한다는 점이 중요하다. 위 예에서 40만 원과 4만 원 대신 「열 배」만 알아도 최소가 되는 지점은 같은 자리다. 비율조차 모르는 상황이라면 최소 요구치를 거는 방법이 있다 — 「재현율 0.9 이상을 만족하는 점 중 정밀도가 가장 높은 것」처럼 한쪽을 제약으로 걸고 다른 쪽을 최대화한다. 규제나 계약이 「놓치면 안 되는 비율」을 못 박아 두는 분야에서는 이쪽이 오히려 자연스럽다.

주의할 것은 임계값을 고른 데이터와 성능을 보고하는 데이터를 분리해야 한다는 점이다. 테스트 세트에서 최적 임계값을 찾아 그 테스트 세트의 F1을 보고하면 그 숫자는 이미 부풀려져 있다. 임계값도 학습된 파라미터이므로 검증 세트에서 정하고 테스트 세트는 마지막에 한 번만 연다. 이 구분이 왜 중요한지는 편향과 분산에서 다룬 일반화 논의와 같은 이야기다.

오답 분석

틀린 샘플의 인덱스

지표는 요약이라 어디를 고쳐야 할지 알려 주지 않는다. 「재현율 0.60」에서 다음 행동이 나오지 않는다. 그래서 행렬을 읽은 다음에는 틀린 칸에 들어간 샘플을 직접 꺼내 본다.

fn_idx = np.where((y_test == 1) & (y_pred == 0))[0]   # 놓친 양성
fp_idx = np.where((y_test == 0) & (y_pred == 1))[0]   # 헛짚은 음성

print(f"FN {len(fn_idx)}건 / FP {len(fp_idx)}건")

이 인덱스로 원본 데이터를 뽑아 눈으로 훑으면 대개 패턴이 보인다. 놓친 사기 거래가 전부 특정 시간대에 몰려 있다거나, 헛짚은 이미지가 전부 조명이 어두운 사진이라거나 하는 식이다. 그런 패턴이 보이면 다음 행동은 임계값 조정이 아니라 특성 추가나 데이터 보강이다. 지표만 보고 있으면 절대 나오지 않을 결론이다.

패턴을 찾는 값싼 방법이 하나 있다. 틀린 샘플들의 특성 평균을 맞힌 샘플들의 평균과 나란히 놓고 차이가 큰 열부터 보는 것이다. 여기서 「FN 샘플만 종양 반지름의 평균이 눈에 띄게 작다」 같은 문장이 나오면, 모델이 작은 종양을 배우지 못했다는 구체적인 진단이 된다. 그다음은 그 구간의 데이터를 더 모으거나, 크기와 상관없이 악성을 가리키는 다른 특성을 찾는 일이다.

오답의 확률 점수

틀린 샘플을 한 덩어리로 보면 안 된다. 모델이 그 샘플에 준 확률 점수를 함께 보면 성격이 다른 두 오답으로 갈린다.

fn_proba = y_proba[fn_idx]
print(f"FN 샘플 확률 — 평균 {fn_proba.mean():.3f} / 최대 {fn_proba.max():.3f}")
print(f"0.4 이상인 경계선 FN: {(fn_proba >= 0.4).sum()}건")

FN 샘플의 점수가 0.45, 0.48처럼 임계값 바로 아래에 몰려 있으면 경계선 케이스다. 모델은 이미 그 샘플을 의심하고 있었고 기준선을 조금만 내렸어도 잡았을 것이다. 이런 오답은 임계값 조정으로 값싸게 회수된다.

반대로 점수가 0.02, 0.05처럼 바닥에 붙어 있으면 이야기가 다르다. 모델이 그 샘플을 자신 있게 음성이라 판단했다는 뜻이고, 임계값을 아무리 내려도 그 전에 다른 정상 거래 수천 건이 먼저 양성으로 넘어온다. 이런 오답은 모델이 그 유형을 아예 배우지 못한 것이거나 라벨이 잘못 붙은 것이므로, 손댈 곳은 임계값이 아니라 데이터나 특성이다. 두 오답의 개수 비율이 다음 작업이 무엇이어야 하는지를 정한다.

FP 쪽도 같은 방식으로 갈린다. 0.52처럼 겨우 넘어온 FP는 기준선을 조금 올리면 사라지지만, 0.95를 받은 FP는 모델이 그 정상 샘플을 사기와 구별하지 못한다는 뜻이라 특성이 부족한 것이다. 확신 있게 틀린 오답이 양쪽 모두에서 많다면 그것은 임계값이 아니라 모델의 표현력이나 데이터 품질을 의심할 신호다. 라벨 오류를 찾을 때도 이 자리를 본다 — 사람이 붙인 정답이 틀렸을 가능성이 가장 높은 곳이 바로 「모델이 아주 강하게 반대로 확신한」 샘플들이다.

확률 점수로 갈리는 경계선 오답과 확신 있는 오답

임계값별 행렬 비교

경계선 FN이 많다고 판단했으면 임계값을 내려 보고 네 칸이 실제로 어떻게 옮겨 갔는지 확인한다. 곡선 위의 숫자를 믿는 것과 바뀐 행렬을 눈으로 보는 것은 다르다.

for thr in (0.5, 0.35, 0.25):
    tn2, fp2, fn2, tp2 = confusion_matrix(
        y_test, (y_proba >= thr).astype(int)).ravel()
    print(f"임계값 {thr}: TP {tp2} / FP {fp2} / FN {fn2}")

이 세 줄을 나란히 보면 앞 절에서 계산으로 고른 점이 실제로 어떤 표가 되는지 확인된다. 0.5에서 0.35로 내렸을 때 FN이 40에서 25로 줄면서 FP가 140에서 260으로 늘었다면, 곡선이 약속했던 교환이 그대로 일어난 것이다. 곡선의 값과 이 표를 대조하는 것이 이 절이 하는 일의 전부이고, 결정 자체는 앞 절에서 이미 내렸다.

대조가 어긋나는 경우도 있고 그때가 오히려 중요하다. 임계값을 검증 세트에서 골랐다면 테스트 세트의 표는 조금 나쁘게 나오는 것이 정상이다 — 그 차이가 곧 임계값 선택이 얼마나 검증 세트에 맞춰졌는지를 재는 값이다. 차이가 크면 검증 세트가 너무 작았거나 곡선이 몇 개의 샘플에 좌우될 만큼 불안정하다는 뜻이므로, 임계값을 하나의 점이 아니라 「0.3~0.4 구간」처럼 폭으로 잡고 그 안에서 표가 얼마나 흔들리는지 함께 본다.

다중 클래스 혼동 행렬

n×n 행렬과 대각선

클래스가 nn 개면 혼동 행렬은 n×nn \times n 이 된다. 방향은 그대로다 — 행이 실제, 열이 예측이다. 대각선은 맞힌 것, 대각선 밖은 틀린 것이고, 이진에서의 TP·TN이 대각선으로, FP·FN이 나머지 칸으로 흩어진 모양이다.

칸이 늘면서 생기는 이점이 있다. 이진에서는 「틀렸다」가 두 종류뿐이지만, 열 개 클래스에서는 90가지 방향으로 틀릴 수 있고 그 분포 자체가 정보다. 손글씨 숫자 인식에서 4를 9로 착각한 것과 4를 0으로 착각한 것은 완전히 다른 실패이며, 전자는 모양이 닮아서고 후자는 모델이 이상한 상태라는 신호다.

아래 이야기는 sklearn의 손글씨 숫자 데이터(load_digits, 8×8 픽셀 1,797장)에 랜덤 포레스트를 200그루 태워 얻은 10×10 행렬을 두고 하는 것이다. 이진에서 쓰던 y_test·y_pred 자리에 그 다중 클래스 예측이 들어갔다고 보면 된다.

혼동 쌍의 비대칭

10×10 행렬을 눈으로 훑어 큰 수를 찾는 것은 금방 지친다. 대각선을 지우고 최댓값을 뽑으면 된다.

cm = confusion_matrix(y_test, y_pred)
off = cm.copy()
np.fill_diagonal(off, 0)                       # 맞힌 칸을 지운다
i, j = np.unravel_index(off.argmax(), off.shape)
print(f"가장 흔한 오류: 실제 {i} → 예측 {j} ({off[i, j]}회)")

cm.copy()로 사본을 뜬 것이 사소해 보이지만 필요하다. np.fill_diagonal은 배열을 제자리에서 고치므로 원본에 바로 쓰면 대각선이 지워진 행렬만 남고, 뒤에서 정확도를 다시 계산하려 할 때 0이 나온다.

여기서 행렬의 비대칭성을 함께 보는 것이 중요하다. cm[4][9]와 cm[9][4]가 둘 다 크면 두 클래스가 서로 구별되지 않는다는 뜻이고, 한쪽만 크면 방향이 있는 편향이다. 4를 9로는 자주 틀리는데 9를 4로는 거의 안 틀린다면 모델이 9 쪽으로 기울어 있는 것이고, 그 클래스의 샘플 수나 클래스 가중치를 의심할 자리다. 클래스 가중치는 학습할 때 손실에 클래스마다 다른 배수를 곱해 어떤 클래스의 오답을 더 비싸게 세는 장치인데, 이것을 어떻게 켜는지는 6절에서 이어 간다.

두 종류의 비대칭을 구별하는 실익은 처방이 다르다는 데 있다. 대칭이면 두 클래스를 가르는 특성이 아예 없다는 뜻이라 특성을 늘려야 하고, 비대칭이면 모델이 한쪽으로 기운 것이라 가중치나 샘플 수로 균형을 되돌리는 것이 먼저다.

macro·weighted·micro

정밀도·재현율·F1은 원래 「양성 클래스 하나」를 전제한 값이라, 클래스가 여럿이면 각 클래스를 차례로 양성으로 놓고 계산한 뒤 평균을 낸다. 그 평균 방법이 셋이다.

평균 계산 방식 성격
macro 클래스별 점수의 단순 평균 클래스를 동등하게 취급
weighted 클래스별 점수를 샘플 수로 가중 평균 큰 클래스가 값을 이끈다
micro TP·FP·FN을 전부 합산한 뒤 한 번 계산 샘플을 동등하게 취급

차이가 드러나는 곳은 클래스가 기울었을 때다. 샘플 900개짜리 클래스의 F1이 0.95이고 50개짜리 두 클래스의 F1이 0.40이면, macro는 (0.95+0.40+0.40)/3=0.58(0.95+0.40+0.40)/3 = 0.58 이고 weighted는 큰 클래스에 끌려 0.89 근처가 된다. 소수 클래스가 무너진 것을 보려면 macro를, 전체 사용자가 겪는 경험을 재려면 weighted를 본다. 둘 중 하나가 옳은 것이 아니라 묻는 질문이 다르다 — 「우리 모델은 어느 클래스에서든 쓸 만한가」와 「무작위로 고른 요청 하나가 잘 처리될 확률은 얼마인가」는 서로 다른 질문이고, 각각의 답이 macro와 weighted다.

micro에는 함정이 하나 있다. 각 샘플이 정확히 하나의 클래스에 속하는 보통의 다중 클래스 문제에서는 micro 정밀도·재현율·F1이 전부 정확도와 같은 값이 된다 — 모든 오류가 어떤 클래스의 FP이자 다른 클래스의 FN으로 두 번 세어져 상쇄되기 때문이다. 그래서 단일 라벨 문제에서 average='micro'를 보고하는 것은 정확도를 다른 이름으로 부르는 일이다. micro가 제 값을 하는 자리는 샘플 하나에 라벨이 여럿 붙는 멀티레이블 문제다.

from sklearn.metrics import f1_score, classification_report

f1_score(y_test, y_pred, average='macro')       # 클래스 동등
f1_score(y_test, y_pred, average='weighted')    # 샘플 수 가중
f1_score(y_test, y_pred, average=None)          # 클래스별 개별 점수

average=None을 먼저 찍어 보는 습관을 권한다. 평균은 무엇이 무너졌는지 감추지만 클래스별 배열은 감추지 않는다.

클래스별 성능 표

classification_report는 위의 값을 한 표로 묶어 준다.

print(classification_report(y_test, y_pred,
                            target_names=['cat', 'dog', 'bird']))

출력은 이런 모양이다.

precision recall f1-score support
cat 0.88 0.82 0.85 150
dog 0.84 0.90 0.87 180
bird 0.91 0.88 0.89 120
accuracy 0.87 450
macro avg 0.88 0.87 0.87 450
weighted avg 0.87 0.87 0.87 450

support는 그 클래스의 실제 샘플 수다. 이 표를 읽을 때는 두 가지를 본다. 첫째, macro avg와 weighted avg의 차이다. 위 예는 클래스가 고르게 분포해 둘이 거의 같지만, 벌어져 있으면 소수 클래스가 뒤처져 있다는 뜻이다. 둘째, 한 행 안에서 precision과 recall의 차이다. cat은 정밀도 0.88에 재현율 0.82이므로 cat이라 부르는 데 인색하고, dog은 반대로 후하다. 그 둘이 서로의 오류를 주고받고 있을 가능성이 크고, 확인하려면 다시 혼동 행렬로 돌아가 cm[cat][dog]을 본다.

support 열을 그냥 지나치지 않기를 권한다. F1이 0.40인 클래스의 support가 8이면 그 값은 샘플 여덟 개가 만든 숫자라 다음 분할에서 크게 흔들린다. 그런 클래스의 점수를 개선 목표로 삼기 전에 먼저 할 일은 데이터를 더 모으는 것이다. 반대로 support가 크고 F1이 낮은 클래스가 있으면 그곳이 손대야 할 진짜 자리다.

분류 평가 sklearn 코드

불균형 데이터의 지표

F1의 TN 사각지대

F1은 정확도보다 낫지만 완전하지 않다. 정의를 다시 보면 정밀도도 재현율도 분자가 TP이고 분모에 FP와 FN만 들어간다 — F1은 네 칸 중 TN을 한 번도 쓰지 않는다.

그래서 이런 일이 벌어진다. 샘플 100개 중 90개가 A, 10개가 B인 데이터에서 무조건 A라고 답하는 모델을 놓고, A를 양성 클래스로 두면 TP 90, FP 10, FN 0, TN 0이라 정밀도 0.9, 재현율 1.0, F1 0.947이 나온다. 아무것도 학습하지 않은 모델이 0.95에 가까운 점수를 받는다.

같은 예측을 두고 B를 양성 클래스라 부르면 TP 0, FP 0, FN 10, TN 90이다. 재현율은 0이고 정밀도는 분모가 0이라 정의되지 않는데, sklearn은 zero_division 기본값에 따라 0으로 두므로 F1도 0으로 찍힌다. 모델도 데이터도 그대로인데 어느 클래스를 양성이라 부르느냐만으로 F1이 0.95에서 0으로 움직인다. 이 비대칭이 F1의 성질이고, 두 클래스의 중요도가 명백히 다를 때는 유용한 성질이지만 그렇지 않을 때는 그냥 함정이다.

특히 위험한 자리는 다수 클래스를 양성으로 두게 되는 경우다. 라벨을 붙일 때 「정상」에 1을, 「이상」에 0을 준 데이터가 드물지 않고, 그대로 f1_score를 부르면 정상을 양성으로 세어 0.95를 보게 된다. 데이터 라벨의 0/1이 어느 쪽인지, pos_label 인자가 무엇을 가리키는지를 매번 확인해야 하는 이유다.

MCC의 네 칸

MCC(Matthews Correlation Coefficient)는 예측과 정답을 두 이진 변수로 보고 그 상관계수를 구한 값이다. 네 칸을 모두 쓴다.

MCC=TP⋅TN−FP⋅FN(TP+FP)(TP+FN)(TN+FP)(TN+FN)\mathrm{MCC} = \frac{TP \cdot TN - FP \cdot FN}{\sqrt{(TP+FP)(TP+FN)(TN+FP)(TN+FN)}}

분자가 「맞힌 것들의 곱 − 틀린 것들의 곱」이라는 점을 보면 성격이 짐작된다. 값은 −1-1 부터 +1+1 까지이고, +1+1 은 완벽, 00 은 무작위 추측과 같음, −1-1 은 정확히 반대로 맞힌 것이다. 0이 「무작위」라는 기준선을 갖는다는 점이 F1과 결정적으로 다르다 — F1의 0.4가 좋은 것인지 나쁜 것인지는 데이터 비율을 알아야 판단되지만, MCC 0.05는 어떤 데이터에서든 「거의 아무것도 못 한다」는 뜻이다.

앞의 A/B 예는 사실 MCC를 계산할 수 없는 자리다. TP 90 · FP 10 · FN 0 · TN 0을 분모에 넣으면 (TN+FN)(TN+FN) 이 0+0=00+0=0 이라 근호 안이 통째로 0이 되고, 분자도 90×0−10×0=090 \times 0 - 10 \times 0 = 0 이라 0/00/0 이다. 수학적으로는 정의되지 않는 값이다. matthews_corrcoef가 이 입력에 0을 돌려주는 것은 계산 결과가 아니라 관례이고, 근거는 이렇다 — 예측이 한 클래스로 완전히 쏠려 있으면 예측이라는 변수의 분산이 0이라 상관을 잴 상대가 없고, 상관계수를 정의하는 자리에서 그런 경우에 0을 놓는 것이 자연스럽다.

네 칸이 모두 비어 있지 않은 예로 옮기면 MCC의 성격이 제대로 드러난다. 같은 90:10 데이터에서 이번에는 모델이 두 건만 B라고 답하고 그중 하나가 실제로 B였다고 하자. A를 양성으로 두면 TP 89 · FP 9 · FN 1 · TN 1이고, F1은 0.950.95 다. B를 양성으로 두면 TP 1 · FP 1 · FN 9 · TN 89이고, 정밀도 0.5에 재현율 0.1이라 F1은 0.170.17 이다. 같은 예측인데 0.95와 0.17이다.

MCC는 어느 쪽으로 두든 하나의 값을 준다. A 기준으로 계산하면 분자가 89×1−9×1=8089 \times 1 - 9 \times 1 = 80 이고 분모가 98×90×10×2=420\sqrt{98 \times 90 \times 10 \times 2} = 420 이라 80/420=0.1980/420 = 0.19 다. B 기준으로 두면 TP와 TN이, FP와 FN이 서로 자리를 바꾸는데 분자의 두 곱도 분모의 네 인수도 그대로라 값이 변하지 않는다. 양성 클래스를 바꿔도 같은 값이 나온다는 것이 MCC의 정의에서 바로 따라 나오는 성질이고, F1에는 없는 성질이다. 그리고 0.19라는 값은 「무작위보다 조금 낫다」로 읽힌다 — B 열 건 중 한 건을 맞혔으니 실제로 딱 그만큼이다.

앞서 든 사기 탐지 예(TP 60, FP 140, FN 40, TN 9,760)의 MCC는 580,000/1,393,000≈0.42580{,}000 / 1{,}393{,}000 \approx 0.42 다. F1 0.40과 비슷한 값이 나왔는데, 이 데이터에서는 두 지표가 같은 이야기를 하고 있다는 뜻이다. 계산은 from sklearn.metrics import matthews_corrcoef 한 줄을 더하고 matthews_corrcoef(y_test, y_pred)를 부르면 된다. 둘을 함께 찍어 보고 크게 벌어질 때만 따져 보는 것이 실용적인 습관이다. 벌어졌다면 그 원인은 거의 항상 TN 칸이다.

클래스 가중치

지표를 잘 고르는 것과 모델을 고치는 것은 다른 일이다. MCC가 0.19라고 알려 주는 것까지가 지표의 몫이고, 그다음에 소수 클래스를 실제로 잡게 만드는 것은 학습 쪽에서 손대야 한다. 가장 값싼 처방이 앞에서 이름만 꺼내 둔 클래스 가중치다.

원리는 단순하다. 보통의 손실 함수는 모든 샘플의 오답을 똑같이 세지만, 클래스마다 배수를 달리 주면 소수 클래스 한 건을 틀리는 일이 다수 클래스 한 건을 틀리는 일보다 비싸진다. sklearn의 class_weight='balanced'는 그 배수를 wc=n/(k⋅nc)w_c = n / (k \cdot n_c) 로 자동 계산한다 — nn 은 전체 샘플 수, kk 는 클래스 수, ncn_c 는 그 클래스의 샘플 수다. 9:1 데이터라면 소수 클래스가 10,000/(2×1,000)=5.010{,}000/(2 \times 1{,}000) = 5.0 배, 다수 클래스가 10,000/(2×9,000)≈0.5610{,}000/(2 \times 9{,}000) \approx 0.56 배를 받아 두 클래스가 손실에 기여하는 총량이 같아진다.

from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier

X_i, y_i = make_classification(n_samples=10000, n_features=20,
                               weights=[0.9, 0.1], random_state=42)

rf = RandomForestClassifier(n_estimators=100,
                            class_weight='balanced',   # 소수 클래스를 비싸게
                            random_state=42)

make_classification으로 9:1 데이터를 직접 만들어 두면 가중치를 켜고 끈 두 모델의 혼동 행렬을 나란히 볼 수 있다. 관찰되는 방향은 정해져 있다 — 가중치를 켜면 소수 클래스의 재현율이 오르고 정밀도가 떨어진다. 임계값을 내렸을 때와 같은 방향이다. 다른 점은 손대는 시점이다. 임계값은 학습이 끝난 뒤 점수를 자르는 위치를 옮기는 것이고, 클래스 가중치는 학습 중에 결정 경계 자체를 소수 클래스 쪽으로 미는 것이다. 그래서 가중치 쪽은 모델을 다시 학습해야 하지만, 경계선 근처가 아니라 애초에 배우지 못한 유형까지 잡아낼 여지가 있다.

두 가지를 주의한다. 첫째, 가중치와 임계값을 동시에 밀지 않는다. 둘 다 재현율을 올리는 손잡이라 함께 당기면 정밀도가 예상보다 훨씬 많이 무너지고, 어느 쪽이 얼마나 기여했는지도 분리되지 않는다. 하나를 고정하고 다른 하나만 움직이면서 곡선을 그린다. 둘째, 가중치를 켠 모델의 predict_proba는 보정(calibration) — 모델이 내놓은 0.8이 실제로 80%의 빈도에 대응하는 성질 — 이 깨진다. 소수 클래스 쪽 확률이 실제보다 부풀려져 나오므로, 확률값 자체를 기대 손실 계산에 넣는 시스템에서는 가중치 대신 임계값으로 조정하거나 보정 단계를 따로 두는 편이 낫다.

상황별 선택

지금까지의 이야기를 한 표로 접으면 이렇다.

상황 볼 것 이유
클래스가 균형 정확도, F1 직관적이고 정보가 충분하다
FP 비용이 큼 (스팸, 추천) 정밀도 헛짚는 것을 줄인다
FN 비용이 큼 (진단, 사기) 재현율, F2F_2 놓치는 것을 줄인다
클래스가 크게 기움 MCC, PR 곡선 정확도의 허수와 F1의 TN 사각지대를 피한다
다중 클래스 클래스별 F1, macro 평균이 감추는 것을 본다
멀티레이블 micro F1 샘플마다 라벨 수가 다르다

표보다 중요한 것은 표를 만든 방식이다. 지표를 외우는 것이 아니라 혼동 행렬을 먼저 그리고, 네 칸 중 무엇이 비싼지 정하고, 그 칸을 무겁게 세는 지표를 고른다. 순서를 거꾸로 밟으면 남이 쓰는 지표를 따라 쓰게 되고, 그 지표가 우리 문제의 비용 구조와 맞는지는 아무도 확인하지 않는다.

보고할 때 지표를 하나로 줄이려는 유혹도 같은 실수의 다른 얼굴이다. 실무에서 무난한 조합은 혼동 행렬 한 장에 클래스별 F1과 MCC를 함께 붙이는 것이다. 행렬은 무슨 일이 있었는지를, 클래스별 F1은 어느 클래스가 무너졌는지를, MCC는 전체가 무작위보다 얼마나 나은지를 각각 말해 준다. 셋이 서로 어긋나면 그 어긋남 자체가 다음에 봐야 할 곳을 가리킨다.

그리고 이 글의 모든 숫자에는 조건이 하나 붙어 있었다. 임계값 하나를 고정했다는 조건이다. 정확도도 F1도 MCC도 특정 임계값에서 만들어진 혼동 행렬 위에서 계산된 값이라, 임계값을 바꾸면 전부 달라진다. 그렇다면 임계값을 고르기 전에 모델 자체의 품질을 재는 방법은 없을까. 다음 글에서는 가능한 모든 임계값을 한꺼번에 훑어 곡선 아래 면적 하나로 요약하는 방식을 다룬다 — 이 글에서 특이도를 짚어 둔 이유이기도 하고, 임계값에 기대지 않고 두 모델을 비교하는 자리다.


읽어주셔서 감사합니다. 😊

LATEST

머신러닝·신경망의 최신 글

머신러닝·신경망2026.05.08

문맥적 임베딩: ELMo부터 BERT까지

정적 임베딩의 다의어 문제를 해결하는 문맥적 임베딩의 원리, ELMo의 양방향 LSTM 레이어 표현, BERT의 트랜스포머 기반 서브워드 임베딩 추출법을 수식과 코드로 완전히 해설한다.

12 MIN
머신러닝·신경망2026.05.08

FastText: 부분 단어로 OOV를 정복하다

FastText가 문자 n-gram 기반의 부분 단어 모델로 OOV 문제를 해결하는 방법, 한국어 형태론에서의 강점, 실전 학습과 추론 코드를 완전히 해설한다.

11 MIN
머신러닝·신경망2026.05.08

GloVe: 전역 공기 통계로 단어 벡터를 만들다

GloVe가 공기 행렬의 전역 통계와 국소 문맥 창의 장점을 결합하는 방법, 목적 함수의 수학적 의미, 사전 학습 벡터 활용법을 깊이 있게 다룬다.

11 MIN