포스트

머신러닝 기초 (7) - 분류 평가: 혼동행렬과 ROC-AUC

정확도가 왜 불균형 데이터에서 무너지는지에서 출발해, 혼동행렬로 오류를 나눠 보고 precision, recall, F1과 임계값 조정, ROC-AUC까지 분류 모델을 평가하는 지표를 정리한 7편입니다.

머신러닝 기초 (7) - 분류 평가: 혼동행렬과 ROC-AUC

머신러닝 기초 시리즈의 7편입니다. 6편의 “규제: Ridge와 Lasso”에 이어집니다.

6편까지는 모델을 잘 학습시키는 방법을 다뤘습니다. 이제 방향을 바꿔, 학습이 끝난 모델이 좋은지 나쁜지를 무엇으로 판단하는가를 봅니다. 회귀의 평가 지표는 8편에서 다루고, 이번 편은 분류입니다.

분류 모델의 성능을 물으면 대부분 정확도(accuracy)를 먼저 떠올립니다. 맞힌 비율이니 직관적이고, model.score()가 기본으로 돌려주는 값이기도 합니다. 그런데 정확도 하나로는 부족하고, 어떤 상황에서는 위험하기까지 합니다. 왜 그런지부터 짚습니다.

정확도의 함정

정확도는 전체 예측 중 맞힌 비율입니다. 문제는 이 값이 다수 클래스에 지배된다는 데 있습니다.

신용카드 사기 탐지를 생각해봅니다. 전체 거래의 0.2%만 사기라고 하면, “모든 거래는 정상”이라고 답하는 모델은 정확도 99.8%를 얻습니다. 숫자만 보면 훌륭하지만, 이 모델이 잡아낸 사기는 0건입니다. 정확도가 높은 이유는 모델이 똑똑해서가 아니라 정답의 대부분이 한쪽으로 쏠려 있어서입니다.

실제 데이터로 확인해봅니다. 이번 편 내내 유방암 진단 데이터(load_breast_cancer)를 씁니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.dummy import DummyClassifier

X, y = load_breast_cancer(return_X_y=True)
# 원본 레이블은 악성=0, 양성(benign, 정상 종양)=1이다.
# 탐지 대상인 '암(악성)'을 positive(1)로 두기 위해 뒤집는다.
y = 1 - y   # 1 = 악성(암), 0 = 정상

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=42)

# 스케일링은 12편에서 다룬다. 여기서는 로지스틱 회귀가
# 안정적으로 수렴하도록만 붙였고, 지표 해석에는 영향이 없다.
scaler = StandardScaler().fit(X_train)
X_train = scaler.transform(X_train)
X_test = scaler.transform(X_test)

clf = LogisticRegression(max_iter=5000, random_state=42).fit(X_train, y_train)
clf.score(X_test, y_test)   # 정확도 = 0.9708

# 무조건 다수 클래스(정상)라고만 답하는 모델
dummy = DummyClassifier(strategy="most_frequent").fit(X_train, y_train)
dummy.score(X_test, y_test)  # 0.6257, 아무것도 안 배워도 이만큼

이진분류에서 어느 클래스가 positive(1)인지는 지표의 의미를 통째로 바꾼다. load_breast_cancer는 악성이 0이므로, 관심 대상을 positive로 두려면 위처럼 레이블을 확인하고 맞춰야 한다. 뒤에 나올 recall, ROC-AUC가 모두 positive 클래스를 기준으로 계산된다.

정확도 97%는 좋아 보이지만, 아무것도 배우지 않은 모델도 62.6%를 받는다는 점을 함께 봐야 의미가 생깁니다. 그리고 더 중요한 사실은, 정확도는 모델이 어떤 종류의 오류를 내는지를 숨긴다는 것입니다. 암을 놓친 오류와 정상을 암이라 오해한 오류는 무게가 전혀 다른데, 정확도는 둘을 뭉뚱그려 하나의 숫자로 만듭니다. 그래서 오류를 종류별로 나눠 보는 것에서 출발합니다.

오류를 나눠 보기: 혼동행렬

이진분류의 예측 결과는 실제 정답과 대조하면 네 가지로 나뉩니다.

  • TP(True Positive): 암을 암이라 예측. 맞음.
  • TN(True Negative): 정상을 정상이라 예측. 맞음.
  • FP(False Positive): 정상을 암이라 예측. 거짓 경보.
  • FN(False Negative): 암을 정상이라 예측. 놓친 암.

이 네 개를 표로 정리한 것이 혼동행렬(confusion matrix)입니다. 정확도는 (TP + TN) / 전체, 즉 대각선의 두 칸만 보는 값이라 FP와 FN을 구분하지 못합니다.

1
2
3
4
5
6
7
8
9
10
11
from sklearn.metrics import confusion_matrix

y_pred = clf.predict(X_test)
confusion_matrix(y_test, y_pred)
# array([[106,   1],    행 = 실제, 열 = 예측
#        [  4,  60]])
#
# [0,0]=106  TN: 정상을 정상으로
# [0,1]=1    FP: 정상을 암으로 (거짓 경보 1건)
# [1,0]=4    FN: 암을 정상으로 (놓친 암 4건)
# [1,1]=60   TP: 암을 암으로

sklearn의 혼동행렬은 행이 실제, 열이 예측이고 레이블 오름차순(0, 1)으로 배열됩니다. 이 모델은 실제 암 64건 중 60건을 잡고 4건을 놓쳤습니다(FN=4). 정확도 97%라는 한 숫자에는 이 “놓친 암 4건”이 드러나지 않습니다.

precision과 recall, 그리고 F1

FP와 FN을 구분했으니, 이제 관심 있는 오류를 겨냥한 지표를 정의합니다. 둘 다 positive 클래스(여기서는 암)를 기준으로 합니다.

  • precision(정밀도) = TP / (TP + FP). 암이라고 예측한 것들 중 실제로 암인 비율. “경보가 얼마나 믿을 만한가.”
  • recall(재현율) = TP / (TP + FN). 실제 암들 중 모델이 잡아낸 비율. “놓치지 않는 능력.”

이름은 비슷해 보여도 분모가 다릅니다. precision의 분모는 예측한 positive(TP+FP)이고, recall의 분모는 실제 positive(TP+FN)입니다. 그래서 둘은 보통 반대로 움직입니다. 의심스러우면 무조건 암이라 판정하면 놓치는 암은 줄어 recall이 오르지만, 거짓 경보가 늘어 precision이 떨어집니다.

둘을 하나로 요약하고 싶을 때 쓰는 것이 F1 점수입니다. precision과 recall의 조화평균, 즉 2 * precision * recall / (precision + recall)입니다. 산술평균이 아니라 조화평균을 쓰는 이유는, 한쪽이 크게 낮으면 F1도 크게 낮아지도록, 두 값이 모두 높을 때만 F1이 높도록 만들기 위해서입니다.

classification_report가 이 세 지표를 클래스별로 한 번에 보여줍니다.

1
2
3
4
5
6
7
8
9
10
11
from sklearn.metrics import classification_report

print(classification_report(y_test, y_pred, target_names=["정상", "악성"], digits=3))
#               precision    recall  f1-score   support
#
#           정상      0.964     0.991     0.977       107
#           악성      0.984     0.938     0.960        64
#
#     accuracy                          0.971       171
#    macro avg      0.974     0.964     0.968       171
# weighted avg      0.971     0.971     0.971       171

악성 행을 읽습니다. precision 0.984는 암이라 예측한 61건 중 60건이 실제 암이었다는 뜻이고, recall 0.938은 실제 암 64건 중 60건을 잡았다는 뜻입니다. support는 각 클래스의 실제 표본 수입니다. 정확도(0.971) 하나였다면 보이지 않았을 “recall 0.938 = 암 4건을 놓쳤다”가 여기서 드러납니다.

임계값은 정답이 아니라 선택이다

predict()가 0/1을 돌려주지만, 모델이 내부에서 계산하는 것은 사실 확률입니다. 로지스틱 회귀는 각 표본이 positive일 확률을 내고, 그 확률이 0.5를 넘으면 1로 판정합니다. 이 0.5는 수학이 정해준 값이 아니라 관례로 정한 기본 임계값일 뿐입니다.

임계값을 바꾸면 precision과 recall이 함께 움직입니다. 확률은 predict_proba로 직접 꺼낼 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
from sklearn.metrics import precision_score, recall_score

proba = clf.predict_proba(X_test)[:, 1]   # 각 표본이 악성(암)일 확률

for t in [0.5, 0.3, 0.1]:
    y_t = (proba >= t).astype(int)        # 임계값 t로 자른 예측
    p = precision_score(y_test, y_t)
    r = recall_score(y_test, y_t)
    fn = confusion_matrix(y_test, y_t)[1, 0]   # 놓친 암(FN)
    print(f"임계값 {t}: precision={p:.3f}  recall={r:.3f}  놓친 암={fn}")
# 임계값 0.5: precision=0.984  recall=0.938  놓친 암=4
# 임계값 0.3: precision=0.984  recall=0.969  놓친 암=2
# 임계값 0.1: precision=0.900  recall=0.984  놓친 암=1

임계값을 낮출수록 더 쉽게 암으로 판정하므로 놓치는 암(FN)이 줄고 recall이 오릅니다. 0.1까지 내리면 놓친 암은 1건으로 줄지만, 거짓 경보가 늘어 precision이 0.90으로 떨어집니다. 어느 임계값이 옳은지는 이 코드가 답해주지 않습니다. recall을 얼마나 확보하고 precision을 얼마나 포기할 것인가는 문제가 정합니다.

임계값을 바꾸는 것은 모델을 다시 학습시키는 것이 아니다. 학습으로 정해진 확률은 그대로 두고, 그 확률을 0/1로 바꾸는 결정 규칙만 조정하는 것이다.

ROC 곡선과 AUC

임계값을 하나 고르는 대신, 0부터 1까지 전부 훑으면 어떨까요. 각 임계값마다 두 값을 계산합니다.

  • TPR(True Positive Rate) = recall = TP / (TP + FN). 실제 암을 잡은 비율.
  • FPR(False Positive Rate) = FP / (FP + TN). 정상을 암이라 잘못 부른 비율.

임계값을 옮기며 (FPR, TPR) 점을 이으면 곡선이 그려집니다. 이것이 ROC 곡선입니다. 그 아래 넓이가 AUC(Area Under the Curve)이고, 이 한 숫자로 모델을 요약합니다.

  • AUC = 0.5: 무작위 추측과 같음(대각선).
  • AUC = 1.0: 모든 positive를 모든 negative보다 높은 확률로 매김. 완벽.

AUC에는 깔끔한 해석이 있습니다. 무작위로 고른 암 표본이 무작위로 고른 정상 표본보다 높은 확률을 받을 확률입니다. 즉 특정 임계값의 성능이 아니라, 모델이 두 클래스를 확률로 얼마나 잘 순위 매기는지를 잽니다. 임계값에 독립적이라 모델끼리 비교할 때 특히 유용합니다.

1
2
3
from sklearn.metrics import roc_auc_score

roc_auc_score(y_test, proba)   # 0.9975

여기서 흔한 실수가 하나 있습니다. roc_auc_score에는 예측 레이블(y_pred)이 아니라 확률/점수(proba)를 넣어야 합니다. 레이블을 넣으면 임계값이 하나로 고정되어 곡선의 의미가 사라집니다.

불균형이 심한 데이터에서는 ROC가 지나치게 낙관적으로 보일 수 있습니다. negative가 압도적으로 많으면 FPR이 잘 오르지 않아 곡선이 쉽게 좋아 보이기 때문입니다. 이럴 때는 precision과 recall을 직접 이은 PR 곡선과 그 아래 넓이(average_precision_score)를 함께 보는 편이 정직합니다.

1
2
3
from sklearn.metrics import average_precision_score

average_precision_score(y_test, proba)   # PR 곡선 아래 넓이 = 0.9963

어떤 지표를 볼지는 문제가 정한다

지금까지 정확도, precision, recall, F1, AUC를 봤습니다. 그럼 무엇을 봐야 할까요. 정답은 지표 안에 없고 문제 안에 있습니다. 기준은 두 오류(FP와 FN) 중 어느 쪽이 더 비싼가입니다.

  • 암 진단: 암을 놓치는 FN은 생명과 직결되고, 정상을 암이라 부르는 FP는 추가 검사로 끝납니다. FN이 훨씬 비싸므로 recall을 중시하고, 필요하면 앞에서처럼 임계값을 낮춰 recall을 끌어올립니다.
  • 스팸 필터: 정상 메일을 스팸으로 보내는 FP가 스팸 하나를 놓치는 FN보다 치명적일 수 있습니다. 이 경우엔 반대로 precision을 중시합니다.
  • 두 오류의 비용이 비슷하면 F1처럼 균형 잡힌 지표를, 클래스 불균형과 비용까지 반영하려면 임계값을 직접 골라 씁니다.

정확도 하나로 모델을 판단하지 않는 이유가 여기 있습니다. 좋은 모델인지는 통계량이 아니라, 그 모델이 실제로 어떤 오류를 어떤 대가로 내는지가 정합니다. 평가 지표를 고르는 일은 그 대가를 명시하는 일입니다.

정리

개념한 줄 요약
정확도의 함정다수 클래스에 지배됨. 불균형 데이터에서 무너지고 오류 종류를 숨긴다
혼동행렬예측을 TP, TN, FP, FN으로 나눔. 행=실제, 열=예측
precisionTP/(TP+FP). 암이라 한 것 중 진짜 비율, 경보의 신뢰성
recallTP/(TP+FN). 실제 암 중 잡은 비율, 놓치지 않는 능력
F1precision과 recall의 조화평균. 둘 다 높아야 높다
임계값0.5는 관례일 뿐. 낮추면 recall↑ precision↓, 모델은 그대로
ROC-AUC모든 임계값을 요약. 0.5=무작위, 1.0=완벽. proba를 넣는다
PR 곡선불균형이 심하면 ROC보다 정직. average_precision_score
지표 선택FP와 FN 중 무엇이 더 비싼가, 문제가 정한다

다음 편에서는 분류가 아닌 회귀의 평가로 넘어가, RMSE와 R²이 각각 무엇을 재는지, 잔차를 어떻게 읽는지를 정리합니다.

다음 글: 머신러닝 기초 (8) - 회귀 평가: RMSE, R², 잔차

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.