본문 바로가기
TYLER SONGBlog
← Blog index
LLM Research11 min read

ML 평가지표 · Tree 계열 · 실험 루프: 정확도 하나만 보면 모델을 잘못 판단한다

분류 모델의 예측을 혼동행렬로 정리하고 Accuracy·Precision·Recall·F1을 직접 계산한다. 결정 임계값을 바꾸며 정밀도와 재현율이 어떻게 충돌하는지 확인한다. Decision Tree·Random Forest·XGBoost의 핵심 아이디어를 익히고, 가설 수립부터 오류 분석까지 작은 실험 한 번을 완주한다.

By 송민성

오늘의 목표

분류 모델의 성능을 정확도 하나로 판단하지 않고, 정밀도·재현율·F1·ROC-AUC를 목적에 맞게 해석한다. 작은 예측 데이터를 이용해 기준선 설정부터 가설 검증과 오류 분석까지 실험 절차를 한 번 완주한다.

개념과 직관

1. 혼동행렬

혼동행렬(confusion matrix, 실제 정답과 모델 예측의 조합을 표로 정리한 것)은 분류 지표의 출발점이다.

스팸 메일 탐지를 예로 들자.

  • 양성(positive): 스팸
  • 음성(negative): 정상 메일
  • 참 양성(True Positive, TP): 스팸을 스팸으로 예측
  • 거짓 양성(False Positive, FP): 정상 메일을 스팸으로 잘못 예측
  • 거짓 음성(False Negative, FN): 스팸을 정상으로 놓침
  • 참 음성(True Negative, TN): 정상 메일을 정상으로 예측

그림으로 보면 다음과 같다.

실제 \ 예측스팸정상
스팸TPFN
정상FPTN

거짓은 모델이 틀렸다는 뜻이다. 양성과 음성은 모델이 예측한 방향을 기준으로 읽으면 쉽다.

2. 정확도

정확도(accuracy, 전체 예측 중 맞힌 비율)는 다음과 같다.

Accuracy = (TP + TN) / 전체 데이터 수

환자 100명 중 실제 환자가 1명뿐이라고 하자. 모든 사람을 정상으로 예측해도 정확도는 99%다. 하지만 환자를 한 명도 찾지 못했다. 데이터가 불균형할 때 정확도만 보면 이런 모델을 좋은 모델로 착각할 수 있다.

3. 정밀도

정밀도(precision, 모델이 양성이라고 예측한 것 중 실제 양성의 비율)는 다음과 같다.

Precision = TP / (TP + FP)

정밀도가 높으면 모델의 양성 예측을 믿기 쉽다. 정상 메일을 스팸함으로 보내는 실수가 치명적이라면 정밀도가 중요하다.

4. 재현율

재현율(recall, 실제 양성 중 모델이 찾아낸 비율)은 다음과 같다.

Recall = TP / (TP + FN)

재현율이 높으면 실제 양성을 적게 놓친다. 암 환자나 위험 콘텐츠를 놓치는 비용이 크다면 재현율을 우선할 수 있다.

5. 정밀도와 재현율의 상충 관계

모델은 보통 0과 1 사이의 점수를 출력한다. 이 점수를 결정 임계값(threshold, 양성으로 판정하는 기준점)과 비교해 최종 예측을 만든다.

text
점수 >= 임계값 → 양성
점수 < 임계값  → 음성

건물 입구의 보안 검색대를 떠올리자.

  • 검사 기준을 느슨하게 하면 많은 사람을 통과시킨다.
  • 검사 기준을 엄격하게 하면 위험 인물을 더 잘 막지만 정상인도 붙잡을 수 있다.

분류 모델에서는 임계값을 낮추면 양성 예측이 많아진다. 보통 재현율은 올라가지만 거짓 양성도 늘어 정밀도가 내려갈 수 있다. 임계값을 높이면 반대 현상이 나타난다.

6. F1 점수

F1 점수(F1 score, 정밀도와 재현율을 함께 반영한 조화평균)는 다음과 같다.

F1 = 2 × Precision × Recall / (Precision + Recall)

정밀도와 재현율 중 하나가 매우 낮으면 F1도 낮아진다.

예를 들어 정밀도가 1.0이고 재현율이 0.1이면 산술평균은 0.55지만 F1은 약 0.18이다. 한쪽만 높은 모델에 높은 점수를 주지 않는 방식이다.

7. ROC-AUC

ROC 곡선(Receiver Operating Characteristic curve, 임계값을 바꿀 때 참 양성 비율과 거짓 양성 비율의 변화를 그린 곡선)은 다음 두 값을 사용한다.

  • 세로축: 참 양성 비율, 즉 재현율
  • 가로축: 거짓 양성 비율 FP / (FP + TN)

ROC-AUC(Area Under the ROC Curve, ROC 곡선 아래 면적)는 모델이 양성 샘플에 음성 샘플보다 높은 점수를 주는 능력을 요약한다.

직관적으로 양성 샘플 하나와 음성 샘플 하나를 무작위로 뽑았을 때, 모델이 양성에 더 높은 점수를 줄 가능성과 연결해 이해할 수 있다.

  • 1에 가까울수록 순위를 잘 구분한다.
  • 0.5에 가까우면 무작위 추측과 비슷하다.
  • ROC-AUC가 높아도 특정 임계값의 정밀도나 재현율이 업무 목표에 맞는다는 보장은 없다.

8. Tree 계열 모델

Decision Tree

의사결정나무(Decision Tree, 질문을 연속으로 던져 데이터를 나누는 모델)는 스무고개와 비슷하다.

text
점수가 0.5 이상인가?
├─ 예: 길이가 100 이상인가?
│  ├─ 예: 양성
│  └─ 아니오: 음성
└─ 아니오: 음성

각 질문은 데이터가 더 잘 구분되도록 선택된다. 나무가 너무 깊어지면 훈련 데이터를 외우기 쉬워진다. 이를 과적합(overfitting, 훈련 데이터에는 잘 맞지만 새로운 데이터에서는 성능이 떨어지는 현상)이라고 한다.

Random Forest

랜덤 포레스트(Random Forest, 서로 조금씩 다른 여러 의사결정나무의 결과를 합치는 모델)는 여러 심사위원에게 독립적으로 판정을 맡기는 방식과 비슷하다.

각 나무는 데이터와 입력 특징의 일부를 무작위로 사용한다. 한 나무의 실수가 다른 나무의 판단으로 완화될 수 있어 단일 나무보다 안정적인 경우가 많다.

XGBoost

XGBoost(eXtreme Gradient Boosting, 앞선 나무의 실수를 다음 나무가 보완하도록 순서대로 학습하는 부스팅 모델)는 오답 노트를 반복하는 과정과 비슷하다.

첫 나무가 틀린 사례에 다음 나무가 더 집중한다. 작은 나무들을 차례로 더해 전체 예측을 개선한다. 강력하지만 나무 깊이, 학습 속도 같은 설정을 잘못 고르면 과적합될 수 있다.

세 모델의 차이를 한 줄로 정리하면 다음과 같다.

  • Decision Tree: 질문 하나의 흐름
  • Random Forest: 여러 나무의 투표
  • XGBoost: 이전 나무의 실수를 다음 나무가 보완

9. 실험 루프

머신러닝 실험은 다음 순서로 반복한다.

  1. 기준선(Baseline): 비교 대상이 될 가장 단순한 결과를 만든다.
  2. 가설(Hypothesis): 무엇을 바꾸면 어떤 지표가 좋아질지 한 문장으로 적는다.
  3. 실험(Experiment): 한 번에 한 요소만 바꾼다.
  4. 평가(Evaluation): 미리 정한 지표로 기준선과 비교한다.
  5. 오류 분석(Error Analysis): FP와 FN 사례를 직접 살펴 다음 가설을 만든다.

예시 가설:

> 임계값을 0.5에서 0.3으로 낮추면 놓치는 양성이 줄어 재현율이 높아질 것이다.

좋은 실험은 결과가 좋지 않아도 의미가 있다. 가설이 틀렸다는 사실과 어떤 오류가 늘었는지를 기록할 수 있기 때문이다.

코드로 직접 해보기

1. NumPy로 지표 직접 계산하기

python
import numpy as np # 1은 양성, 0은 음성 y_true = np.array([0, 1, 0, 1, 1, 0, 1, 0, 1, 0]) y_pred = np.array([0, 1, 0, 1, 1, 0, 0, 1, 1, 0]) tp = np.sum((y_true == 1) & (y_pred == 1)) fp = np.sum((y_true == 0) & (y_pred == 1)) fn = np.sum((y_true == 1) & (y_pred == 0)) tn = np.sum((y_true == 0) & (y_pred == 0)) accuracy = (tp + tn) / len(y_true) precision = tp / (tp + fp) recall = tp / (tp + fn) f1 = 2 * precision * recall / (precision + recall) print("TP, FP, FN, TN:", tp, fp, fn, tn) print("Accuracy:", round(accuracy, 3)) print("Precision:", round(precision, 3)) print("Recall:", round(recall, 3)) print("F1:", round(f1, 3))

실행 결과:

text
TP, FP, FN, TN: 4 1 1 4
Accuracy: 0.8
Precision: 0.8
Recall: 0.8
F1: 0.8

&는 두 조건이 모두 참인 위치를 찾는다. 각 조건을 괄호로 감싸지 않으면 연산 순서 때문에 오류가 나거나 예상과 다른 결과가 나올 수 있다.

2. 0으로 나누는 경우 처리하기

모델이 양성을 하나도 예측하지 않으면 TP + FP가 0이 된다. 안전한 함수를 만들어 처리한다.

python
import numpy as np def classification_metrics(y_true, y_pred): tp = np.sum((y_true == 1) & (y_pred == 1)) fp = np.sum((y_true == 0) & (y_pred == 1)) fn = np.sum((y_true == 1) & (y_pred == 0)) tn = np.sum((y_true == 0) & (y_pred == 0)) accuracy = (tp + tn) / len(y_true) precision = tp / (tp + fp) if tp + fp > 0 else 0.0 recall = tp / (tp + fn) if tp + fn > 0 else 0.0 f1 = ( 2 * precision * recall / (precision + recall) if precision + recall > 0 else 0.0 ) return { "TP": int(tp), "FP": int(fp), "FN": int(fn), "TN": int(tn), "Accuracy": accuracy, "Precision": precision, "Recall": recall, "F1": f1, } y_true = np.array([0, 1, 0, 1]) y_pred = np.array([0, 0, 0, 0]) print(classification_metrics(y_true, y_pred))

3. sklearn 결과와 비교하기

사이킷런(scikit-learn, 머신러닝 모델과 평가 도구를 제공하는 파이썬 라이브러리)의 계산 결과와 직접 만든 결과를 비교한다.

python
import numpy as np from sklearn.metrics import ( accuracy_score, precision_score, recall_score, f1_score, confusion_matrix, roc_auc_score, ) y_true = np.array([0, 1, 0, 1, 1, 0, 1, 0, 1, 0]) y_score = np.array([0.10, 0.80, 0.40, 0.70, 0.55, 0.30, 0.45, 0.60, 0.90, 0.20]) threshold = 0.5 y_pred = (y_score >= threshold).astype(int) print("혼동행렬:") print(confusion_matrix(y_true, y_pred)) print("Accuracy:", accuracy_score(y_true, y_pred)) print("Precision:", precision_score(y_true, y_pred, zero_division=0)) print("Recall:", recall_score(y_true, y_pred, zero_division=0)) print("F1:", f1_score(y_true, y_pred, zero_division=0)) print("ROC-AUC:", roc_auc_score(y_true, y_score))

ROC-AUC에는 y_pred가 아니라 연속적인 점수인 y_score를 전달한다. 0과 1로 이미 잘라낸 결과를 넣으면 다양한 임계값에서의 순위 정보를 잃는다.

4. 기준선부터 오류 분석까지 한 번 실행하기

python
import numpy as np y_true = np.array([0, 1, 0, 1, 1, 0, 1, 0, 1, 0]) y_score = np.array([0.10, 0.80, 0.40, 0.70, 0.55, 0.30, 0.45, 0.60, 0.90, 0.20]) def evaluate(threshold): y_pred = (y_score >= threshold).astype(int) result = classification_metrics(y_true, y_pred) return y_pred, result # Baseline: 임계값 0.5 baseline_pred, baseline = evaluate(0.5) # Hypothesis: 임계값을 낮추면 FN이 줄고 Recall이 높아진다. experiment_pred, experiment = evaluate(0.3) print("Baseline:", baseline) print("Experiment:", experiment) fp_indices = np.where((y_true == 0) & (experiment_pred == 1))[0] fn_indices = np.where((y_true == 1) & (experiment_pred == 0))[0] print("FP 위치:", fp_indices) print("FN 위치:", fn_indices) print("FP 점수:", y_score[fp_indices]) print("FN 점수:", y_score[fn_indices])

이 실험에서는 임계값을 낮추자 FN은 줄지만 FP가 늘어난다. 오류 위치와 점수를 확인하면 경계 근처에서 어떤 사례가 뒤집혔는지 볼 수 있다.

실제 프로젝트에서는 위치 번호만 보지 않는다. 해당 입력 문장, 정답 라벨, 모델 점수, 예측 라벨을 함께 표로 저장해 오류 유형을 분류한다.

오늘의 실험

결정 임계값을 0.3, 0.5, 0.7, 0.85로 바꾸며 정밀도와 재현율을 기록한다.

python
import numpy as np y_true = np.array([0, 1, 0, 1, 1, 0, 1, 0, 1, 0]) y_score = np.array([0.10, 0.80, 0.40, 0.70, 0.55, 0.30, 0.45, 0.60, 0.90, 0.20]) print("임계값 | Precision | Recall | F1 | FP | FN") print("-" * 48) for threshold in [0.3, 0.5, 0.7, 0.85]: y_pred = (y_score >= threshold).astype(int) metrics = classification_metrics(y_true, y_pred) print( f"{threshold:>6.2f} | " f"{metrics['Precision']:.3f} | " f"{metrics['Recall']:.3f} | " f"{metrics['F1']:.3f} | " f"{metrics['FP']:>2} | " f"{metrics['FN']:>2}" )

예상 표:

임계값PrecisionRecallF1FPFN
0.300.6251.0000.76930
0.500.8000.8000.80011
0.701.0000.6000.75002
0.851.0000.2000.33304

실험 기록에는 다음 내용을 남긴다.

  1. 기준선: 임계값 0.5, F1 0.800
  2. 가설: 임계값을 낮추면 FN이 줄어 재현율이 높아진다.
  3. 변경 요소: 임계값만 변경
  4. 결과: 0.3에서 재현율은 1.000으로 상승했지만 FP가 1개에서 3개로 증가
  5. 오류 분석: 점수 0.3·0.4·0.6인 음성 사례가 양성으로 잘못 분류됨

가장 높은 지표를 무조건 선택하지 않는다. 위험 사례를 놓치면 안 되는 업무라면 임계값 0.3이 적합할 수 있다. 잘못된 경고가 비싼 업무라면 0.7이 적합할 수 있다.

왜 이걸 배우나 (LLM / Post-training 연결)

LLM 후속 학습(Post-training, 사전학습된 모델을 사람의 목적과 기준에 맞게 추가 학습하는 과정)에서도 안전성 판정, 선호 예측, 유해 응답 탐지처럼 분류 형태의 평가가 반복된다. 유해 응답을 놓치는 FN과 정상 응답을 차단하는 FP의 비용이 다르므로, 목적에 맞는 지표와 임계값을 선택해야 한다.

LLM Research Engineer는 모델이나 데이터 설정을 바꿀 때마다 Baseline → Hypothesis → Experiment → Evaluation → Error Analysis 루프를 기록한다. 평균 점수만 비교하지 않고 실패 사례를 묶어 다음 데이터 수집이나 학습 가설로 연결하는 일이 핵심이다.

초보자가 막히는 지점

양성을 숫자 1 자체로 생각하기

양성은 관심 대상이라는 뜻이다. 스팸 탐지에서는 스팸, 질병 진단에서는 환자, 품질 검사에서는 불량품을 양성으로 정할 수 있다. 코드를 작성하기 전에 어떤 라벨이 양성인지 적는다.

Precision과 Recall의 분모를 바꾸기

  • Precision 분모: 모델이 양성이라고 예측한 수, TP + FP
  • Recall 분모: 실제 양성 수, TP + FN

Precision은 “내 양성 예측을 얼마나 믿을 수 있나?”, Recall은 “실제 양성을 얼마나 찾았나?”로 구분한다.

예측 점수와 예측 라벨을 혼동하기

y_score는 0과 1 사이의 점수다. y_pred는 임계값을 적용한 뒤의 0 또는 1이다. ROC-AUC 계산에는 y_score, 혼동행렬 계산에는 y_pred를 사용한다.

F1이 가장 높으면 항상 좋은 모델이라고 생각하기

F1은 정밀도와 재현율을 같은 비중으로 본다. 실제 업무에서 FN이 FP보다 훨씬 비싸다면 재현율을 더 우선해야 한다. 지표 선택에는 오류 비용이 먼저다.

실험 중 여러 요소를 동시에 바꾸기

임계값, 데이터, 모델 설정을 한꺼번에 바꾸면 무엇이 결과를 만들었는지 알 수 없다. 첫 실험에서는 한 요소만 바꾸고 나머지는 고정한다.

알고리즘 (병행 1시간)

해시(hash, 값을 키와 연결해 빠르게 찾도록 저장하는 방식)를 사용하는 문제 두 개를 푼다.

Easy: LeetCode 1 — Two Sum

숫자 배열에서 합이 목표값이 되는 두 원소의 위치를 찾는다.

풀이 방향:

  • 지금 필요한 짝은 목표값 - 현재 숫자다.
  • 이전에 본 숫자와 위치를 딕셔너리에 저장한다.
  • 필요한 짝이 딕셔너리에 있으면 두 위치를 반환한다.

목표 시간복잡도: O(n)

python
def two_sum(nums, target): seen = {} for index, number in enumerate(nums): needed = target - number if needed in seen: return [seen[needed], index] seen[number] = index return [] assert two_sum([2, 7, 11, 15], 9) == [0, 1]

Medium: LeetCode 49 — Group Anagrams

문자의 구성은 같고 순서만 다른 단어들을 같은 그룹으로 묶는다.

풀이 방향:

  • 각 단어의 문자를 정렬한다.
  • 정렬된 문자열을 딕셔너리 키로 사용한다.
  • 같은 키를 가진 단어를 같은 목록에 추가한다.

시간복잡도: 단어 수를 n, 가장 긴 단어 길이를 k라고 하면 O(n × k log k)

python
def group_anagrams(words): groups = {} for word in words: key = "".join(sorted(word)) if key not in groups: groups[key] = [] groups[key].append(word) return list(groups.values()) result = group_anagrams(["eat", "tea", "tan", "ate", "nat", "bat"]) print(result)

각 문제를 푼 뒤 다음 세 항목을 기록한다.

  1. 접근: 어떤 값을 딕셔너리 키로 사용했는가
  2. 시간복잡도: 반복문과 정렬이 입력 크기에 따라 얼마나 늘어나는가
  3. 틀린 이유: 중복 값, 저장 순서, 키 생성 중 무엇을 놓쳤는가