04. 분류 모델 평가, 정밀도·재현율과 임계값
복습 음성 · 12분 03초
분류 결과를 읽을 때는 맞힌 개수와 함께 놓친 양성과 잘못 고른 양성도 확인한다.
Precision과 Recall은 분모가 다르다. 혼동행렬의 개수로 계산한 뒤 같은 점수에서 임계값을 바꿔 F1·PR 곡선·ROC 곡선을 비교했다.
1. 먼저 무엇을 양성으로 볼지 정한다
Positive(양성)는 이번에 찾으려는 클래스다. 좋다는 뜻으로 붙이는 이름은 아니다. 스팸을 찾는 예제라면 스팸을 1, 정상 메일을 0으로 두고 스팸을 양성이라고 부를 수 있다.
이 글에서도 찾으려는 클래스는 1로 둔다. 양성의 의미를 바꾸면 정밀도와 재현율이 설명하는 대상도 바뀐다. 점수만 기록하고 어느 클래스를 기준으로 계산했는지 빼놓으면 비교하기 어렵다.
Binary classification(이진 분류)은 두 클래스 중 하나를 선택한다. 예측 결과가 0 또는 1인지, 선택하기 전의 확률이나 점수인지도 구분한다. 정확도·정밀도·재현율에는 보통 최종 클래스가 들어가고, PR·ROC 곡선에는 여러 임계값을 적용할 수 있는 점수가 필요하다.
2. 혼동행렬의 행과 열부터 읽기
Confusion matrix(혼동행렬)는 실제 정답과 예측을 교차해서 센 표다. scikit-learn에서는 행이 실제 정답, 열이 예측이다. labels=[0, 1]을 지정하면 순서는 아래와 같다.
| 실제 정답 | 예측 0 | 예측 1 |
|---|---|---|
| 실제 0 | TN: 정상으로 맞힘 | FP: 스팸으로 잘못 고름 |
| 실제 1 | FN: 스팸을 놓침 | TP: 스팸을 찾아냄 |
TP는 True Positive, FP는 False Positive다. 뒤의 Positive는 모델이 양성으로 예측했다는 뜻이고, 앞의 True·False가 그 판단이 맞았는지 알려준다. TN과 FN도 같은 방식으로 읽는다.
실제 스팸이 5개, 정상 메일이 15개라고 하자. 스팸 4개를 찾고 정상 메일 3개를 스팸으로 분류했다면 아래처럼 된다.
예측 0 예측 1
실제 0 12 3
실제 1 1 4
TN=12, FP=3, FN=1, TP=4
전체는 20개다. 행을 더하면 실제 클래스별 개수, 열을 더하면 모델이 각 클래스로 예측한 개수가 된다. 이 관계를 먼저 확인하면 숫자를 잘못 읽는 일을 줄일 수 있다.
3. 정확도·정밀도·재현율은 분모가 다르다
Accuracy(정확도)는 전체 중 맞힌 비율이다.
Accuracy = (TP + TN) / 전체 개수
= (4 + 12) / 20 = 0.80
Precision(정밀도)은 양성이라고 고른 것 중 실제 양성의 비율이다. 스팸으로 보낸 메일함에 정상 메일이 얼마나 섞였는지와 연결된다.
Precision = TP / (TP + FP)
= 4 / (4 + 3) ≈ 0.571
Recall(재현율)은 실제 양성 중 찾아낸 비율이다. 찾았어야 할 스팸을 얼마나 놓쳤는지 보는 기준이다. Sensitivity(민감도), TPR(참양성률)이라는 이름도 쓴다.
Recall = TP / (TP + FN)
= 4 / (4 + 1) = 0.80
정밀도는 예측한 양성 7개를, 재현율은 실제 양성 5개를 분모로 삼는다. 둘 다 TP를 쓰지만 질문이 다르다. 정상 메일이 스팸함으로 넘어가는 실수가 중요한지, 스팸을 놓치는 실수가 중요한지에 따라 함께 볼 비중이 달라진다.
음성 클래스 쪽도 같은 방식으로 읽을 수 있다.
Specificity(특이도) = TN / (TN + FP) = 12 / 15 = 0.80
FPR(거짓양성률) = FP / (TN + FP) = 3 / 15 = 0.20
FPR = 1 - Specificity
FPR의 분모는 실제 음성이다. 1 - Precision과는 분모가 다르므로 같은 숫자로 보면 안 된다.
4. 불균형한 자료에서 정확도만 보면 생기는 일
위의 20개를 전부 정상이라고 예측해도 정확도는 15 / 20 = 75%다. 스팸을 하나도 찾지 못해도 제법 높은 숫자가 나온다. 이렇게 클래스 개수가 크게 다른 상황을 Class imbalance(클래스 불균형)라고 한다.
새 모델이 정확도 80%라고 했을 때는 다수 클래스만 고른 기준선 75%와 비교하고, 양성 5개 중 몇 개를 찾았는지도 봐야 한다. 샘플이 20개뿐이라 한 개만 달라져도 정확도는 5%p, 재현율은 20%p 바뀐다는 점도 같이 기억해둔다.
양성을 하나도 예측하지 않으면 정밀도의 분모 TP + FP가 0이다. 이것은 정밀도를 계산할 대상이 없는 경우다. 코드의 zero_division=0은 그 상황에서 반환할 값을 0으로 정하는 설정이다. 오류 없이 숫자가 출력됐더라도 왜 0이 나왔는지 혼동행렬을 같이 읽어야 한다.
5. F1과 F-beta는 무엇을 묶어서 보나
F1은 정밀도와 재현율의 Harmonic mean(조화평균)이다.
F1 = 2 × Precision × Recall / (Precision + Recall)
= 2TP / (2TP + FP + FN)
한쪽이 아주 낮으면 F1도 낮아진다. 정밀도 1.0, 재현율 0.1이면 단순 산술평균은 0.55지만 F1은 약 0.182다. 앞의 혼동행렬에서는 8 / (8 + 3 + 1) ≈ 0.667이다.
F-beta는 두 지표에 주는 비중을 바꾼다. beta=1이면 F1, 1보다 크면 재현율 쪽, 1보다 작으면 정밀도 쪽에 더 비중을 둔다. 식에는 beta의 제곱이 들어간다. fbeta_score 설명
F-beta = (1 + beta²) × Precision × Recall
/ (beta² × Precision + Recall)
예를 들어 F2는 F1보다 놓친 양성을 더 중요하게 평가하는 기준이다. 다만 beta=2를 곧바로 “FN 비용이 FP 비용의 두 배”라고 해석하지는 않는다. 실제 비용이 정해져 있다면 FP와 FN 각각의 비용으로 계산한 기준을 별도로 사용할 수 있다.
beta를 바꾸는 것과 예측을 바꾸는 것은 별개다. 같은 예측에 F1 대신 F2를 계산해도 0과 1의 결과는 그대로다. F2가 좋은 임계값을 찾아 실제 분류에 적용해야 예측 결과가 달라진다.
F1에는 TN이 직접 들어가지 않는다. F1 하나가 같더라도 FP와 FN 구성이 다를 수 있으므로 혼동행렬을 같이 남기는 편이 좋겠다.
6. 임계값을 바꾸면 어떤 숫자가 움직일까
Threshold(임계값)는 연속적인 점수를 최종 클래스로 바꾸는 경계다. 여기서는 점수가 높을수록 양성에 가깝다고 보고, 임계값 이상이면 1로 정한다.
점수 >= 임계값 → 1
점수 < 임계값 → 0
같은 자료와 점수에서 임계값을 낮추면 양성으로 고르는 수가 늘거나 유지된다. TP와 FP는 줄지 않고, FN과 TN은 늘지 않는다. 따라서 실제 양성이 존재하면 재현율은 내려가지 않는다.
정밀도는 꼭 한 방향으로 움직이지 않는다. 새로 고른 샘플 중 양성과 음성이 어떤 비율로 섞였는지에 따라 오르거나 내릴 수 있다. “임계값을 낮추면 재현율이 높아지고 정밀도는 낮아지는 경향이 있다”는 설명을 모든 단계에서 성립하는 규칙처럼 외우지는 않으려고 한다.
다음은 양성 5개와 음성 15개에 직접 점수를 붙인 계산 예제다. 모델 학습 결과나 보정된 확률을 뜻하지 않는다. 임계값을 바꿨을 때 오류 개수가 어떻게 달라지는지 확인하려고 만들었다.
import numpy as np
from sklearn.metrics import (
accuracy_score, average_precision_score, auc, confusion_matrix,
f1_score, fbeta_score, precision_recall_curve, precision_score,
recall_score, roc_auc_score,
)
# 지표 계산용으로 만든 20개 정답과 점수. 1이 관심 클래스다.
# 실제 모델의 검증·테스트 성능을 나타내는 데이터는 아니다.
y = np.array([1, 0, 1, 1, 0, 0, 1, 0, 0, 1,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
scores = np.array([.96, .92, .87, .82, .78, .72, .68, .63, .58, .52,
.46, .41, .36, .32, .28, .23, .19, .14, .09, .03])
print(f"양성 비율: {y.mean():.2f}")
print(f"전부 0으로 예측한 정확도: {accuracy_score(y, np.zeros_like(y)):.3f}")
for threshold in [0.90, 0.65, 0.50]:
pred = (scores >= threshold).astype(int)
tn, fp, fn, tp = confusion_matrix(y, pred, labels=[0, 1]).ravel()
print(f"threshold={threshold:.2f}: TN={tn} FP={fp} FN={fn} TP={tp}")
print(f" Accuracy={accuracy_score(y, pred):.3f} "
f"Precision={precision_score(y, pred, zero_division=0):.3f} "
f"Recall={recall_score(y, pred):.3f}")
print(f" F1={f1_score(y, pred):.3f} "
f"F2={fbeta_score(y, pred, beta=2):.3f}")
# 곡선 전체를 평가할 때는 잘라낸 0/1 대신 연속 점수를 넣는다.
precision, recall, _ = precision_recall_curve(y, scores)
print(f"AP: {average_precision_score(y, scores):.4f}")
print(f"사다리꼴 PR AUC: {auc(recall, precision):.4f}")
print(f"ROC AUC: {roc_auc_score(y, scores):.4f}")
실행 결과:
양성 비율: 0.25
전부 0으로 예측한 정확도: 0.750
threshold=0.90: TN=14 FP=1 FN=4 TP=1
Accuracy=0.750 Precision=0.500 Recall=0.200
F1=0.286 F2=0.227
threshold=0.65: TN=12 FP=3 FN=1 TP=4
Accuracy=0.800 Precision=0.571 Recall=0.800
F1=0.667 F2=0.741
threshold=0.50: TN=10 FP=5 FN=0 TP=5
Accuracy=0.750 Precision=0.500 Recall=1.000
F1=0.667 F2=0.833
AP: 0.6976
사다리꼴 PR AUC: 0.6599
ROC AUC: 0.8667
임계값 0.90에서는 양성 다섯 개 중 하나만 찾았다. 0.65로 낮추면 네 개, 0.50에서는 다섯 개를 모두 찾는다. 그동안 FP도 1개, 3개, 5개로 늘었다.
0.65와 0.50의 F1은 둘 다 약 0.667이다. 하지만 전자는 한 개를 놓치고 세 개를 잘못 고르며, 후자는 놓치는 건 없고 다섯 개를 잘못 고른다. 재현율에 비중을 더 둔 F2에서는 0.50의 점수가 더 높다. 같은 F1이라도 실제 오류는 다를 수 있다는 걸 이 숫자로 확인했다.
7. 임계값은 검증 데이터에서 고른다
위의 세 값을 비교했다고 곧바로 0.50이 좋은 임계값이라는 결론을 내릴 수는 없다. 어떤 오류가 더 중요한지 먼저 정해야 한다. 예를 들어 “재현율 0.8 이상을 만족하는 후보 중 정밀도가 높은 값”처럼 조건을 둘 수 있다.
실제 모델에서는 다음 순서로 나눈다.
- 학습 데이터로 모델과 전처리 기준을 학습한다.
- 검증 데이터의 점수에서 임계값 후보를 비교한다.
- 목적에 맞는 기준으로 임계값을 고정한다.
- 마지막 테스트 데이터에 고정한 모델과 임계값을 적용한다.
테스트 정답을 보면서 임계값을 다시 바꾸면 테스트가 선택 과정에 들어간다. 데이터가 적다면 교차 검증을 활용할 수 있지만, 모델 학습과 임계값 선택에 같은 학습 결과를 그대로 재사용하는 문제를 피해야 한다. scikit-learn 임계값 조정 안내
모델을 다시 학습하거나 실제 데이터의 분포가 달라지면 점수의 성질도 달라질 수 있다. 임계값 숫자만 옮기기보다 어떤 모델과 자료에서 선택했는지 같이 기록한다.
8. 확률과 결정 점수를 구분하기
predict()는 최종 클래스를 준다. predict_proba()는 클래스별 확률을 주며, 보통 (샘플 수, 클래스 수) 형태다. 두 번째 열을 쓰기 전에 model.classes_에서 그 열이 내가 찾는 클래스인지 확인한다.
decision_function()은 결정 점수를 준다. 예를 들어 이진 SVM의 점수는 확률이 아니며 0보다 작거나 1보다 클 수 있다. 일반적인 이진 분류에서 확률은 0.5, 결정 점수는 0을 기본 경계로 사용하는 경우가 많지만, 쓰는 모델의 동작을 확인해야 한다.
높은 점수가 양성을 앞쪽에 놓는지와 확률이 실제 비율에 맞는지는 다른 질문이다. 예측 확률 0.8인 샘플을 많이 모았을 때 실제 양성 비율도 약 80%인지 보는 것이 Calibration(확률 보정·보정 상태 평가)과 연결된다.
순위를 잘 매기는 모델도 확률을 과하게 자신할 수 있다. 따라서 ROC AUC가 높다는 이유만으로 확률 0.8을 실제 발생 가능성 80%라고 믿지는 않는다. 이번 예제의 고정 점수로는 확률 보정 성능을 평가하지 않았다.
9. PR 곡선과 AP는 임계값 전체를 요약한다
Precision-recall curve(PR 곡선)는 임계값을 움직이면서 얻은 재현율과 정밀도를 그린다. 가로축이 Recall, 세로축이 Precision이다. 곡선의 한 점은 특정 임계값에서의 결과다.
양성을 드물게 찾는 문제에서는, 찾아낸 것에 잘못 고른 음성이 얼마나 섞이는지 PR 곡선으로 확인하기 좋다. 양성 비율이 바뀌면 정밀도도 달라질 수 있으므로 서로 다른 데이터의 PR 점수를 맥락 없이 비교하지 않는다. 무작위 순위의 기준 수준을 생각할 때는 양성 비율을 참고한다. 이번 자료의 양성 비율은 0.25다.
AP(Average precision, 평균 정밀도)는 재현율이 늘어난 구간의 크기로 정밀도를 가중해 합친 값이다. scikit-learn의 average_precision_score()는 점 사이를 사다리꼴로 연결해 면적을 구하는 계산과 다르다. AP 계산 방식
이번 예제의 AP = 0.6976
사다리꼴로 계산한 PR AUC = 0.6599
둘을 모두 “PR 곡선 아래 면적” 정도로만 적으면 같은 결과라고 오해하기 쉽다. 보고할 때는 AP인지, auc(recall, precision)으로 계산한 값인지 함수와 함께 적어둔다.
10. ROC 곡선과 AUC를 읽는 기준
ROC 곡선의 가로축은 FPR, 세로축은 TPR이다. TPR은 재현율과 같다. 양성을 얼마나 찾았는지와 실제 음성 중 얼마를 잘못 골랐는지를 함께 본다.
x축: FP / 실제 음성 수
y축: TP / 실제 양성 수
왼쪽 위에 가까울수록 양성을 많이 찾으면서 음성을 적게 잘못 고른다. ROC AUC(곡선 아래 면적)는 이런 순위 구분 능력을 요약한다. 이진 분류에서는 무작위로 뽑은 양성 하나가 음성 하나보다 높은 점수를 받을 가능성으로 해석할 수 있고, 동점은 절반으로 센다. 무작위 순위의 기대 수준은 0.5다.
위 예제의 ROC AUC는 0.8667이다. 양성·음성 쌍 75개에서 양성 점수가 더 높은 쌍이 65개여서 65 / 75가 된다. 임계값을 0.65로 정했을 때의 정확도 0.80과는 다른 계산이다.
ROC AUC를 계산할 때 최종 0·1 예측만 넣으면 점수의 순위 정보 대부분을 잃는다. roc_auc_score(y, scores)처럼 양성에 대한 점수를 넣는다. roc_auc_score 입력 설명
음성이 아주 많으면 작은 FPR도 실제로는 큰 FP 개수일 수 있다. 예를 들어 음성 10,000개에서 FPR 0.01은 FP 100개다. ROC AUC와 함께 양성 비율, PR 지표, 선택한 임계값에서의 실제 개수를 확인해야 운영 결과를 설명할 수 있다.
11. 클래스가 여러 개면 평균 방식도 적는다
Multiclass classification(다중 클래스 분류)에서는 각 클래스를 양성으로 보고 나머지를 묶어 Precision·Recall·F1을 계산할 수 있다. 이렇게 얻은 클래스별 점수를 합칠 때 평균 방법이 달라진다.
| 평균 방법 | 계산에서 하는 일 |
|---|---|
| macro | 클래스별 점수를 같은 비중으로 평균 |
| weighted | 실제 클래스별 샘플 수로 가중 평균 |
| micro | 전체 클래스의 TP·FP·FN을 먼저 합친 뒤 계산 |
macro는 작은 클래스도 같은 비중으로 본다. weighted는 많은 클래스의 영향이 커서 소수 클래스의 낮은 점수가 덜 드러날 수 있다. classification_report()에서 평균만 읽지 말고 클래스별 점수와 Support(실제 샘플 수)를 같이 본다.
한 샘플이 정확히 한 클래스에 속하는 분류에서 모든 클래스를 포함하면 micro Precision·Recall·F1은 정확도와 같아진다. 여러 레이블을 동시에 갖는 Multilabel(다중 레이블) 문제나 일부 클래스만 계산하는 경우에는 이 관계를 그대로 적용하면 안 된다.
12. 점수를 남길 때 같이 적을 것
평가 결과에 분모와 판단 기준을 같이 적어두면 다시 읽기 편하다.
- 찾으려는 양성 클래스와 클래스별 샘플 수
- 학습·검증·테스트를 나눈 방법
- 점수의 종류와 임계값을 고른 기준
- 혼동행렬, 정밀도·재현율·F1, 필요하면 AP와 ROC AUC
- 다중 클래스라면 평균 방식
정밀도는 내가 고른 것의 정확함, 재현율은 찾아야 할 것의 회수율이다. 임계값은 실제 선택을 바꾸고, F-beta는 그 선택을 채점하는 비중을 바꾼다. 같은 점수 뒤에도 서로 다른 오류가 있다.
AIFFEL 수업과 개인 복습 노트를 참고했다. 위 음성은 복습용 TTS(음성 합성)이며 본문의 20개 샘플과 실행 결과는 별도로 작성했다. 음성 속 강의 순서와 블로그 회차는 다를 수 있다.
예제 실행 환경: Python 3.12.9, NumPy 2.5.2, scikit-learn 1.9.0. 2026년 9월 28일에 지표 계산과 혼동행렬을 확인했다. 모델 학습, 실제 임계값 선택, 확률 보정 학습은 이번 예제의 범위에 포함하지 않았다.