模型评估:测量方式决定模型价值


文档摘要

模型评估:测量方式决定模型价值 本节摘要:一个模型的好坏,只取决于你测量它的方式。你训练了模型,在自己的数据上拿到 95% 准确率——它好吗?不一定。如果 95% 数据属于同一类,永远预测那一类的模型也拿 95% 准确率却毫无用处;如果你用训练数据评估,95% 这个数字毫无意义,因为模型只是背下了答案;如果数据有时间成分而你切分前随机打乱,模型可能用未来预测过去。模型评估是大多数 ML 项目翻车的地方:错的指标让坏模型显得好,错的切分让模型作弊,错的比较让你选了更差的模型。

模型评估:测量方式决定模型价值

本节摘要:一个模型的好坏,只取决于你测量它的方式。你训练了模型,在自己的数据上拿到 95% 准确率——它好吗?不一定。如果 95% 数据属于同一类,永远预测那一类的模型也拿 95% 准确率却毫无用处;如果你用训练数据评估,95% 这个数字毫无意义,因为模型只是背下了答案;如果数据有时间成分而你切分前随机打乱,模型可能用未来预测过去。模型评估是大多数 ML 项目翻车的地方:错的指标让坏模型显得好,错的切分让模型作弊,错的比较让你选了更差的模型。本节将从零实现训练/验证/测试三划分、K 折与分层 K 折交叉验证、混淆矩阵与全部分类指标(精确率、召回率、F1、AUC-ROC)、回归指标(MSE/RMSE/MAE/R²)、学习曲线,以及配对 t 检验式的模型对比,把数据泄漏、类别不平衡、测试集污染这些常见坑讲透。

学习目标

阅读完本节,你应当能够:

  1. 从零实现 K 折和分层 K 折交叉验证,解释分层对不平衡数据为何重要。
  2. 从零计算精确率、召回率、F1、AUC-ROC,以及回归指标(MSE、RMSE、MAE、R²)。
  3. 解读学习曲线,诊断模型是高偏差还是高方差。
  4. 识别常见评估错误:数据泄漏、错误指标选择、测试集污染。

一、问题与直觉

你训练了模型,在自己的数据上拿到 95% 准确率。它好吗?

也许。也许不。如果 95% 数据属于一类,永远预测那类的模型拿 95% 准确率却完全无用。如果你用训练数据评估,95% 这个数字毫无意义,因为模型只是背了答案。如果数据有时间成分而你切分前随机打乱,模型可能用未来数据预测过去。

模型评估是大多数 ML 项目翻车的地方。错的指标让坏模型显得好。错的切分让模型作弊。错的比较让你选了更差的模型。把评估做对不是可选项,它决定了一个模型是生产里好用,还是见到真实数据就崩。

训练、验证、测试

三份切分,三个用途:

  • 训练集:模型从这里学习,训练时见到这些例子。
  • 验证集:用来调超参数和选模型。模型从不在上面训练,但你的决策受它影响。
  • 测试集:只在最末尾碰一次,汇报最终性能。如果你看了测试性能再回去改模型,它就不再是测试集,而成了第二个验证集。

测试集是你那份保留保证:报出来的性能反映了模型在真正未见数据上的表现。

K 折交叉验证

小数据集上,单次训练/验证切分浪费数据且估计噪声大。K 折交叉验证让所有数据都参与训练和验证:

  1. 把数据切成 K 等份
  2. 每折用 K-1 折训练,剩下 1 折验证
  3. 把 K 个验证分数平均

K=5 或 K=10 是标准选择。每个数据点恰好被验证一次,平均分数比任何单次切分都稳定。

分层 K 折:在每折里保持类分布。若数据 70% 是 A 类、30% 是 B 类,每折里也是这个比例。对不平衡数据至关重要——随机切分可能把所有少数类样本塞进同一折。

分类指标

混淆矩阵:基础。对二分类:

预测为正 预测为负
实际为正 真正例(TP) 假负例(FN)
实际为负 假正例(FP) 真负例(TN)

所有其他指标都从它派生:

  • 准确率 = (TP + TN) / (TP + TN + FP + FN)。正确预测占比。类别不平衡时有误导。
  • 精确率 = TP / (TP + FP)。所有预测为正的里,实际为正多少?假正例代价高时用(如垃圾过滤器把真邮件标垃圾)。
  • 召回率(灵敏度)= TP / (TP + FN)。所有实际为正的里,我们抓到了多少?假负例代价高时用(如癌症筛查漏掉肿瘤)。
  • F1 分数 = 2 * 精确率 * 召回率 / (精确率 + 召回率)。精确率与召回率的调和平均。两者都不明显占优时用来平衡。
  • AUC-ROC:受试者工作特征曲线下面积。在不同分类阈值下画真正例率对假正例率。AUC = 0.5 是随机猜,AUC = 1.0 是完美分离。与阈值无关:它度量模型把正样本排在负样本前面的能力,不管你选哪个截断。

回归指标

  • MSE(均方误差)= mean((y_true - y_pred)²)。二次惩罚大误差,对离群点敏感。
  • RMSE(均方根误差)= sqrt(MSE)。与目标变量同量纲,比 MSE 好解读。
  • MAE(平均绝对误差)= mean(|y_true - y_pred|)。线性对待所有误差,比 MSE 更抗离群点。
  • = 1 - SS_res / SS_tot,其中 SS_res = sum((y_true - y_pred)²)、SS_tot = sum((y_true - y_mean)²)。模型解释的方差占比。R² = 1.0 完美,R² = 0.0 等于永远预测均值,模型比均值还差时 R² 可为负。

学习曲线

把训练和验证分数画成训练集大小的函数:

  • 高偏差(欠拟合):两条曲线收敛到低分。加数据没用,要更复杂的模型。
  • 高方差(过拟合):训练分高但验证分低很多,两者差距大。加数据应该有用。

验证曲线

把训练和验证分数画成某超参数的函数:

  • 复杂度低:两分都低(欠拟合)
  • 复杂度对:两分都高且接近
  • 复杂度高:训练分保持高但验证分下降(过拟合)

最优超参数值在验证分峰值处。

常见评估错误

数据泄漏:测试集信息漏进训练。例子:切分前在全量数据上拟合缩放器、时间序列预测里包含未来数据、用了从目标派生的特征。务必先切分再预处理。

类别不平衡:99% 交易合法、1% 欺诈。永远预测「合法」的模型拿 99% 准确率。改用精确率、召回率、F1 或 AUC-ROC。

错误指标:该优化召回率时却优化准确率(医疗诊断),或数据有重尾离群点时却优化 RMSE(改用 MAE)。

没用分层切分:不平衡数据下,随机切分可能把很少的少数类样本放进验证折,估计不稳定。

测试太频繁:每看一次测试性能就调整一次,你就在过拟合测试集。测试集是一次性的。

二、从零实现

第 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

第 2 步:K 折与分层 K 折交叉验证

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

第 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

第 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

第 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

第 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

完整演示(回归模型、标准化、合成数据、不平衡数据、跑通全部指标与曲线)见 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 的环境里复用。

五、练习

  1. 实现精确率-召回率曲线:在不同阈值画精确率对召回率,计算平均精度(PR 曲线下面积)。在不平衡数据上对比 PR 曲线与 ROC 曲线,解释何时哪个更有信息量。
  2. 构建嵌套交叉验证:外层评估模型性能,内层调超参数。用它公平对比两个模型,不让验证数据泄漏进评估。
  3. 实现模型对比的置换检验:打乱标签、重训、测性能,重复 100 次构建零分布,算观测到的模型性能相对该分布的 p 值。

本节要点回顾

  1. 测量决定价值:错的指标、错的切分、错的比较让坏模型显好、模型作弊、选了更差的模型。
  2. 三划分各有用途:训练集学、验证集调、测试集只看一次,看了再改就降级成第二验证集。
  3. K 折用全量数据:K=5 或 10,每点恰好验证一次,平均分数比单切分稳定。
  4. 分层 K 折保类比:不平衡数据下随机切分会把少数类塞进同一折,分层保证每折比例一致。
  5. 准确率会撒谎:99% 负样本的数据里永远预测负就有 99% 准确率却无用,要看精确率/召回率/F1。
  6. 指标按代价选:假正例贵看精确率、假负例贵看召回率、要平衡看 F1、要阈值无关看 AUC-ROC。
  7. 回归指标四件套:MSE 二次罚大误差敏感离群、RMSE 同量纲好解读、MAE 线性抗离群、R² 解释方差占比可为负。
  8. 学习曲线诊断偏差方差:两线收敛低分是高偏差(要更复杂模型)、训练高验证低差距大是高方差(要更多数据)。
  9. 四大评估坑:数据泄漏(先切分再预处理)、类别不平衡、错误指标、测试集污染(测试是一次性的)。

下一节,我们深入偏差方差权衡的数学——把学习曲线背后的总误差分解讲透,理解正则化、集成、加数据各自如何对症下药。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U