모델 평가 — 지표와 교차검증(Model Evaluation)

모델은 그것을 측정하는 방식만큼만 좋습니다.

유형: Build 언어: Python 선수 지식: Phase 1 (확률과 분포, Machine Learning을 위한 통계), Phase 2 Lessons 1-8 예상 시간: 약 90분

학습 목표

  • K-fold 교차 검증(K-fold cross-validation)과 층화 K-fold(Stratified K-fold)를 처음부터 구현하고, 불균형 데이터에서 층화(stratification)가 왜 중요한지 설명합니다.
  • 정밀도(Precision), 재현율(Recall), F1, AUC-ROC, 회귀 지표인 MSE, RMSE, MAE, R-squared를 직접 계산합니다.
  • 학습 곡선(Learning curve)을 해석해 모델이 높은 편향(High bias) 또는 높은 분산(High variance) 문제를 겪는지 진단합니다.
  • 데이터 누수(Data leakage), 잘못된 지표(metric) 선택, 테스트 세트(test set) 오염 같은 흔한 평가 실수를 식별합니다.

문제

모델을 학습했더니 데이터에서 정확도 95%가 나왔습니다. 좋은 모델일까요?

그럴 수도 있고 아닐 수도 있습니다. 데이터의 95%가 한 클래스(class)에 속한다면, 그 클래스만 항상 예측하는 모델도 95% 정확도를 얻지만 완전히 쓸모없을 수 있습니다. 학습에 사용한 데이터로 다시 평가했다면, 모델이 정답을 외웠을 뿐이므로 95%라는 숫자는 의미가 없습니다. 데이터에 시간 요소가 있는데 무작위로 섞은 뒤 분할(split)했다면, 모델이 미래 데이터를 사용해 과거를 예측하고 있을 수도 있습니다.

모델 평가는 많은 머신러닝(ML) 프로젝트가 잘못되는 지점입니다. 잘못된 지표(metric)는 나쁜 모델을 좋아 보이게 만듭니다. 잘못된 분할은 모델이 부정 행위(cheat)를 하도록 허용합니다. 잘못된 비교는 더 나쁜 모델을 선택하게 만듭니다. 평가를 제대로 하는 것은 선택 사항이 아닙니다. 실서비스(production) 환경에서 동작하는 모델과, 실제 데이터를 만나는 순간 실패하는 모델의 차이를 만드는 핵심 요소입니다.

사전 테스트

2문제 · 이 강의를 시작하기 전에 얼마나 알고 있는지 확인해보세요

1.왜 테스트 세트(test set) 성능을 기준으로 하이퍼파라미터(hyperparameter)를 튜닝하면 안 되나요?

2.데이터셋이 음성(negative) 95%, 양성(positive) 5%입니다. 모든 샘플(sample)을 '음성'으로 예측하는 모델의 정확도(accuracy)는 얼마인가요?

0/2 답변 완료

개념

학습, 검증, 테스트(Train, Validation, Test)

flowchart LR
    A[Full Dataset] --> B[Train Set 60-70%]
    A --> C[Validation Set 15-20%]
    A --> D[Test Set 15-20%]
    B --> E[Fit Model]
    E --> C
    C --> F[Tune Hyperparameters]
    F --> E
    F --> G[Final Model]
    G --> D
    D --> H[Report Performance]

세 가지 분할(split)은 목적이 다릅니다.

  • 학습 세트(Training set): 모델이 이 데이터에서 학습합니다. 학습 중 직접 보는 예시입니다.
  • 검증 세트(Validation set): 하이퍼파라미터를 조정하고 모델을 선택하는 데 사용합니다. 모델이 이 데이터로 직접 학습하지는 않지만, 우리의 결정은 이 데이터의 영향을 받습니다.
  • 테스트 세트(Test set): 마지막에 최종 성능을 보고하기 위해 정확히 한 번만 사용합니다. 테스트 성능을 본 뒤 다시 모델을 바꾼다면, 그 순간 그것은 더 이상 테스트 세트가 아니라 두 번째 검증 세트가 됩니다.

테스트 세트는 보고된 성능이 진짜로 본 적 없는 데이터(unseen data)에서의 성능을 반영한다는 사실을 보장해 주는 따로 떼어 둔(hold-out) 데이터입니다.

K-fold 교차 검증

작은 데이터셋에서는 단일 학습/검증 분할(train/validation split)이 데이터를 낭비하고 추정도 불안정하게 만듭니다. K-fold 교차 검증은 모든 데이터를 학습과 검증에 모두 사용합니다.

flowchart TB
    subgraph Fold1["Fold 1"]
        direction LR
        V1["Val"] --- T1a["Train"] --- T1b["Train"] --- T1c["Train"] --- T1d["Train"]
    end
    subgraph Fold2["Fold 2"]
        direction LR
        T2a["Train"] --- V2["Val"] --- T2b["Train"] --- T2c["Train"] --- T2d["Train"]
    end
    subgraph Fold3["Fold 3"]
        direction LR
        T3a["Train"] --- T3b["Train"] --- V3["Val"] --- T3c["Train"] --- T3d["Train"]
    end
    subgraph Fold4["Fold 4"]
        direction LR
        T4a["Train"] --- T4b["Train"] --- T4c["Train"] --- V4["Val"] --- T4d["Train"]
    end
    subgraph Fold5["Fold 5"]
        direction LR
        T5a["Train"] --- T5b["Train"] --- T5c["Train"] --- T5d["Train"] --- V5["Val"]
    end
    Fold1 --> R["점수 평균"]
    Fold2 --> R
    Fold3 --> R
    Fold4 --> R
    Fold5 --> R
  1. 데이터를 K개의 같은 크기의 폴드(fold)로 나눕니다.
  2. 각 폴드에 대해 나머지 K-1개의 폴드로 학습하고 남은 한 개의 폴드로 검증합니다.
  3. K개의 검증 점수(validation score)를 평균냅니다.

K=5 또는 K=10이 표준적인 선택입니다. 모든 데이터 포인트는 정확히 한 번씩 검증에 사용됩니다. 평균 점수는 단일 분할보다 더 안정적인 추정값을 제공합니다.

층화 K-fold(Stratified K-fold) 는 각 폴드의 클래스 분포를 보존합니다. 데이터셋이 클래스 A 70%, 클래스 B 30%로 구성되어 있다면 각 폴드도 대략 같은 비율을 유지합니다. 불균형 데이터에서는 무작위 분할이 소수 클래스 표본(minority sample)을 한 폴드에 몰아넣을 수 있으므로 층화가 중요합니다.

분류 지표

혼동 행렬(Confusion matrix) 이 모든 평가의 기반입니다. 이진 분류에서는 다음과 같이 정의됩니다.

양성 예측(Predicted Positive)음성 예측(Predicted Negative)
실제 양성(Actually Positive)참 양성, True Positive (TP)거짓 음성, False Negative (FN)
실제 음성(Actually Negative)거짓 양성, False Positive (FP)참 음성, True Negative (TN)

이 행렬에서 다른 모든 지표(metric)가 파생됩니다.

  • 정확도(Accuracy) = (TP + TN) / (TP + TN + FP + FN). 전체 중에서 맞힌 비율입니다. 클래스가 불균형하면 오해를 부르기 쉽습니다.
  • 정밀도(Precision) = TP / (TP + FP). 양성으로 예측한 것들 중 실제로 양성이 얼마나 되는지를 나타냅니다. 거짓 양성(false positive)이 비싼 비용을 발생시킬 때 사용합니다. 예를 들어 정상 메일을 스팸(spam)으로 표시하면 안 되는 스팸 필터(spam filter)가 그렇습니다.
  • 재현율(Recall, sensitivity) = TP / (TP + FN). 실제 양성 중 모델이 얼마나 많이 잡아냈는지를 나타냅니다. 거짓 음성(false negative)이 비쌀 때 사용합니다. 예를 들어 암 선별 검사(screening)에서 종양(tumor)을 놓치면 안 되는 경우입니다.
  • F1 점수(F1 score) = 2 * precision * recall / (precision + recall). 정밀도와 재현율의 조화 평균입니다. 어느 한쪽이 명확히 우선하지 않을 때 두 값을 균형 있게 보고 싶을 때 사용합니다.
  • AUC-ROC: 수신자 운영 특성 곡선(Receiver Operating Characteristic; ROC) 아래의 면적입니다. 여러 임계값(threshold)에서 참 양성 비율(true positive rate)과 거짓 양성 비율(false positive rate)을 그립니다. AUC=0.5는 무작위 추측(random guessing) 수준이고, AUC=1.0은 완벽한 분리를 의미합니다. 임계값에 독립적인 지표로서, 모델이 양성 표본을 음성 표본보다 얼마나 더 위쪽에 순위(rank)를 매기는지를 측정합니다.

회귀 지표

  • 평균 제곱 오차(MSE, Mean Squared Error) = mean((y_true - y_pred)^2). 큰 오류를 제곱으로 더 크게 벌점화합니다. 이상치(outlier)에 민감합니다.
  • 평균 제곱근 오차(RMSE, Root Mean Squared Error) = sqrt(MSE). 목표 변수(target variable)와 같은 단위를 가지므로 MSE보다 해석하기 쉽습니다.
  • 평균 절대 오차(MAE, Mean Absolute Error) = mean(|y_true - y_pred|). 모든 오류를 선형으로 다룹니다. MSE보다 이상치에 강건(robust)합니다.
  • 결정 계수(R-squared) = 1 - SS_res / SS_tot. SS_res는 잔차(residual) 제곱합이고 SS_tot는 목표 변수의 평균을 기준으로 한 전체 제곱합입니다. 모델이 설명한 분산의 비율을 의미합니다. 1.0은 완벽, 0.0은 평균만 예측하는 것과 같다는 뜻이며, 음수는 평균 예측보다도 나쁘다는 뜻입니다.

학습 곡선

학습 데이터 크기에 따른 학습 점수(training score)와 검증 점수(validation score)를 그립니다.

  • 높은 편향, 과소적합(High bias, underfitting): 두 곡선이 모두 낮은 점수에서 수렴합니다. 데이터를 더 추가해도 큰 도움이 되지 않습니다. 더 복잡한 모델이 필요합니다.
  • 높은 분산, 과적합(High variance, overfitting): 학습 점수는 높지만 검증 점수가 훨씬 낮습니다. 두 곡선 사이의 격차(gap)가 큽니다. 이때는 데이터를 더 추가하면 도움이 될 수 있습니다.

검증 곡선(Validation Curves)

하이퍼파라미터 값의 변화에 따른 학습 점수와 검증 점수를 그립니다.

  • 복잡도가 낮을 때: 두 점수가 모두 낮습니다. 과소적합 상태입니다.
  • 적절한 복잡도: 두 점수가 모두 높고 서로 가깝습니다.
  • 복잡도가 높을 때: 학습 점수는 높지만 검증 점수가 떨어집니다. 과적합 상태입니다.

최적의 하이퍼파라미터 값은 검증 점수가 가장 높은 지점입니다.

흔한 평가 실수

데이터 누수(Data leakage): 테스트 세트의 정보가 학습 과정에 새어 들어갑니다. 예를 들어 분할 전에 전체 데이터셋에 스케일러(scaler)를 학습시키거나, 시계열(time series) 예측에 미래 데이터를 포함하거나, 목표 변수에서 파생된 특성(feature)을 그대로 사용하는 경우입니다. 항상 먼저 분할하고 그다음에 전처리(preprocess)합니다.

클래스 불균형(Class imbalance): 거래의 99%가 정상이고 1%만 사기(fraud)라면, 항상 정상이라고만 예측하는 모델도 99% 정확도(accuracy)를 얻습니다. 이런 상황에서는 정확도 대신 정밀도(precision), 재현율(recall), F1, AUC-ROC를 사용합니다.

잘못된 지표 선택(Wrong metric): 의료 진단에서는 재현율을 봐야 하는데 정확도를 최적화하거나, 큰 이상치(outlier)가 많은 데이터에서 RMSE를 그대로 쓰고 MAE를 보지 않는 경우입니다.

층화 분할(Stratified split) 미사용: 불균형 데이터에서 무작위 분할(random split)을 쓰면 검증 폴드(validation fold)에 소수 클래스 표본이 너무 적게 들어가 추정이 불안정해질 수 있습니다.

테스트 세트를 너무 자주 보기: 테스트 성능을 본 뒤 모델을 조정할 때마다 테스트 세트에 과적합됩니다. 테스트 세트는 한 번만 쓰는(single-use) 자원입니다.

만들어 보기

Step 1: 학습/검증/테스트 분할

import random
import math


def train_val_test_split(X, y, train_ratio=0.6, val_ratio=0.2, seed=42):
    random.seed(seed)
    n = len(X)
    indices = list(range(n))
    random.shuffle(indices)

    train_end = int(n * train_ratio)
    val_end = int(n * (train_ratio + val_ratio))

    train_idx = indices[:train_end]
    val_idx = indices[train_end:val_end]
    test_idx = indices[val_end:]

    X_train = [X[i] for i in train_idx]
    y_train = [y[i] for i in train_idx]
    X_val = [X[i] for i in val_idx]
    y_val = [y[i] for i in val_idx]
    X_test = [X[i] for i in test_idx]
    y_test = [y[i] for i in test_idx]

    return X_train, y_train, X_val, y_val, X_test, y_test

Step 2: K-fold와 층화 K-fold 교차 검증

def kfold_split(n, k=5, seed=42):
    random.seed(seed)
    indices = list(range(n))
    random.shuffle(indices)

    fold_size = n // k
    folds = []

    for i in range(k):
        start = i * fold_size
        end = start + fold_size if i < k - 1 else n
        val_idx = indices[start:end]
        train_idx = indices[:start] + indices[end:]
        folds.append((train_idx, val_idx))

    return folds


def stratified_kfold_split(y, k=5, seed=42):
    random.seed(seed)

    class_indices = {}
    for i, label in enumerate(y):
        class_indices.setdefault(label, []).append(i)

    for label in class_indices:
        random.shuffle(class_indices[label])

    folds = [{"train": [], "val": []} for _ in range(k)]

    for label, indices in class_indices.items():
        fold_size = len(indices) // k
        for i in range(k):
            start = i * fold_size
            end = start + fold_size if i < k - 1 else len(indices)
            val_part = indices[start:end]
            train_part = indices[:start] + indices[end:]
            folds[i]["val"].extend(val_part)
            folds[i]["train"].extend(train_part)

    return [(f["train"], f["val"]) for f in folds]


def cross_validate(X, y, model_fn, k=5, metric_fn=None, stratified=False):
    n = len(X)

    if stratified:
        folds = stratified_kfold_split(y, k)
    else:
        folds = kfold_split(n, k)

    scores = []
    for train_idx, val_idx in folds:
        X_train = [X[i] for i in train_idx]
        y_train = [y[i] for i in train_idx]
        X_val = [X[i] for i in val_idx]
        y_val = [y[i] for i in val_idx]

        model = model_fn()
        model.fit(X_train, y_train)
        predictions = [model.predict(x) for x in X_val]

        if metric_fn:
            score = metric_fn(y_val, predictions)
        else:
            score = sum(1 for yt, yp in zip(y_val, predictions) if yt == yp) / len(y_val)
        scores.append(score)

    return scores

Step 3: 혼동 행렬과 분류 지표

def confusion_matrix(y_true, y_pred):
    tp = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 1 and yp == 1)
    tn = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 0 and yp == 0)
    fp = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 0 and yp == 1)
    fn = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 1 and yp == 0)
    return tp, tn, fp, fn


def accuracy(y_true, y_pred):
    tp, tn, fp, fn = confusion_matrix(y_true, y_pred)
    total = tp + tn + fp + fn
    return (tp + tn) / total if total > 0 else 0.0


def precision(y_true, y_pred):
    tp, tn, fp, fn = confusion_matrix(y_true, y_pred)
    return tp / (tp + fp) if (tp + fp) > 0 else 0.0


def recall(y_true, y_pred):
    tp, tn, fp, fn = confusion_matrix(y_true, y_pred)
    return tp / (tp + fn) if (tp + fn) > 0 else 0.0


def f1_score(y_true, y_pred):
    p = precision(y_true, y_pred)
    r = recall(y_true, y_pred)
    return 2 * p * r / (p + r) if (p + r) > 0 else 0.0


def roc_curve(y_true, y_scores):
    thresholds = sorted(set(y_scores), reverse=True)
    tpr_list = []
    fpr_list = []

    total_positives = sum(y_true)
    total_negatives = len(y_true) - total_positives

    for threshold in thresholds:
        y_pred = [1 if s >= threshold else 0 for s in y_scores]
        tp = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 1 and yp == 1)
        fp = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 0 and yp == 1)

        tpr = tp / total_positives if total_positives > 0 else 0.0
        fpr = fp / total_negatives if total_negatives > 0 else 0.0

        tpr_list.append(tpr)
        fpr_list.append(fpr)

    return fpr_list, tpr_list, thresholds


def auc_roc(y_true, y_scores):
    fpr_list, tpr_list, _ = roc_curve(y_true, y_scores)

    pairs = sorted(zip(fpr_list, tpr_list))
    fpr_sorted = [p[0] for p in pairs]
    tpr_sorted = [p[1] for p in pairs]

    area = 0.0
    for i in range(1, len(fpr_sorted)):
        width = fpr_sorted[i] - fpr_sorted[i - 1]
        height = (tpr_sorted[i] + tpr_sorted[i - 1]) / 2
        area += width * height

    return area

Step 4: 회귀 지표

def mse(y_true, y_pred):
    n = len(y_true)
    return sum((yt - yp) ** 2 for yt, yp in zip(y_true, y_pred)) / n


def rmse(y_true, y_pred):
    return math.sqrt(mse(y_true, y_pred))


def mae(y_true, y_pred):
    n = len(y_true)
    return sum(abs(yt - yp) for yt, yp in zip(y_true, y_pred)) / n


def r_squared(y_true, y_pred):
    mean_y = sum(y_true) / len(y_true)
    ss_res = sum((yt - yp) ** 2 for yt, yp in zip(y_true, y_pred))
    ss_tot = sum((yt - mean_y) ** 2 for yt in y_true)
    if ss_tot == 0:
        return 0.0
    return 1.0 - ss_res / ss_tot

Step 5: 학습 곡선

def learning_curve(X, y, model_fn, metric_fn, train_sizes=None, val_ratio=0.2, seed=42):
    random.seed(seed)
    n = len(X)
    indices = list(range(n))
    random.shuffle(indices)

    val_size = int(n * val_ratio)
    val_idx = indices[:val_size]
    pool_idx = indices[val_size:]

    X_val = [X[i] for i in val_idx]
    y_val = [y[i] for i in val_idx]

    if train_sizes is None:
        train_sizes = [int(len(pool_idx) * r) for r in [0.1, 0.2, 0.4, 0.6, 0.8, 1.0]]

    train_scores = []
    val_scores = []

    for size in train_sizes:
        subset = pool_idx[:size]
        X_train = [X[i] for i in subset]
        y_train = [y[i] for i in subset]

        model = model_fn()
        model.fit(X_train, y_train)

        train_pred = [model.predict(x) for x in X_train]
        val_pred = [model.predict(x) for x in X_val]

        train_scores.append(metric_fn(y_train, train_pred))
        val_scores.append(metric_fn(y_val, val_pred))

    return train_sizes, train_scores, val_scores

Step 6: 테스트용 간단한 분류기와 전체 데모

class SimpleLogistic:
    def __init__(self, lr=0.1, epochs=100):
        self.lr = lr
        self.epochs = epochs
        self.weights = None
        self.bias = 0.0

    def sigmoid(self, z):
        z = max(-500, min(500, z))
        return 1.0 / (1.0 + math.exp(-z))

    def fit(self, X, y):
        n_features = len(X[0])
        self.weights = [0.0] * n_features
        self.bias = 0.0

        for _ in range(self.epochs):
            for xi, yi in zip(X, y):
                z = sum(w * x for w, x in zip(self.weights, xi)) + self.bias
                pred = self.sigmoid(z)
                error = yi - pred
                for j in range(n_features):
                    self.weights[j] += self.lr * error * xi[j]
                self.bias += self.lr * error

    def predict_proba(self, x):
        z = sum(w * xi for w, xi in zip(self.weights, x)) + self.bias
        return self.sigmoid(z)

    def predict(self, x):
        return 1 if self.predict_proba(x) >= 0.5 else 0


class SimpleLinearRegression:
    def __init__(self, lr=0.001, epochs=200):
        self.lr = lr
        self.epochs = epochs
        self.weights = None
        self.bias = 0.0

    def fit(self, X, y):
        n_features = len(X[0])
        self.weights = [0.0] * n_features
        self.bias = 0.0
        n = len(X)

        for _ in range(self.epochs):
            for xi, yi in zip(X, y):
                pred = sum(w * x for w, x in zip(self.weights, xi)) + self.bias
                error = yi - pred
                for j in range(n_features):
                    self.weights[j] += self.lr * error * xi[j] / n
                self.bias += self.lr * error / n

    def predict(self, x):
        return sum(w * xi for w, xi in zip(self.weights, x)) + self.bias


def standardize(values):
    n = len(values)
    mean = sum(values) / n
    var = sum((v - mean) ** 2 for v in values) / n
    std = math.sqrt(var) if var > 0 else 1.0
    return [(v - mean) / std for v in values], mean, std


def make_classification_data(n=300, seed=42):
    random.seed(seed)
    X = []
    y = []
    for _ in range(n):
        x1 = random.gauss(0, 1)
        x2 = random.gauss(0, 1)
        label = 1 if (x1 + x2 + random.gauss(0, 0.5)) > 0 else 0
        X.append([x1, x2])
        y.append(label)
    return X, y


def make_regression_data(n=200, seed=42):
    random.seed(seed)
    X = []
    y = []
    for _ in range(n):
        x1 = random.uniform(0, 10)
        x2 = random.uniform(0, 5)
        target = 3 * x1 + 2 * x2 + random.gauss(0, 2)
        X.append([x1, x2])
        y.append(target)
    return X, y


def make_imbalanced_data(n=300, minority_ratio=0.05, seed=42):
    random.seed(seed)
    X = []
    y = []
    for _ in range(n):
        if random.random() < minority_ratio:
            x1 = random.gauss(3, 0.5)
            x2 = random.gauss(3, 0.5)
            label = 1
        else:
            x1 = random.gauss(0, 1)
            x2 = random.gauss(0, 1)
            label = 0
        X.append([x1, x2])
        y.append(label)
    return X, y


if __name__ == "__main__":
    X_clf, y_clf = make_classification_data(300)

    print("=== 학습/검증/테스트 분할 ===")
    X_train, y_train, X_val, y_val, X_test, y_test = train_val_test_split(X_clf, y_clf)
    print(f"  학습: {len(X_train)}, 검증: {len(X_val)}, 테스트: {len(X_test)}")
    print(f"  학습 클래스 분포: 양성 {sum(y_train)}/{len(y_train)}")
    print(f"  검증 클래스 분포: 양성 {sum(y_val)}/{len(y_val)}")

    model = SimpleLogistic(lr=0.1, epochs=200)
    model.fit(X_train, y_train)

    print("\n=== 분류 지표 ===")
    y_pred = [model.predict(x) for x in X_test]
    tp, tn, fp, fn = confusion_matrix(y_test, y_pred)
    print(f"  혼동 행렬: TP={tp}, TN={tn}, FP={fp}, FN={fn}")
    print(f"  정확도:   {accuracy(y_test, y_pred):.4f}")
    print(f"  정밀도:   {precision(y_test, y_pred):.4f}")
    print(f"  재현율:   {recall(y_test, y_pred):.4f}")
    print(f"  F1 점수: {f1_score(y_test, y_pred):.4f}")

    y_scores = [model.predict_proba(x) for x in X_test]
    auc = auc_roc(y_test, y_scores)
    print(f"  AUC-ROC:   {auc:.4f}")

    print("\n=== K-Fold 교차 검증 (K=5) ===")
    cv_scores = cross_validate(
        X_clf, y_clf,
        model_fn=lambda: SimpleLogistic(lr=0.1, epochs=200),
        k=5,
        metric_fn=accuracy,
    )
    mean_cv = sum(cv_scores) / len(cv_scores)
    std_cv = math.sqrt(sum((s - mean_cv) ** 2 for s in cv_scores) / len(cv_scores))
    print(f"  폴드 점수: {[round(s, 4) for s in cv_scores]}")
    print(f"  평균: {mean_cv:.4f} (+/- {std_cv:.4f})")

    print("\n=== 층화 K-Fold 교차 검증 (K=5) ===")
    strat_scores = cross_validate(
        X_clf, y_clf,
        model_fn=lambda: SimpleLogistic(lr=0.1, epochs=200),
        k=5,
        metric_fn=accuracy,
        stratified=True,
    )
    strat_mean = sum(strat_scores) / len(strat_scores)
    strat_std = math.sqrt(sum((s - strat_mean) ** 2 for s in strat_scores) / len(strat_scores))
    print(f"  폴드 점수: {[round(s, 4) for s in strat_scores]}")
    print(f"  평균: {strat_mean:.4f} (+/- {strat_std:.4f})")

    print("\n=== 불균형 데이터: 정확도가 속이는 이유 ===")
    X_imb, y_imb = make_imbalanced_data(300, minority_ratio=0.05)
    positives = sum(y_imb)
    print(f"  클래스 분포: 양성 {positives}개, 음성 {len(y_imb) - positives}개 ({positives/len(y_imb)*100:.1f}% 양성)")

    always_negative = [0] * len(y_imb)
    print("  항상 음성을 예측하는 기준선(baseline):")
    print(f"    정확도:   {accuracy(y_imb, always_negative):.4f}")
    print(f"    정밀도:   {precision(y_imb, always_negative):.4f}")
    print(f"    재현율:   {recall(y_imb, always_negative):.4f}")
    print(f"    F1 점수:  {f1_score(y_imb, always_negative):.4f}")

    X_tr_i, y_tr_i, X_v_i, y_v_i, X_te_i, y_te_i = train_val_test_split(X_imb, y_imb)
    model_imb = SimpleLogistic(lr=0.5, epochs=500)
    model_imb.fit(X_tr_i, y_tr_i)
    y_pred_imb = [model_imb.predict(x) for x in X_te_i]
    print("\n  불균형 데이터로 학습한 모델:")
    print(f"    정확도:   {accuracy(y_te_i, y_pred_imb):.4f}")
    print(f"    정밀도:   {precision(y_te_i, y_pred_imb):.4f}")
    print(f"    재현율:   {recall(y_te_i, y_pred_imb):.4f}")
    print(f"    F1 점수:  {f1_score(y_te_i, y_pred_imb):.4f}")

    print("\n=== 회귀 지표 ===")
    X_reg, y_reg = make_regression_data(200)

    col0 = [x[0] for x in X_reg]
    col1 = [x[1] for x in X_reg]
    col0_s, m0, s0 = standardize(col0)
    col1_s, m1, s1 = standardize(col1)
    X_reg_scaled = [[col0_s[i], col1_s[i]] for i in range(len(X_reg))]

    X_tr_r, y_tr_r, X_v_r, y_v_r, X_te_r, y_te_r = train_val_test_split(X_reg_scaled, y_reg)
    reg_model = SimpleLinearRegression(lr=0.01, epochs=500)
    reg_model.fit(X_tr_r, y_tr_r)
    y_pred_r = [reg_model.predict(x) for x in X_te_r]

    print(f"  MSE:       {mse(y_te_r, y_pred_r):.4f}")
    print(f"  RMSE:      {rmse(y_te_r, y_pred_r):.4f}")
    print(f"  MAE:       {mae(y_te_r, y_pred_r):.4f}")
    print(f"  R-squared: {r_squared(y_te_r, y_pred_r):.4f}")

    mean_baseline = [sum(y_tr_r) / len(y_tr_r)] * len(y_te_r)
    print("\n  평균 예측 기준선(baseline):")
    print(f"    MSE:       {mse(y_te_r, mean_baseline):.4f}")
    print(f"    R-squared: {r_squared(y_te_r, mean_baseline):.4f}")

    print("\n=== 학습 곡선 ===")
    sizes, train_sc, val_sc = learning_curve(
        X_clf, y_clf,
        model_fn=lambda: SimpleLogistic(lr=0.1, epochs=200),
        metric_fn=accuracy,
    )
    print(f"  {'크기':>6} {'학습':>8} {'검증':>8}")
    for s, tr, va in zip(sizes, train_sc, val_sc):
        print(f"  {s:>6} {tr:>8.4f} {va:>8.4f}")

    print("\n=== 통계적 모델 비교 ===")
    model_a_scores = cross_validate(
        X_clf, y_clf,
        model_fn=lambda: SimpleLogistic(lr=0.1, epochs=100),
        k=5, metric_fn=accuracy,
    )
    model_b_scores = cross_validate(
        X_clf, y_clf,
        model_fn=lambda: SimpleLogistic(lr=0.1, epochs=500),
        k=5, metric_fn=accuracy,
    )
    diffs = [a - b for a, b in zip(model_a_scores, model_b_scores)]
    mean_diff = sum(diffs) / len(diffs)
    std_diff = math.sqrt(sum((d - mean_diff) ** 2 for d in diffs) / len(diffs))
    t_stat = mean_diff / (std_diff / math.sqrt(len(diffs))) if std_diff > 0 else 0.0
    print(f"  모델 A (100 epochs) 평균: {sum(model_a_scores)/len(model_a_scores):.4f}")
    print(f"  모델 B (500 epochs) 평균: {sum(model_b_scores)/len(model_b_scores):.4f}")
    print(f"  평균 차이: {mean_diff:.4f}")
    print(f"  대응표본 t-통계량(Paired t-statistic): {t_stat:.4f}")
    print("  (df=4에서 p<0.05 유의성을 보려면 |t| > 2.78)")

직접 구현한 버전은 교차 검증(cross-validation)이 마법이 아니라 인덱스를 나누어 반복하는 단순한 구조라는 점, 각 지표가 TP/FP/TN/FN 계산에서 자연스럽게 도출된다는 점, 층화가 클래스 비율(class ratio)을 보존한다는 점을 보여 줍니다.

사용하기

scikit-learn에서는 평가가 학습 작업 흐름(workflow)에 통합되어 있습니다.

from sklearn.model_selection import cross_val_score, StratifiedKFold, learning_curve
from sklearn.metrics import (
    accuracy_score, precision_score, recall_score, f1_score,
    roc_auc_score, confusion_matrix, mean_squared_error, r2_score,
)
from sklearn.linear_model import LogisticRegression

model = LogisticRegression()
scores = cross_val_score(model, X, y, cv=StratifiedKFold(5), scoring="f1")

라이브러리 버전은 병렬 처리(parallelism), 더 많은 채점 옵션(scoring option), 파이프라인(pipeline) 통합을 제공합니다. 하지만 핵심은 같습니다. 올바르게 분할하고, 문제에 맞는 지표를 고르고, 테스트 세트는 마지막에 단 한 번만 사용합니다.

출하하기

이 lesson의 산출물은 다음입니다.

  • outputs/skill-evaluation.md — 분류와 회귀 모델의 평가 전략을 다루는 skill

연습문제

  1. (쉬움) 정밀도-재현율 곡선(Precision-recall curve)을 구현합니다. 여러 임계값(threshold)에서 정밀도와 재현율을 그리고, 평균 정밀도(average precision), 즉 PR 곡선 아래 면적을 계산합니다. 불균형 데이터셋에서 PR 곡선과 ROC 곡선을 비교하고 언제 어느 쪽이 더 유용한지 설명합니다.

  2. (중간) 중첩 교차 검증 반복문(Nested cross-validation loop)을 만듭니다. 바깥쪽 반복문(outer loop)은 모델 성능(model performance)을 평가하고, 안쪽 반복문(inner loop)은 하이퍼파라미터(hyperparameter)를 튜닝합니다. 검증 데이터가 최종 평가(evaluation)에 누수되지 않도록 두 모델을 공정하게 비교합니다.

  3. (어려움) 모델 비교를 위한 순열 검정(permutation test)을 구현합니다. 레이블(label)을 무작위로 섞은 뒤 다시 학습하고 성능을 측정합니다. 이 과정을 100번 반복해 영가설 분포(null distribution)를 만들고, 관측된 모델 성능에 대한 p-값(p-value)을 계산합니다.

핵심 용어

용어흔한 설명실제 의미
과적합(Overfitting)학습 데이터를 외움모델이 학습 데이터의 잡음(noise)까지 잡아내어 학습에서는 좋지만 본 적 없는 데이터에서는 나쁘게 동작하는 현상
교차 검증(Cross-validation)여러 부분 집합으로 테스트검증에 사용할 부분을 체계적으로 바꿔 가며 결과를 평균내는 평가 방식
정밀도(Precision)양성 예측 중 맞은 비율TP / (TP + FP)
재현율(Recall)실제 양성 중 찾은 비율TP / (TP + FN)
AUC-ROC클래스를 얼마나 잘 분리하는지모든 임계값에서 참 양성 비율(TPR)과 거짓 양성 비율(FPR) 곡선 아래 면적. 0.5는 무작위, 1.0은 완벽
결정 계수(R-squared)설명한 분산잔차 제곱합과 전체 제곱합의 비율로 계산한, 목표 변수의 분산을 모델이 설명한 정도
데이터 누수(Data leakage)모델이 부정 행위(cheating)를 함예측 시점에는 사용할 수 없는 정보가 학습 중 들어가 평가가 낙관적으로 나오는 문제
학습 곡선(Learning curve)데이터가 늘 때의 성능 변화학습 세트 크기에 따른 학습/검증 점수의 그래프(plot)
층화 분할(Stratified split)클래스 비율 유지각 부분 집합이 전체 데이터셋과 비슷한 클래스 비율을 갖도록 나누는 분할

더 읽을거리

실습 코드

이 강의의 실습 코드 1개

evaluation
Code

산출물

이 강의에서 생성된 프롬프트, 스킬, 코드 산출물 1개

skill-evaluation

Evaluation strategy checklist for classification and regression models

Skill

확인 문제

3문제 · 모두 맞추면 완료 표시가 가능합니다

1.K=5인 K-fold 교차 검증(K-fold cross-validation)에서 각 데이터 포인트는 검증(validation)에 몇 번 사용되나요?

2.학습 곡선(Learning curve)에서 학습 점수(training score)는 0.95이고 검증 점수(validation score)는 0.60이며, 데이터를 더 넣어도 나아지지 않습니다. 무엇을 시도해야 하나요?

3.이진 분류기에서 AUC-ROC = 0.5라면 무엇을 의미하나요?

0/3 답변 완료