개념
학습, 검증, 테스트(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
- 데이터를 K개의 같은 크기의 폴드(fold)로 나눕니다.
- 각 폴드에 대해 나머지 K-1개의 폴드로 학습하고 남은 한 개의 폴드로 검증합니다.
- 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
연습문제
-
(쉬움) 정밀도-재현율 곡선(Precision-recall curve)을 구현합니다. 여러 임계값(threshold)에서 정밀도와 재현율을 그리고, 평균 정밀도(average precision), 즉 PR 곡선 아래 면적을 계산합니다. 불균형 데이터셋에서 PR 곡선과 ROC 곡선을 비교하고 언제 어느 쪽이 더 유용한지 설명합니다.
-
(중간) 중첩 교차 검증 반복문(Nested cross-validation loop)을 만듭니다. 바깥쪽 반복문(outer loop)은 모델 성능(model performance)을 평가하고, 안쪽 반복문(inner loop)은 하이퍼파라미터(hyperparameter)를 튜닝합니다. 검증 데이터가 최종 평가(evaluation)에 누수되지 않도록 두 모델을 공정하게 비교합니다.
-
(어려움) 모델 비교를 위한 순열 검정(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) | 클래스 비율 유지 | 각 부분 집합이 전체 데이터셋과 비슷한 클래스 비율을 갖도록 나누는 분할 |
더 읽을거리