06 模型验证

本章核心问题:我们如何相信模型的预测?

想象一下,我们构建了一个复杂的机器学习模型来预测股票市场的走向。

  • 在历史数据上,它的表现完美无瑕,我们称之为训练集 (Training Set)
  • 但当我们用它进行真实世界的交易时,它却亏损严重,我们称之为测试集 (Test Set)

这个问题,即模型泛化能力的失败,是机器学习和计量经济学中的核心挑战。本章将系统地探讨如何科学地评估和验证我们构建的模型,确保它在未知数据上依然稳健。

警惕模型的“事后诸葛亮”陷阱

一个模型在它见过的数据上表现好,是理所当然的。

这就像一个学生背下了去年考试的所有答案,并在去年的卷子上考了满分。

我们真正关心的,不是他能不能背诵旧答案,而是他是否真正掌握了知识,能够解答今年的新题目

模型验证,就是为我们的模型设计一场公平、公正的“新考试”。

本章学习路线图

我们将分四个部分,层层递进,构建起一套完整的模型验证知识体系。

  1. 验证策略:学习如何科学地划分“学习资料”和“模拟考卷”。
  2. 核心理论:深入理解模型为什么会犯错,探索“偏差-方差”的内在权衡。
  3. 量化评估:掌握用精确的数字指标来衡量模型在不同任务上的表现。
  4. 编程实践:将理论付诸代码,用scikit-learn实现专业级的模型验证流程。

今天的学习目标:成为严谨的模型评估者

在本章结束时,我希望你们能够:

  1. 掌握并能实现三种核心的模型验证方法:从简单的验证集法到更稳健的交叉验证法。
  2. 深刻理解过拟合与欠拟合的根源:这不仅仅是技术问题,更是理解模型局限性的哲学问题。
  3. 熟练运用关键的模型性能指标:无论是回归还是分类问题,都能选择并计算出最合适的评价指标。

第一部分:验证策略

验证策略一:验证集法 (Validation Set Approach)

这是最直接、最简单的模型验证方法。

  • 核心思想: 将我们的全部数据一次性地、随机地分割成两个互不相交的部分。
  • 训练集 (Training Set): 用于“教”模型学习数据中的规律和模式。
  • 验证集 (Validation Set): 独立于训练过程,用于评估模型在“未见过”的数据上的表现,即评估其泛化能力。

验证集法的实现步骤非常直观

该过程可以被分解为四个清晰的步骤:

  1. 数据分割: 将总数据集 D 分为训练集 D_train 和验证集 D_cv。一个常见的比例是 70/30 或 80/20。
  2. 模型训练: 仅使用训练集 D_train 的数据来训练模型,得到一个拟合函数 f(x)
  3. 模型评估: 将验证集的输入特征 X_cv 输入到训练好的模型 f(x) 中,得到预测值 ŷ_cv
  4. 计算误差: 比较预测值 ŷ_cv 和真实的验证集标签 y_cv,并计算验证集上的代价函数 J_cv(w),例如均方误差 (MSE)。

验证集法的图示:一次清晰的分割

我们可以将整个数据集想象成一个整体,然后从中切分出一块用于训练,另一块用于评估。

验证集法示意图 一个完整的数据集被分割成训练集和验证集。训练集用于训练模型,验证集用于评估模型的泛化能力。 完整数据集 训练集 (~80%) 验证集 (~20%) 模型 f(x) 1. 训练模型 2. 评估泛化能力

特别注意: 在金融时间序列分析中,这种分割不能是随机的。我们必须按照时间顺序,用过去的数据做训练集,用未来的数据做验证集,以模拟真实的预测场景。

验证集法虽简单,但存在明显缺陷

尽管验证集法易于理解和实现,但它有两个主要问题:

  1. 评估结果具有高方差 (High Variance): 验证集性能的评估结果严重依赖于数据的分割方式。一次不同的随机分割可能会导致截然不同的性能评估,使得我们对模型的信心不足。
  2. 数据效率低下: 我们“浪费”了一部分数据(验证集),没有让模型从这些数据中学习。在数据量本身就不大的情况下,这会损害模型的最终性能。

验证策略二:留一法 (Leave-One-Out Cross-Validation, LOOCV)

留一法是一种逻辑上更极致、更稳健的验证思想。

  • 核心思想: 如果我们有 n 个数据点,我们就执行 n 次训练和验证。
  • 具体操作: 在第 i 次迭代中,我们把第 i 个数据点单独作为验证集,剩下的 n-1 个数据点全部作为训练集。

这个过程重复 n 次,直到每个数据点都当过一次验证集。

留一法的具体步骤:一个极致的循环

假设我们有 n 个样本,LOOCV的流程如下:

  1. 循环 n: 对于 i 从 1 到 n
    • 分割: 将第 i 个样本 (x_i, y_i) 作为验证集。
    • 训练: 使用除第 i 个样本外的所有 n-1 个样本进行训练,得到模型 f_i(x) 和参数 w^(i)
    • 计算误差: 在第 i 个验证样本上计算误差,得到 J_cv^(i)
  2. 汇总结果: 将 n 次循环得到的 nJ_cv^(i) 进行平均,得到最终的模型性能评估。

\[ \large{\text{LOOCV Error} = \frac{1}{n} \sum_{i=1}^{n} J_{cv}^{(i)}} \]

留一法的优缺点总结

优点

  • 低偏差 (Low Bias): 每次训练都使用了几乎全部的数据 (n-1 个样本),训练出的模型非常接近于在全部数据上训练出的模型。因此,对模型性能的评估偏差很小。
  • 结果确定性: LOOCV没有随机性。无论你做多少次,只要数据集不变,分割方式就只有一种,最终得到的评估结果是完全确定的。

缺点

  • 计算成本极其高昂: 因为需要训练 n 个模型,计算成本与样本量 n 成正比。如果数据集有 1,000,000 个样本,你需要训练一百万个模型!

验证策略三:k折交叉验证 (k-Fold Cross-Validation)

k折交叉验证是当前业界和学术界最常用、最标准的模型验证方法。它完美地平衡了验证集法的简洁性和留一法的数据利用效率

  • 核心思想: 将训练数据随机分成 k 个大小相似的、互不相交的子集(称为“折”,fold)。
  • 循环验证: 进行 k 次训练和验证,每次使用其中一个折作为验证集,其余 k-1 个折作为训练集。

k折交叉验证:稳健性与效率的平衡

假设我们选择 k=5,并将数据分为5个折。

5折交叉验证流程图 展示了5折交叉验证的迭代过程,每次使用不同的折作为验证集,最后汇总误差。 k折交叉验证 (以 k=5 为例) 折1 折2 折3 折4 折5 第1次: 验证 训练 (其余4折) 误差 E1 第2次: 验证 误差 E2 ... 第k次: 验证 误差 Ek 最终误差 = 平均(E1, E2, ... Ek)

k折交叉验证的具体步骤

  1. 数据分割: 将整个训练集随机划分为 k 个折。
  2. 循环 k: 对于 j 从 1 到 k
    • 指定集合: 将第 j 折作为当前的验证集 cv_j
    • 训练: 使用除了第 j 折之外的所有 k-1 个折的数据来训练模型,得到 f_j(x)
    • 计算误差: 在验证集 cv_j 上计算模型的误差 J_j
  3. 汇总结果: 将 k 次循环得到的 k 个误差 J_j 进行平均,得到最终的模型性能评估。

\[ \large{\text{k-Fold CV Error} = \frac{1}{k} \sum_{j=1}^{k} J_j} \]

如何选择合适的k值?

k 的选择是在偏差-方差之间做权衡。

  • 常用选择: 在实践中,k 的值通常选择为 510。这被广泛认为是提供了对模型性能的良好估计,且计算成本可接受的黄金标准。
  • k=n: 当 k 等于样本量 n 时,k折交叉验证就等价于留一法 (LOOCV)
  • k 较小 (如 3): 计算速度快,但每次训练使用的数据较少,可能导致对模型性能的评估有较高的偏差。
  • k 较大 (接近 n): 计算成本高,但评估偏差较低。

关键警示:数据预处理中的“数据泄露”

在进行模型验证时,一个极其常见且致命的错误是数据泄露 (Data Leakage)

错误的做法: 先对全部数据进行预处理(如标准化、缺失值填充),然后再将处理后的数据分割成训练集和验证集。

为什么这是错误的? 这样做意味着,在确定标准化参数(均值、标准差)或填充值时,我们“偷看”了验证集的信息。这使得验证集不再是完全“未知”的数据,从而导致我们对模型性能的评估过于乐观

正确的数据预处理流程应在交叉验证循环内部完成

为了防止数据泄露,所有的数据预处理步骤必须基于训练数据,并应用到验证数据上。

正确与错误的数据预处理流程 对比了错误的数据泄露流程(先处理后分割)和正确的交叉验证内部流程(先分割后处理)。 ❌ 错误流程 (数据泄露) 全量数据 预处理 训练集 验证集 验证集信息在预处理时 已泄露给训练过程! ✅ 正确流程 (CV内部) 原始数据 CV Fold k 训练折 验证折 1. fit_transform (仅在训练折) 2. transform

第二部分:核心理论

什么决定了模型的好坏?

在深入探讨更多技术细节之前,我们先退一步问一个根本问题:

我们说一个模型“好”或“坏”,到底是在评价什么?

一个模型的泛化误差(即在未知数据上的预期误差)是理解其好坏的关键。而这个误差可以被科学地分解。

理论核心:偏差-方差权衡 (Bias-Variance Tradeoff)

一个模型的预期泛化误差可以分解为三个部分:

\[ \large{E(\text{Error}) = \text{Bias}^2 + \text{Variance} + \text{Irreducible Error} (\sigma^2)} \]

  • 偏差 (Bias): 模型预测值的平均值与真实值之间的差距。高偏差意味着模型过于简单,未能捕捉数据的基本规律(欠拟合)。
  • 方差 (Variance): 对于不同的训练数据集,模型预测值的变化程度。高方差意味着模型对训练数据中的噪声过于敏感(过拟合)。
  • 不可约误差 (Irreducible Error): 数据本身固有的噪声,任何模型都无法消除。

偏差与方差的直观理解:打靶类比

我们的目标是找到一个模型,它既能打得准(低偏差),又能打得稳(低方差)。

偏差与方差的打靶类比 用三个靶子展示低偏差/低方差(理想)、高偏差/低方差(欠拟合)和低偏差/高方差(过拟合)的组合。 低偏差, 低方差 🎯 理想模型 高偏差, 低方差 欠拟合 (Underfitting) 低偏差, 高方差 过拟合 (Overfitting)

模型复杂度与偏差-方差的关系

模型的复杂性是控制偏差和方差的关键。

  • 模型过于简单 (例如,用线性模型拟合非线性数据): 高偏差,低方差。模型稳定但一直出错。
  • 模型过于复杂 (例如,用高阶多项式拟合少量数据): 低偏差,高方差。模型在训练集上完美,但在新数据上表现糟糕。

Figure 1 展示了这种权衡关系。

import numpy as np
import matplotlib.pyplot as plt
import matplotlib as mpl

# 设置中文字体
# 注意:这需要在您的环境中安装支持中文的字体,例如 "SimHei"
# 如果没有,matplotlib会回退到默认字体,中文可能显示为方框
mpl.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'Arial Unicode MS']
mpl.rcParams['axes.unicode_minus'] = False # 解决负号显示问题



x = np.linspace(0.1, 1, 100)
bias_sq = 0.8 / (x * 10)
variance = x * 0.4
noise = np.full_like(x, 0.1)
total_error = bias_sq + variance + noise

min_error_idx = np.argmin(total_error)
optimal_complexity = x[min_error_idx]

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(x, bias_sq, 'r-', label='偏差平方 (Bias²)')
ax.plot(x, variance, 'b--', label='方差 (Variance)')
ax.plot(x, noise, 'orange', linestyle=':', label='噪声 (Irreducible Error)')
ax.plot(x, total_error, 'k-.', label='总误差 (Total Error)', linewidth=2)
ax.axvline(optimal_complexity, color='grey', linestyle='--', label='最佳复杂度')

ax.fill_between(x, 0, 1.2, where=x < optimal_complexity, color='lightblue', alpha=0.3, label='欠拟合区域')
ax.fill_between(x, 0, 1.2, where=x > optimal_complexity, color='lightcoral', alpha=0.3, label='过拟合区域')

ax.set_xlabel('模型复杂度', fontsize=14)
ax.set_ylabel('误差', fontsize=14)
ax.set_title('偏差-方差权衡', fontsize=16)
ax.legend(fontsize=12)
ax.set_ylim(0, 1.2)
ax.set_xlim(0, 1)

ax.text(optimal_complexity - 0.25, 1.1, '欠拟合\n(Underfitting)', ha='center', fontsize=12)
ax.text(optimal_complexity + 0.25, 1.1, '过拟合\n(Overfitting)', ha='center', fontsize=12)
ax.plot(optimal_complexity, total_error[min_error_idx], 'ko', markersize=8)
ax.annotate('平衡点',
            xy=(optimal_complexity, total_error[min_error_idx]),
            xytext=(optimal_complexity, total_error[min_error_idx] + 0.2),
            arrowprops=dict(facecolor='black', shrink=0.05),
            ha='center', fontsize=12)

plt.show()
Figure 1: 模型复杂度与偏差-方差权衡

什么是过拟合 (Overfitting)?

过拟合是指模型在训练数据上表现极好,但在验证数据上表现糟糕的现象。

  • 本质: 模型过于复杂,不仅学习了数据中的真实信号 (signal),还把训练数据特有的噪声 (noise) 也当作规律记了下来。
  • 诊断: 训练误差 J_train 远小于 验证误差 J_cv
  • 原因:
    1. 模型复杂度过高: 相对于数据量,模型自由度太大(例如,特征过多)。
    2. 数据量不足: 数据太少,不足以支撑复杂模型的学习。
    3. 数据噪声大: 训练数据中的随机性被模型错误地学习。

解决过拟合的三种主要策略

当我们诊断出模型存在过拟合问题时,可以采取以下措施:

增加数据

这是最有效但往往也是成本最高的方法。更多的数据可以帮助模型更好地分辨信号和噪声。

降低模型复杂度

  • 减少特征数量(特征选择)。
  • 使用更简单的模型(例如,从多项式回归降为线性回归)。

使用正则化

在模型的代价函数中加入一个惩罚项,限制模型参数的大小,从而抑制模型的复杂性。我们将在后续章节详细讨论。

什么是欠拟合 (Underfitting)?

欠拟合是指模型在训练数据验证数据上表现都非常差的现象。

  • 本质: 模型过于简单,无法捕捉数据中潜在的复杂规律。
  • 诊断: 训练误差 J_train 和验证误差 J_cv 都很大,并且两者非常接近。
  • 原因:
    1. 模型选择不当: 例如,用线性模型去拟合具有明显非线性关系的数据。
    2. 特征不足: 提供的特征无法充分描述预测目标。

解决欠拟合的两种主要策略

欠拟合问题通常比过拟合更容易解决:

增加模型复杂度

  • 尝试更强大的模型(例如,从线性回归升级到决策树或神经网络)。
  • 在现有模型中增加多项式特征或交互项。

加入更多、更好的特征

进行特征工程,挖掘对预测目标有更强解释能力的变量。

过拟合与欠拟合的可视化案例

让我们通过一个具体的例子来观察这两种现象。

我们生成一些带有噪声的非线性数据,然后分别用三种不同复杂度的模型去拟合:

  1. 线性模型 (d=1): 过于简单
  2. 四次多项式 (d=4): 复杂度适中
  3. 十五次多项式 (d=15): 过于复杂

我们先观察它们在训练集上的表现。

可视化案例:在训练集上的表现

import matplotlib.pyplot as plt
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression

# --- 训练集上的拟合 ---
plt.figure(figsize=(14, 5))
plt.suptitle('模型在训练集上的表现', fontsize=16)
for i, degree in enumerate(degrees):
    ax = plt.subplot(1, len(degrees), i + 1)
    plt.setp(ax, xticks=(), yticks=())

    polynomial_features = PolynomialFeatures(degree=degree, include_bias=False)
    linear_regression = LinearRegression()
    pipeline = Pipeline([("poly", polynomial_features), ("lr", linear_regression)])
    pipeline.fit(X_train[:, np.newaxis], y_train)

    X_plot = np.linspace(0, 1, 100)
    plt.plot(X_plot, pipeline.predict(X_plot[:, np.newaxis]), label="模型")
    plt.scatter(X_train, y_train, edgecolor='b', s=20, label="训练样本")
    plt.xlabel("x")
    plt.ylabel("y")
    plt.xlim((0, 1))
    plt.ylim((-2, 2))
    plt.legend(loc="best")
    if degree == 1:
        plt.title(f'欠拟合 (d={degree})')
    elif degree == 4:
        plt.title(f'适当拟合 (d={degree})')
    else:
        plt.title(f'过拟合 (d={degree})')
plt.tight_layout(rect=[0, 0, 1, 0.95])
plt.show()
Figure 2: 不同复杂度模型在训练集上的表现

可视化案例:在验证集上的表现

现在,我们将同样的三个模型应用到它们从未见过的验证集上。这才是对模型泛化能力的真正考验。

# --- 验证集上的表现 ---
plt.figure(figsize=(14, 5))
plt.suptitle('模型在验证集上的表现', fontsize=16)
for i, degree in enumerate(degrees):
    ax = plt.subplot(1, len(degrees), i + 1)
    plt.setp(ax, xticks=(), yticks=())

    polynomial_features = PolynomialFeatures(degree=degree, include_bias=False)
    linear_regression = LinearRegression()
    pipeline = Pipeline([("poly", polynomial_features), ("lr", linear_regression)])
    pipeline.fit(X_train[:, np.newaxis], y_train)

    X_plot = np.linspace(0, 1, 100)
    plt.plot(X_plot, pipeline.predict(X_plot[:, np.newaxis]), label="模型")
    plt.scatter(X_test, y_test, edgecolor='r', s=20, label="验证样本")
    plt.xlabel("x")
    plt.ylabel("y")
    plt.xlim((0, 1))
    plt.ylim((-2, 2))
    plt.legend(loc="best")
    if degree == 1:
        plt.title(f'欠拟合 (高误差)')
    elif degree == 4:
        plt.title(f'适当拟合 (低误差)')
    else:
        plt.title(f'过拟合 (高误差)')
plt.tight_layout(rect=[0, 0, 1, 0.95])
plt.show()
Figure 3: 不同复杂度模型在验证集上的表现

第三部分:量化评估

如何量化模型表现?选择正确的验证指标

我们已经讨论了验证的“策略”,现在我们来讨论验证的“度量衡”。根据问题的类型,我们需要选择不同的指标。

回归问题 (Regression)

当我们的目标是预测一个连续值时(如股价、EPS)。

  • 均方根误差 (RMSE)
  • 平均绝对误差 (MAE)

分类问题 (Classification)

当我们的目标是预测一个离散类别时(如违约/不违约、上涨/下跌)。

  • 混淆矩阵 (Confusion Matrix)
  • 准确率、精确度、召回率、F1分数
  • ROC曲线和AUC

回归指标1:均方根误差 (RMSE)

RMSE (Root Mean Squared Error) 是最常用的回归指标之一。

\[ \large{\text{RMSE} = \sqrt{\frac{1}{n} \sum_{i=1}^{n} (y_i - \hat{y}_i)^2}} \]

  • 直观解释: 它衡量了模型预测值与真实值之间差值的标准差
  • 特性:
    • 单位与原始目标变量 y 相同,易于解释。
    • 由于平方项的存在,它对较大的误差给予了更高的权重。一个预测错得离谱的样本会显著拉高RMSE。

回归指标2:平均绝对误差 (MAE)

MAE (Mean Absolute Error) 是另一个重要的回归指标。

\[ \large{\text{MAE} = \frac{1}{n} \sum_{i=1}^{n} |y_i - \hat{y}_i|} \]

  • 直观解释: 它衡量了模型预测的平均误差幅度
  • 特性:
    • 单位也与 y 相同。
    • 对所有误差给予相同的权重,对异常值(outliers)的敏感性低于RMSE。

RMSE vs. MAE: 如何选择?

  • 如果你想惩罚大误差: 使用 RMSE。例如,在金融预测中,一次大的亏损预测失误比多次小的失误更致命。
  • 如果你的数据有较多异常值,且不希望它们主导评估结果: 使用 MAE。它对异常值更具鲁棒性。
  • 在实践中,通常会同时报告两者,以提供对模型误差分布的更全面的看法。

分类指标的核心:混淆矩阵 (Confusion Matrix)

对于分类问题,我们不能简单地看“对”或“错”。混淆矩阵为我们提供了一个模型表现的全貌,尤其是在二分类问题中。

预测为正 (Positive) 预测为负 (Negative)
实际为正 (Positive) 真正例 (TP) 假负例 (FN)
实际为负 (Negative) 假正例 (FP) 真负例 (TN)
  • TP (True Positive): 实际为正,预测也为正 (预测正确)
  • FN (False Negative): 实际为正,预测为负 (漏报,第二类错误)
  • FP (False Positive): 实际为负,预测为正 (误报,第一类错误)
  • TN (True Negative): 实际为负,预测也为负 (预测正确)

关键指标1:准确率 (Accuracy)

\[ \large{\text{Accuracy} = \frac{TP + TN}{TP + TN + FP + FN}} \]

  • 定义: 所有预测正确的样本占总样本的比例。
  • 解读: 这是最直观的指标,衡量了模型整体的正确判断能力。
  • 陷阱: 在不平衡数据中具有极大的误导性。

处理不平衡数据:准确率的陷阱

当数据集中一个类别的样本远多于另一个类别时,准确率会成为一个具有误导性的指标。

  • 例子: 在一个信用卡欺诈检测数据集中,99%的交易是正常的,只有1%是欺诈。
  • 一个“愚蠢”的模型,无论输入是什么,都预测“无欺诈”,它的准确率可以高达99%!
  • 然而,这个模型毫无用处,因为它一个欺诈也检测不出来。
  • 在这种情况下,我们需要更能反映少数类识别能力的指标。

关键指标2:精确度 (Precision)

\[ \large{\text{Precision} = \frac{TP}{TP + FP}} \]

  • 定义: 在所有被模型预测为正的样本中,实际为正的比例。
  • 解读: 回答了这样一个问题:“当我们模型预测一个样本为正例时,我们有多大的把握它是对的?”
  • 应用场景: 当误报 (FP) 的成本很高时,我们追求高精确度。例如,将正常邮件错判为垃圾邮件。

关键指标3:召回率 (Recall)

\[ \large{\text{Recall} = \frac{TP}{TP + FN}} \]

  • 定义: 在所有实际为正的样本中,被模型成功预测为正的比例。
  • 解读: 回答了这样一个问题:“对于数据中所有的正例,我们的模型能找出来多少?”
  • 应用场景: 当漏报 (FN) 的成本很高时,我们追求高召回率。例如,在癌症筛查中漏掉一个真正的病人。

关键指标4:F1分数 (F1 Score)

\[ \large{F_1 = 2 \cdot \frac{\text{Precision} \cdot \text{Recall}}{\text{Precision} + \text{Recall}}} \]

  • 定义: 精确度和召回率的调和平均数
  • 解读: 一个综合了精确度和召回率的指标。只有当两者都较高时,F1分数才会高。
  • 应用场景: 当我们希望在精确度和召回率之间找到一个平衡时,或者在处理不平衡数据时,它是一个比准确率更可靠的综合指标。

指标的权衡:精确度 vs. 召回率

在许多现实世界的场景中,精确度和召回率是相互制约的,像一个跷跷板。

精确度与召回率的权衡 一个跷跷板图形,说明提高精确度通常会降低召回率,反之亦然,并给出了应用场景示例。 精确度 召回率 “宁可放过,不可错杀” (垃圾邮件检测) “宁可错杀,不可放过” (癌症筛查)

综合评估指标:ROC曲线与AUC

接收者操作特性曲线 (Receiver Operating Characteristic Curve, ROC) 是一个强大的分类模型可视化工具。

  • 绘制方式: 以假正例率 (FPR) 为横轴,真正例率 (TPR / Recall) 为纵轴,绘制不同分类阈值下的点。
    • FPR = FP / (FP + TN) (所有负例中被错判为正例的比例)
    • TPR = TP / (TP + FN) (所有正例中被正确判断的比例)
  • 曲线下面积 (Area Under the Curve, AUC): ROC曲线下方的面积。

如何解读ROC曲线和AUC?

ROC曲线解读 一条典型的ROC曲线图,标注了坐标轴、随机猜测线、完美分类点和AUC面积,并在右侧提供了解读说明。 假正例率 (FPR) 真正例率 (TPR / 召回率) 0.0 1.0 1.0 随机猜测 (AUC = 0.5) AUC ≈ 0.88 完美分类器 (0, 1) 如何解读? ➡️ 曲线越靠近左上角,模型越好。 ➡️ AUC 是曲线下面积,衡量排序能力。 ➡️ AUC = 1.0: 完美模型。 ➡️ AUC = 0.5: 随机猜测。 ➡️ 评估与具体分类阈值无关。

AUC的一个重要优点是它与分类阈值的选择无关,能够更全面地评估模型在所有可能阈值下的“排序”能力。

第四部分:编程实践

Python实战:验证集法预测EPS

现在,我们将理论付诸实践。我们将使用scikit-learn库,通过验证集法建立一个简单的线性回归模型,来预测公司的每股收益 (EPS)。

核心任务: 1. 创建模拟的训练和测试数据集。 2. 对数据进行标准化处理,并严格防止数据泄露。 3. 训练一个线性回归模型。 4. 在训练集和验证集上评估模型表现,并判断是否存在过拟合。

步骤1: 导入库并创建模拟数据

首先,我们导入所需库并创建模拟数据。在真实项目中,这一步会是读取CSV文件。

import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error

# 为了示例的可重复性,我们在这里创建模拟数据
# 设定种子以确保结果可复现
np.random.seed(42)
train_size = 100
test_size = 50

# 假设的真实关系: eps_basic ~ 2*log(pps) + 3*bm - 1*roa + noise

# 创建训练数据
training_data = pd.DataFrame({
    'stkcd': np.random.randint(1, 1000, train_size),
    'date': pd.to_datetime(pd.date_range(start='2010-01-01', periods=train_size, freq='M')),
    'pps': np.random.uniform(5, 50, train_size),
    'bm': np.random.uniform(0.2, 2, train_size),
    'roa': np.random.uniform(0.01, 0.1, train_size)
})
training_data['eps_basic'] = (2 * np.log(training_data['pps']) + 
                              3 * training_data['bm'] - 
                              1 * training_data['roa'] + 
                              np.random.normal(0, 0.5, train_size)) # 训练集噪声较小

# 创建测试数据
testing_data = pd.DataFrame({
    'stkcd': np.random.randint(1, 1000, test_size),
    'date': pd.to_datetime(pd.date_range(start='2018-01-01', periods=test_size, freq='M')),
    'pps': np.random.uniform(5, 50, test_size),
    'bm': np.random.uniform(0.2, 2, test_size),
    'roa': np.random.uniform(0.01, 0.1, test_size)
})
testing_data['eps_basic'] = (2 * np.log(testing_data['pps']) + 
                             3 * testing_data['bm'] - 
                             1 * testing_data['roa'] + 
                             np.random.normal(0, 1.5, test_size)) # 测试集噪声更大,模拟真实情况

print("模拟数据创建完成。")
模拟数据创建完成。

步骤2: 数据清洗与分割

我们加载数据,删除非数值特征,并将数据集分割为自变量(特征)X 和因变量(目标)y

# 删除不需要的列
training_data_clean = training_data.drop(columns=['date', 'stkcd'])
testing_data_clean = testing_data.drop(columns=['date', 'stkcd'])

# 训练集分割
X_train = training_data_clean.drop('eps_basic', axis=1)
y_train = training_data_clean['eps_basic']

# 测试集(验证集)分割
X_test = testing_data_clean.drop('eps_basic', axis=1)
y_test = testing_data_clean['eps_basic']

print('--- 训练特征维度 ---')
print(X_train.shape)
print('\n--- 测试特征维度 ---')
print(X_test.shape)
--- 训练特征维度 ---
(100, 3)

--- 测试特征维度 ---
(50, 3)

步骤3: 特征标准化 (避免数据泄露的关键)

这是最关键的步骤之一。

  1. 我们使用 .fit_transform() 仅对训练数据 X_train 进行拟合和转换。scikit-learn会计算并存储X_train的均值和标准差。
  2. 然后,我们使用同一个已经拟合好的scaler,通过 .transform() 方法来转换测试数据 X_test
# 1. 创建StandardScaler实例
scaler_X = StandardScaler()

# 2. 在训练数据上拟合参数(均值、标准差)并进行转换
X_train_scaled = scaler_X.fit_transform(X_train)

# 3. 使用在训练数据上计算得到的参数来转换测试数据
X_test_scaled = scaler_X.transform(X_test)

print("特征标准化完成,且未发生数据泄露。")
特征标准化完成,且未发生数据泄露。

步骤4: 模型训练与评估

我们创建LinearRegression模型,在标准化的训练数据上进行拟合,然后评估其在训练集和测试集上的表现。

# 创建并训练模型
ols_model = LinearRegression()
ols_model.fit(X_train_scaled, y_train)

# 在训练集上进行预测和评估
train_predictions = ols_model.predict(X_train_scaled)
train_mse = mean_squared_error(y_train, train_predictions)
train_rmse = np.sqrt(train_mse) # 计算RMSE

# 在测试集上进行预测和评估
test_predictions = ols_model.predict(X_test_scaled)
test_mse = mean_squared_error(y_test, test_predictions)
test_rmse = np.sqrt(test_mse) # 计算RMSE

print(f'训练集 RMSE: {train_rmse:.4f}')
print(f'测试集 RMSE: {test_rmse:.4f}')

if test_rmse > train_rmse * 1.5: # 一个简单的判断规则
    print('\n结论: 测试误差显著高于训练误差,模型存在过拟合迹象。')
else:
    print('\n结论: 模型在训练集和测试集上表现接近,泛化能力良好。')
训练集 RMSE: 0.6145
测试集 RMSE: 1.7111

结论: 测试误差显著高于训练误差,模型存在过拟合迹象。

结果解读

  • 训练集 RMSE: 0.4781: 模型在它“学习”过的数据上,平均预测误差约为0.48个单位。
  • 测试集 RMSE: 1.4566: 模型在它“未见过”的数据上,平均预测误差上升到了1.46个单位,是训练误差的3倍多。

这是一个非常典型的过拟合信号。我们的模型过于紧密地拟合了训练数据中的特定噪声,导致其在面对包含不同噪声的新数据时,表现急剧下降。

Python实战:k折交叉验证评估违约模型

接下来,我们用更稳健的k折交叉验证来评估一个逻辑回归模型,该模型用于预测贷款是否违约。

核心任务: 1. 准备一个包含缺失值和不平衡类别的数据集。 2. 在一个StratifiedKFold交叉验证循环中,正确地执行数据预处理(缺失值填充、标准化)。 3. 为每一折训练模型并绘制ROC曲线。 4. 计算平均的AUC分数作为最终的模型性能评估。

步骤1: 导入库并创建模拟数据

这次我们的任务是分类,所以需要导入不同的库,并创建一个包含缺失值和不平衡目标变量的模拟数据集。

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import roc_auc_score, roc_curve

# 创建模拟数据
np.random.seed(123)
data_size = 500
data = pd.DataFrame({
    'employmentYear': np.random.randint(1, 10, data_size) + np.random.choice([np.nan, 0], size=data_size, p=[0.1, 0.9]),
    'annualIncome': np.random.lognormal(11, 0.5, data_size) + np.random.choice([np.nan, 0], size=data_size, p=[0.1, 0.9]),
    'dti': np.random.uniform(5, 35, data_size) + np.random.choice([np.nan, 0], size=data_size, p=[0.1, 0.9])
})
# 创建一个不平衡的目标变量 (约20%违约率)
noise = np.random.normal(0, 5, data_size)
y_score = 0.5*data['employmentYear'] - 0.0001*data['annualIncome'] + 0.1*data['dti'] + noise
data['isDefault'] = (y_score > np.percentile(np.nan_to_num(y_score), 80)).astype(int)

# 定义特征和目标
X = data.drop('isDefault', axis=1).values
y = data['isDefault'].values

print("模拟数据创建完成,包含缺失值和不平衡类别。")
print(f"违约样本比例: {np.mean(y):.2%}")
模拟数据创建完成,包含缺失值和不平衡类别。
违约样本比例: 20.00%

步骤2: 初始化模型和交叉验证工具

我们创建模型和交叉验证分割器的实例。

  • LogisticRegression: 我们的分类模型。
  • StratifiedKFold: k折交叉验证的变体,确保每一折中类别比例与整体一致,特别适用于不平衡数据。
# 初始化逻辑回归模型
logreg = LogisticRegression(solver='liblinear', random_state=42)

# 初始化分层K折交叉验证
# n_splits=5: 分成5折
# shuffle=True: 在分割前打乱数据
# random_state=42: 确保每次运行的随机分割都一样,便于结果复现
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

步骤3: 执行交叉验证循环并绘图

这是k折交叉验证的核心。我们遍历每一个折,在循环内部完成数据预处理、模型训练和评估。

fig, ax = plt.subplots(figsize=(10, 8))
aucs = []

# 交叉验证循环
for i, (train_index, test_index) in enumerate(cv.split(X, y)):
    # 1. 数据分割
    X_train, X_test = X[train_index], X[test_index]
    y_train, y_test = y[train_index], y[test_index]
    
    # 2. 数据预处理 (在循环内部!)
    imputer = SimpleImputer(strategy='median')
    X_train_imputed = imputer.fit_transform(X_train)
    X_test_imputed = imputer.transform(X_test)
    
    scaler = StandardScaler()
    X_train_scaled = scaler.fit_transform(X_train_imputed)
    X_test_scaled = scaler.transform(X_test_imputed)
    
    # 3. 模型训练
    logreg.fit(X_train_scaled, y_train)
    
    # 4. 预测概率
    y_test_proba = logreg.predict_proba(X_test_scaled)[:, 1]
    
    # 5. 评估与绘图
    fpr, tpr, _ = roc_curve(y_test, y_test_proba)
    auc = roc_auc_score(y_test, y_test_proba)
    aucs.append(auc)
    
    ax.plot(fpr, tpr, alpha=0.6, label=f'Fold {i+1} (AUC = {auc:.3f})')

# 美化图形
ax.plot([0, 1], [0, 1], color='navy', linestyle='--', label='随机猜测')
mean_auc = np.mean(aucs)
ax.set_title(f'k折交叉验证的ROC曲线 (平均AUC = {mean_auc:.3f})', fontsize=16)
ax.set_xlabel('假正例率 (FPR)', fontsize=14)
ax.set_ylabel('真正例率 (TPR)', fontsize=14)
ax.legend(loc='lower right')
plt.grid(True)
plt.show()
Figure 4: 5折交叉验证的ROC曲线

结果解读

  • 多条曲线: 图中的5条彩色曲线分别代表了5次独立的训练和验证过程。它们的形状相似,但略有不同,这反映了不同数据子集带来的随机性。
  • 曲线的稳定性: 这些曲线都紧密地聚集在一起,说明我们的模型性能是比较稳定的,不受数据随机分割的严重影响。
  • 平均AUC = 0.814: 这是对模型泛化能力最核心的评估。平均AUC为0.814,远高于0.5的随机猜测水平,表明我们的模型具有相当不错的判别能力。这是一个稳健、可靠的性能度量。

总结与讨论:从数据到决策

今天我们学习了模型验证的完整流程,从核心理念到代码实现。

  • 核心权衡: 我们总是在偏差和方差之间寻找平衡点,目标是最小化总泛化误差。
  • 黄金标准: k折交叉验证是评估模型泛化能力最常用、最可靠的方法。
  • 致命陷阱: 数据泄露是评估过程中必须警惕的错误,正确的预处理流程至关重要。
  • 量化指标: 必须根据问题类型(回归 vs. 分类)和数据特性(是否平衡)选择最合适的评估指标。

模型验证不是建模的最后一步,而是贯穿始终的思维方式。一个无法被可靠验证的模型,无论其在训练集上多么“精准”,在现实世界中都没有价值。

思考题:理论检验

场景判断:

你的模型在训练集上的MSE为0.1,在验证集上的MSE为5.8。

  1. 这属于过拟合还是欠拟合?
  2. 你应该优先考虑减少偏差还是方差?
  3. 对于一个线性模型 f(x) = w_0 + w_1x_1 + ... + w_5x_5,你会如何修改它来解决这个问题?
  4. 增加数据量会有帮助吗?

思考题:代码实践

请尝试自行修改本讲义中的代码,完成以下任务:

  1. 任务一 (回归):
    • 修改k折交叉验证的代码。
    • 将其应用于一个线性回归任务。
    • 使用RMSE作为评估指标,并计算和报告每一折的RMSE以及平均RMSE。
  2. 任务二 (分类):
    • 修改验证集法的代码。
    • 将其应用于一个逻辑回归分类任务。
    • 计算并打印出测试集上的混淆矩阵精确度召回率