模型评估:测量方式决定模型价值 本节摘要:一个模型的好坏,只取决于你测量它的方式。你训练了模型,在自己的数据上拿到 95% 准确率——它好吗?不一定。如果 95% 数据属于同一类,永远预测那一类的模型也拿 95% 准确率却毫无用处;如果你用训练数据评估,95% 这个数字毫无意义,因为模型只是背下了答案;如果数据有时间成分而你切分前随机打乱,模型可能用未来预测过去。模型评估是大多数 ML 项目翻车的地方:错的指标让坏模型显得好,错的切分让模型作弊,错的比较让你选了更差的模型。
本节摘要:一个模型的好坏,只取决于你测量它的方式。你训练了模型,在自己的数据上拿到 95% 准确率——它好吗?不一定。如果 95% 数据属于同一类,永远预测那一类的模型也拿 95% 准确率却毫无用处;如果你用训练数据评估,95% 这个数字毫无意义,因为模型只是背下了答案;如果数据有时间成分而你切分前随机打乱,模型可能用未来预测过去。模型评估是大多数 ML 项目翻车的地方:错的指标让坏模型显得好,错的切分让模型作弊,错的比较让你选了更差的模型。本节将从零实现训练/验证/测试三划分、K 折与分层 K 折交叉验证、混淆矩阵与全部分类指标(精确率、召回率、F1、AUC-ROC)、回归指标(MSE/RMSE/MAE/R²)、学习曲线,以及配对 t 检验式的模型对比,把数据泄漏、类别不平衡、测试集污染这些常见坑讲透。
阅读完本节,你应当能够:
你训练了模型,在自己的数据上拿到 95% 准确率。它好吗?
也许。也许不。如果 95% 数据属于一类,永远预测那类的模型拿 95% 准确率却完全无用。如果你用训练数据评估,95% 这个数字毫无意义,因为模型只是背了答案。如果数据有时间成分而你切分前随机打乱,模型可能用未来数据预测过去。
模型评估是大多数 ML 项目翻车的地方。错的指标让坏模型显得好。错的切分让模型作弊。错的比较让你选了更差的模型。把评估做对不是可选项,它决定了一个模型是生产里好用,还是见到真实数据就崩。
三份切分,三个用途:
测试集是你那份保留保证:报出来的性能反映了模型在真正未见数据上的表现。
小数据集上,单次训练/验证切分浪费数据且估计噪声大。K 折交叉验证让所有数据都参与训练和验证:
K=5 或 K=10 是标准选择。每个数据点恰好被验证一次,平均分数比任何单次切分都稳定。
分层 K 折:在每折里保持类分布。若数据 70% 是 A 类、30% 是 B 类,每折里也是这个比例。对不平衡数据至关重要——随机切分可能把所有少数类样本塞进同一折。
混淆矩阵:基础。对二分类:
| 预测为正 | 预测为负 | |
|---|---|---|
| 实际为正 | 真正例(TP) | 假负例(FN) |
| 实际为负 | 假正例(FP) | 真负例(TN) |
所有其他指标都从它派生:
把训练和验证分数画成训练集大小的函数:
把训练和验证分数画成某超参数的函数:
最优超参数值在验证分峰值处。
数据泄漏:测试集信息漏进训练。例子:切分前在全量数据上拟合缩放器、时间序列预测里包含未来数据、用了从目标派生的特征。务必先切分再预处理。
类别不平衡:99% 交易合法、1% 欺诈。永远预测「合法」的模型拿 99% 准确率。改用精确率、召回率、F1 或 AUC-ROC。
错误指标:该优化召回率时却优化准确率(医疗诊断),或数据有重尾离群点时却优化 RMSE(改用 MAE)。
没用分层切分:不平衡数据下,随机切分可能把很少的少数类样本放进验证折,估计不稳定。
测试太频繁:每看一次测试性能就调整一次,你就在过拟合测试集。测试集是一次性的。
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
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
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
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
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
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
完整演示(回归模型、标准化、合成数据、不平衡数据、跑通全部指标与曲线)见 code/model_evaluation.py。
用 scikit-learn,评估内建在工作流里:
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")
从零版让你看清交叉验证在干什么(没魔法,就是 for 循环加索引追踪)、每个指标怎么算(就是数 TP/FP/TN/FN)、分层为何重要(在每折保持类比)。库版本加了并行、更多评分选项、与流水线的集成。
| 维度 | 手写实现 | scikit-learn |
|---|---|---|
| 交叉验证 | for 循环 + 索引 | 并行、多种 CV 策略 |
| 指标 | 直接数 TP/FP | 全套 + 多类 + 平均策略 |
| 适用 | 看清每一步 | 生产、与 Pipeline 集成 |
本节产出:
outputs/skill-evaluation.md —— 覆盖分类与回归模型评估策略的技能文档。从零指标与交叉验证函数集合(code/model_evaluation.py)可作无依赖评估工具箱,在没有 sklearn 的环境里复用。
下一节,我们深入偏差方差权衡的数学——把学习曲线背后的总误差分解讲透,理解正则化、集成、加数据各自如何对症下药。