06 模型验证

本章会用到的数据

  • 公开下载沪深 300 指数行情。代码会在首次运行时下载并保存到 data/course/

  • 这些数据能做什么:练习按时间划分训练、验证和最终测试数据,并比较预测误差。

  • 分析时注意:时间序列不能随机打乱;每一步数据处理都只能根据训练数据确定。

  • 判断模型:模型选择完成后,最终测试数据只使用一次。

【课堂核心】3 学时学习安排

学习内容 分钟
动机与先修诊断 20
验证器选择 40
泄漏、折内预处理与指标 35
形成性检查 20
独立练习 45
小结 20

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

  • 历史表现:复杂模型在训练集上近乎完美。
  • 真实结果:面对后续市场数据,交易表现可能迅速恶化。
  • 核心问题:训练集上的拟合能否迁移到未知数据?
  • 本章任务:设计验证流程,评估模型的泛化能力。

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

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

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

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

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

本章学习路线图

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

  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)

留一法是一种把每个观测轮流作为验证单位的重抽样思想。

  • 适用前提:普通 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)}} \]

留一法的优缺点总结

优点

  • 折内训练样本多:每折使用 n-1 个样本;其误差估计偏差并非无条件很小,而取决于学习算法、损失、目标训练样本量与数据生成过程。
  • 折分确定性:给定样本时,留一折分是确定的;模型初始化、随机优化、折内预处理及软件/硬件环境仍须固定并记录,结果才能复算。

缺点

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

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

  • 适用对象:近似 iid、可交换的横截面样本。

  • 核心折中:在计算量与重复利用训练样本之间取得平衡。

  • 时间序列:按时间使用扩展窗或滚动窗。

  • 公司面板:按公司分组,不能直接随机分折。

  • 调参流程:开发期完成选择后,再打开独立最终测试。

  • 核心思想: 将训练数据随机分成 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. 数据分割: 仅当观测近似 iid/可交换时,将开发训练集随机划分为 k 个折;否则按时间或分组构造折。
  2. 循环 k: 对于 j 从 1 到 k
    • 指定集合: 将第 j 折作为当前的验证集 cv_j
    • 训练: 使用除了第 j 折之外的所有 k-1 个折的数据来训练模型,得到 f_j(x)
    • 计算误差: 在验证集 cv_j 上计算模型的误差 J_j
  3. 汇总结果: 若第 \(j\) 折有 \(n_j\) 个验证观测、\(J_j\) 是该折逐观测平均损失,应按观测数加权汇总。

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

只有各折等大时,它才等于 \(k^{-1}\sum_jJ_j\)

如何选择合适的k值?

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

  • iid 横截面: 5 折或 10 折可作为计算预算内的候选,不是普遍黄金标准。
  • k=n: 当 k 等于样本量 n 时,k折交叉验证就等价于留一法 (LOOCV)
  • k 较小 (如 3): 计算速度快,但每次训练使用的数据较少,可能导致对模型性能的评估有较高的偏差。
  • 依赖数据: 时间预测采用扩展/滚动窗口;同公司多期面板按公司分组;若还要调参,开发期完成交叉验证后再打开一次独立最终测试,必要时使用嵌套 CV。

形成性检查:先识别抽样单位

独立作答:分别为“独立横截面违约样本”“同公司多期面板”“下一交易日收益预测”“需要调参后的最终评价”选择验证器。

形成性检查反馈:验证器必须保留依赖结构

数据结构或任务 验证器
独立横截面分类 分层随机 k 折
同公司多期面板 GroupKFold / 公司留组
时间预测 TimeSeriesSplit / 滚动窗
调参后的最终评价 开发期 CV + 一次独立最终测试

选择随机 k 折处理后三类者,返回 相关内容 订正。

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

在进行模型验证时,一个极其常见且致命的错误是数据泄露 (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)

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

  • 模型过于简单 (例如,用线性模型拟合非线性数据): 高偏差,低方差。模型稳定但一直出错。
  • 模型过于复杂 (例如,用高阶多项式拟合少量数据): 低偏差,高方差。模型在训练集上完美,但在新数据上表现糟糕。
代码
# 为“模型复杂度与偏差-方差的关系”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 为“模型复杂度与偏差-方差的关系”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt
# 导入 Matplotlib 顶层配置接口并绑定为 `mpl`,用于统一偏差—方差图的中文字体与投影样式。
import matplotlib as mpl

# 设置中文字体
# 注意:这需要在您的环境中安装支持中文的字体,例如 "Source Han Serif SC"
# 如果没有,matplotlib会回退到默认字体,中文可能显示为方框
mpl.rcParams['font.sans-serif'] = ['Source Han Serif SC', 'Noto Serif CJK SC']  # 固定开源中文字体栈并移除专有字体回退
mpl.rcParams['axes.unicode_minus'] = False # 解决负号显示问题



# 建立 `x` 的有序取值网格,用于展示“模型复杂度与偏差-方差权衡”随参数变化的比较结果。
x = np.linspace(0.1, 1, 100)
# 用随复杂度递减的反比曲线表示偏差平方。
bias_sq = 0.8 / (x * 10)
# 用随复杂度递增的直线表示估计方差。
variance = x * 0.4
# 将不可约噪声固定为 0.1,作为总误差曲线的水平下界。
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` 画布,承载“模型复杂度与偏差-方差权衡”的并排视觉比较。
fig, ax = plt.subplots(figsize=(10, 3.4))
# 以 `x` 为横轴、`bias_sq` 为纵轴绘制曲线,展示“模型复杂度与偏差-方差权衡”。
ax.plot(x, bias_sq, 'r-', label='偏差平方 (Bias²)')
# 以 `x` 为横轴、`variance` 为纵轴绘制曲线,展示“模型复杂度与偏差-方差权衡”。
ax.plot(x, variance, 'b--', label='方差 (Variance)')
# 以 `x` 为横轴、`noise` 为纵轴绘制曲线,展示“模型复杂度与偏差-方差权衡”。
ax.plot(x, noise, 'orange', linestyle=':', label='噪声 (Irreducible Error)')
# 以 `x` 为横轴、`total_error` 为纵轴绘制曲线,展示“模型复杂度与偏差-方差权衡”。
ax.plot(x, total_error, 'k-.', label='总误差 (Total Error)', linewidth=2)
# 在 `optimal_complexity` 处添加垂直参考线,标出“模型复杂度与偏差-方差权衡”的基准或阈值。
ax.axvline(optimal_complexity, color='grey', linestyle='--', label='最佳复杂度')

# 在 `ax` 上填充两条边界之间的区域。
ax.fill_between(x, 0, 1.2, where=x < optimal_complexity, color='lightblue', alpha=0.3, label='欠拟合区域')
# 在 `ax` 上填充两条边界之间的区域。
ax.fill_between(x, 0, 1.2, where=x > optimal_complexity, color='lightcoral', alpha=0.3, label='过拟合区域')

# 将横轴标为“模型复杂度”,明确横向编码的变量。
ax.set_xlabel('模型复杂度', fontsize=16)
# 将纵轴标为“误差”,明确纵向编码的变量。
ax.set_ylabel('误差', fontsize=16)
# 将图题设为“偏差-方差权衡”,直接说明当前图形的比较目的。
ax.set_title('偏差-方差权衡', fontsize=18)
# 显示“模型复杂度与偏差-方差的关系”图例,使颜色或线型与比较对象一一对应。
ax.legend(fontsize=16)
# 为“模型复杂度与偏差-方差的关系”,限定纵轴范围,使比较对象使用一致尺度。
ax.set_ylim(0, 1.2)
# 为“模型复杂度与偏差-方差的关系”,限定横轴范围,使比较对象使用一致尺度。
ax.set_xlim(0, 1)

# 在图中标注“欠拟合 (Underfitting)”,解释“模型复杂度与偏差-方差权衡”的关键位置。
ax.text(optimal_complexity - 0.25, 1.1, '欠拟合\n(Underfitting)', ha='center', fontsize=16)
# 在图中标注“过拟合 (Overfitting)”,解释“模型复杂度与偏差-方差权衡”的关键位置。
ax.text(optimal_complexity + 0.25, 1.1, '过拟合\n(Overfitting)', ha='center', fontsize=16)
# 以 `optimal_complexity` 为横轴、`total_error[min_error_idx]` 为纵轴绘制曲线,展示“模型复杂度与偏差-方差权衡”。
ax.plot(optimal_complexity, total_error[min_error_idx], 'ko', markersize=8)
# 以箭头标注“平衡点”,指出“模型复杂度与偏差-方差权衡”中的候选拐点或阈值。
ax.annotate('平衡点',
            # 把注释箭头锚定在总误差最低的复杂度与误差坐标。
            xy=(optimal_complexity, total_error[min_error_idx]),
            # 将文字置于最低点上方 0.2,避免箭头与误差曲线重叠。
            xytext=(optimal_complexity, total_error[min_error_idx] + 0.2),
            # 指定 `arrowprops` 为注释箭头样式,细化“模型复杂度与偏差-方差权衡”的输出。
            arrowprops=dict(facecolor='black', shrink=0.05),
            # 将标注水平居中并设为 12pt,使文字与最优点对齐。
            ha='center', fontsize=12)

# 显示偏差平方、方差、不可约噪声与总误差曲线,检查总误差最低点两侧的欠拟合和过拟合区域。
plt.show()
横轴为模型复杂度,纵轴为误差;偏差平方随复杂度下降、方差随复杂度上升,不可约噪声保持水平,三者之和形成先降后升的总误差曲线并标出最低点。
图 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): 过于复杂

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

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

代码
# 为“可视化案例:在训练集上的表现”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt
# 为“可视化案例:在训练集上的表现”,从 `sklearn.pipeline` 导入`Pipeline` 用于把预处理与估计器串成同一拟合流程。
from sklearn.pipeline import Pipeline
# 为“可视化案例:在训练集上的表现”,从 `sklearn.preprocessing` 导入`PolynomialFeatures` 用于由原始特征展开多项式项。
from sklearn.preprocessing import PolynomialFeatures
# 为“可视化案例:在训练集上的表现”,从 `sklearn.linear_model` 导入`LinearRegression` 用于拟合普通最小二乘线性回归。
from sklearn.linear_model import LinearRegression

# 创建训练集三面板画布,按相同坐标尺度比较一、四、十五阶模型的样本内拟合。
plt.figure(figsize=(14, 5))
# 将图题设为“模型在训练集上的表现”,直接说明当前图形的比较目的。
plt.suptitle('模型在训练集上的表现', fontsize=16)
# 为“可视化案例:在训练集上的表现”,遍历 `i, degree in enumerate(degrees)` 的多项式阶数,比较欠拟合、适度拟合与过拟合。
for i, degree in enumerate(degrees):
    # 把当前多项式阶数放入对应子图,便于横向比较三种复杂度。
    ax = plt.subplot(1, len(degrees), i + 1)
    # 将当前 Matplotlib 元素集合的文字旋转角度与对齐方式设为给定值。
    plt.setp(ax, xticks=(), yticks=())

    # 创建 `PolynomialFeatures` 实例 `polynomial_features`,用于由原始特征展开多项式项。
    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` 的有序取值网格,用于展示“不同复杂度模型在训练集上的表现”随参数变化的比较结果。
    X_plot = np.linspace(0, 1, 100)
    # 用已拟合模型生成预测值。
    plt.plot(X_plot, pipeline.predict(X_plot[:, np.newaxis]), label="模型")
    # 以 `X_train` 为横轴、`y_train` 为纵轴绘制散点,展示“不同复杂度模型在训练集上的表现”。
    plt.scatter(X_train, y_train, edgecolor='b', s=20, label="训练样本")
    # 为“可视化案例:在训练集上的表现”,将横轴标为“x”,明确横向编码的变量。
    plt.xlabel("x")
    # 为“可视化案例:在训练集上的表现”,将纵轴标为“y”,明确纵向编码的变量。
    plt.ylabel("y")
    # 限定横轴显示范围,聚焦教学区间。
    plt.xlim((0, 1))
    # 为“可视化案例:在训练集上的表现”,限定纵轴显示范围,避免比例差异误导比较。
    plt.ylim((-2, 2))
    # 显示“可视化案例:在训练集上的表现”图例,使颜色或线型与比较对象一一对应。
    plt.legend(loc="best")
    # 一次多项式只画直线,作为欠拟合对照。
    if degree == 1:
        # 将图题设为“欠拟合 (d={degree})”,直接说明当前图形的比较目的。
        plt.title(f'欠拟合 (d={degree})')
    # 四次多项式提供适中弯曲度,作为当前机制样本的平衡对照。
    elif degree == 4:
        # 将图题设为“适当拟合 (d={degree})”,直接说明当前图形的比较目的。
        plt.title(f'适当拟合 (d={degree})')
    # 其余高阶情形作为过拟合对照。
    else:
        # 将图题设为“过拟合 (d={degree})”,直接说明当前图形的比较目的。
        plt.title(f'过拟合 (d={degree})')
# 为“可视化案例:在训练集上的表现”,压缩子图留白,避免标题和坐标标签相互遮挡。
plt.tight_layout(rect=[0, 0, 1, 0.95])
# 显示三个阶数在训练样本上的拟合曲线,比较欠拟合与高阶模型追随噪声的程度。
plt.show()
相同训练散点上叠加低、中、高复杂度拟合曲线;训练误差随复杂度增加而下降,高阶曲线开始追随噪声。
图 2: 不同复杂度模型在训练集上的表现

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

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

代码
# 创建验证集三面板画布,检查同一批训练模型在未参与拟合的数据上的误差。
plt.figure(figsize=(14, 5))
# 将图题设为“模型在验证集上的表现”,直接说明当前图形的比较目的。
plt.suptitle('模型在验证集上的表现', fontsize=16)
# 为“可视化案例:在验证集上的表现”,遍历 `i, degree in enumerate(degrees)` 的多项式阶数,比较欠拟合、适度拟合与过拟合。
for i, degree in enumerate(degrees):
    # 把当前多项式阶数放入对应子图,便于比较验证期泛化误差。
    ax = plt.subplot(1, len(degrees), i + 1)
    # 将当前 Matplotlib 元素集合的文字旋转角度与对齐方式设为给定值。
    plt.setp(ax, xticks=(), yticks=())

    # 创建 `PolynomialFeatures` 实例 `polynomial_features`,用于由原始特征展开多项式项。
    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` 的有序取值网格,用于展示“不同复杂度模型在验证集上的表现”随参数变化的比较结果。
    X_plot = np.linspace(0, 1, 100)
    # 用已拟合模型生成预测值。
    plt.plot(X_plot, pipeline.predict(X_plot[:, np.newaxis]), label="模型")
    # 以 `X_test` 为横轴、`y_test` 为纵轴绘制散点,展示“不同复杂度模型在验证集上的表现”。
    plt.scatter(X_test, y_test, edgecolor='r', s=20, label="验证样本")
    # 为“可视化案例:在验证集上的表现”,将横轴标为“x”,明确横向编码的变量。
    plt.xlabel("x")
    # 为“可视化案例:在验证集上的表现”,将纵轴标为“y”,明确纵向编码的变量。
    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()
三个验证集散点面板分别叠加 1、4、15 阶多项式拟合;1 阶未捕捉弯曲,4 阶较贴合整体规律,15 阶曲线剧烈波动并显示过拟合。
图 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。

边界反例:误差 \(e=[1,3]\) 时,\(RMSE=\sqrt{5}\approx2.236\),总体标准差为 1。只有误差均值为 0 时,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:完美排序。 • AUC = 0.5:随机排序。 • 实际使用阈值仍须单独选择。

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

第四部分:编程实践

Python实战:验证集法预测EPS

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

核心任务:

  1. 创建机制演示用的训练集和后段验证集;本例不打开最终测试集。
  2. 对数据进行标准化处理,并严格防止数据泄露
  3. 训练一个线性回归模型。
  4. 在训练集和验证集上评估模型表现,并判断是否存在过拟合。

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

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

代码
# 为“步骤1: 导入库并创建模拟数据”导入 `pandas` 并绑定 `pd`,用于整理当前任务的表格、字段与时间索引。
import pandas as pd
# 为“步骤1: 导入库并创建模拟数据”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 为“步骤1: 导入库并创建模拟数据”,导入 `StandardScaler`,供每个交叉验证训练折按特征列估计均值与尺度。
from sklearn.preprocessing import StandardScaler
# 为“步骤1: 导入库并创建模拟数据”,从 `sklearn.linear_model` 导入`LinearRegression` 用于拟合普通最小二乘线性回归。
from sklearn.linear_model import LinearRegression
# 为“步骤1: 导入库并创建模拟数据”,从 `sklearn.metrics` 导入`mean_squared_error` 用于计算真实值与预测值的均方误差。
from sklearn.metrics import mean_squared_error

# 为了示例的可重复性,我们在这里创建模拟数据
# 设定种子以确保结果可复现
np.random.seed(42)
# 将机制演示的训练样本数固定为 100,控制训练期覆盖范围。
train_size = 100
# 将后段验证样本数固定为 50,保留独立于训练期的时间区间。
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),
    # 生成从 2010 年起的训练期月末时间索引。
    '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` 的字段定义,形成较早时期的训练面板。
})
# 按对数股价、账面市值比与 ROA 的设定关系生成训练期 EPS。
training_data['eps_basic'] = (2 * np.log(training_data['pps']) +
                              # 加入 3 倍账面市值比项,构造训练期 EPS 的正向价值效应。
                              3 * training_data['bm'] -
                              # 减去 1 倍资产收益率项,保持本段既定的教学生成公式。
                              1 * training_data['roa'] +
                              np.random.normal(0, 0.5, train_size)) # 训练集噪声较小

步骤1(续):创建后段验证数据

代码
# 创建时间更晚的验证数据
validation_data = pd.DataFrame({
    # 生成验证期证券编号,保持与训练表相同字段结构。
    'stkcd': np.random.randint(1, 1000, test_size),
    # 生成从 2018 年起的后段月末时间索引。
    '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)
# 完成 `validation_data` 的字段定义,形成时间上晚于训练期的验证面板。
})
# 沿用相同信号关系并放大噪声,生成后段验证期 EPS。
validation_data['eps_basic'] = (2 * np.log(validation_data['pps']) +
                                # 沿用训练期的 3 倍账面市值比项,使前后时期共享同一信号机制。
                                3 * validation_data['bm'] -
                                # 沿用负向资产收益率项,只让验证期噪声结构发生变化。
                                1 * validation_data['roa'] +
                                np.random.normal(0, 1.5, test_size)) # 验证窗噪声更大,仅演示分布变化

# 报告前段训练表与后段验证表已经构造完毕。
print("模拟数据创建完成。")
模拟数据创建完成。

步骤2: 数据清洗与分割

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

代码
# 删除不需要的列
training_data_clean = training_data.drop(columns=['date', 'stkcd'])
# 从验证表移除日期与证券代码,只保留与训练模型一致的数值字段。
validation_data_clean = validation_data.drop(columns=['date', 'stkcd'])

# 训练集分割
X_train = training_data_clean.drop('eps_basic', axis=1)
# 从训练表提取 EPS 作为回归目标。
y_train = training_data_clean['eps_basic']

# 验证集分割
X_validation = validation_data_clean.drop('eps_basic', axis=1)
# 提取验证期 EPS 目标列,保持与训练目标相同的单位和行索引。
y_validation = validation_data_clean['eps_basic']

# 标出训练特征矩阵的维度检查结果。
print('--- 训练特征维度 ---')
# 输出训练特征矩阵形状,核对样本数和三项预测变量。
print(X_train.shape)
# 标出验证特征矩阵的维度检查结果。
print('\n--- 验证特征维度 ---')
# 输出验证特征矩阵形状,确认字段数与训练矩阵一致。
print(X_validation.shape)
--- 训练特征维度 ---
(100, 3)

--- 验证特征维度 ---
(50, 3)

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

这是最关键的步骤之一。

  1. 训练折拟合:对 X_train 调用 .fit_transform(),逐特征估计并保存训练均值与标准差。
  2. 验证折复用:对 X_validation 只调用同一 scaler 的 .transform(),不得重新拟合。
代码
# 创建只由训练样本逐特征估计统计量的 StandardScaler。
scaler_X = StandardScaler()

# 在训练样本上逐特征估计均值与标准差并完成转换。
X_train_scaled = scaler_X.fit_transform(X_train)

# 使用确定的训练样本逐特征均值与标准差转换验证数据。
X_validation_scaled = scaler_X.transform(X_validation)

# 报告标准化器只在训练期拟合,验证期仅复用训练参数。
print("特征标准化完成,且未发生数据泄露。")
特征标准化完成,且未发生数据泄露。

步骤4: 模型训练与评估

我们创建 LinearRegression 模型,在标准化训练集拟合,再比较训练集与验证集。验证结果可用于诊断或改进模型,因此不能称为最终测试证据。

代码
# 创建并训练模型
ols_model = LinearRegression()
# 用标准化训练期特征估计 OLS 系数,验证期仅用于一次样本外误差评估。
ols_model.fit(X_train_scaled, y_train)

# 在训练集上进行预测和评估
train_predictions = ols_model.predict(X_train_scaled)
# 计算 `train_mse`(均方误差),量化“步骤4: 模型训练与评估”的模型表现。
train_mse = mean_squared_error(y_train, train_predictions)
train_rmse = np.sqrt(train_mse) # 计算RMSE

# 在验证集上进行预测和评估
validation_predictions = ols_model.predict(X_validation_scaled)
# 计算 `validation_mse`(均方误差),量化“步骤4: 模型训练与评估”的模型表现。
validation_mse = mean_squared_error(y_validation, validation_predictions)
validation_rmse = np.sqrt(validation_mse) # 计算RMSE

# 报告“训练集 RMSE: {train_rmse:.4f}”中的 `train_rmse`,评价“步骤4: 模型训练与评估”的模型表现。
print(f'训练集 RMSE: {train_rmse:.4f}')
# 报告“验证集 RMSE: {validation_rmse:.4f}”中的 `validation_rmse`,评价“步骤4: 模型训练与评估”的模型表现。
print(f'验证集 RMSE: {validation_rmse:.4f}')

if validation_rmse > train_rmse * 1.5: # 一个简单的课堂诊断规则
    # 当验证 RMSE 超过训练 RMSE 的 1.5 倍时,报告描述性经验信号并保留多种解释。
    print('\n结论: 验证 RMSE 超过训练 RMSE 的 1.5 倍经验阈值;可能来自过拟合或分布变化,这不是统计显著性检验。')
# 当验证 RMSE 未超过训练 RMSE 的 1.5 倍时,不触发本页的描述性经验阈值。
else:
    # 当经验阈值未触发时,仅报告误差接近,并保留最终测试集验证要求。
    print('\n结论: 模型在训练集和验证集上表现接近;仍须用最终测试集作一次最终评估。')
训练集 RMSE: 0.6145
验证集 RMSE: 1.7111

结论: 验证 RMSE 超过训练 RMSE 的 1.5 倍经验阈值;可能来自过拟合或分布变化,这不是统计显著性检验。

结果解读

  • 训练集 RMSE: 0.6145:模型在训练样本上的均方根误差约为 0.61 个单位。
  • 验证集 RMSE: 1.7111:模型在后段验证窗上的均方根误差升至约 1.71 个单位,明显高于训练误差。

这是一个过拟合或分布变化的诊断信号,不能只凭一次生成样本区分两者。改进决策只能使用训练/验证数据;最终流程确定后,最终测试集只打开一次。

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

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

核心任务:

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

模拟横截面仅演示随机分折条件

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

代码
# 为“步骤1: 导入库并创建模拟数据”导入 `pandas` 并绑定 `pd`,用于整理当前任务的表格、字段与时间索引。
import pandas as pd
# 为“步骤1: 导入库并创建模拟数据”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 为“步骤1: 导入库并创建模拟数据”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt
# 为“步骤1: 导入库并创建模拟数据”,从 `sklearn.impute` 导入`SimpleImputer` 用于按指定统计量填补缺失值。
from sklearn.impute import SimpleImputer
# 为“步骤1: 导入库并创建模拟数据”,导入 `StandardScaler`,把缩放统计量限制在每个训练折内按特征列估计。
from sklearn.preprocessing import StandardScaler
# 为“步骤1: 导入库并创建模拟数据”,从 `sklearn.linear_model` 导入`LogisticRegression` 用于拟合逻辑回归分类器。
from sklearn.linear_model import LogisticRegression
# 为“步骤1: 导入库并创建模拟数据”,从 `sklearn.model_selection` 导入`StratifiedKFold` 用于调用 `StratifiedKFold` 的专用接口。
from sklearn.model_selection import StratifiedKFold
# 为“步骤1: 导入库并创建模拟数据”,从 `sklearn.metrics` 导入`roc_auc_score` 用于计算分类得分的 ROC AUC,`roc_curve` 用于计算不同分类阈值下的 ROC 坐标。
from sklearn.metrics import roc_auc_score, roc_curve

# 为“步骤1: 导入库并创建模拟数据”,创建模拟数据
np.random.seed(123)
# 将分类机制演示规模固定为 500 条贷款记录。
data_size = 500
# 建立 500 条贷款机制记录的表格,集中保存工龄、收入、负债率和标签生成字段。
data = pd.DataFrame({
    # 生成带约 10% 缺失的就业年限特征。
    'employmentYear': np.random.randint(1, 10, data_size) + np.random.choice([np.nan, 0], size=data_size, p=[0.1, 0.9]),
    # 生成右偏且带约 10% 缺失的年收入特征。
    'annualIncome': np.random.lognormal(11, 0.5, data_size) + np.random.choice([np.nan, 0], size=data_size, p=[0.1, 0.9]),
    # 生成带约 10% 缺失的债务收入比特征。
    'dti': np.random.uniform(5, 35, data_size) + np.random.choice([np.nan, 0], size=data_size, p=[0.1, 0.9])
# 完成 `data` 的三个贷款特征字段,随后按同一观测顺序生成违约标签。
})
# 创建一个不平衡的目标变量 (约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']` 的业务取值与顺序,作为“步骤1: 导入库并创建模拟数据”的可复算输入。
data['isDefault'] = (y_score > np.percentile(np.nan_to_num(y_score), 80)).astype(int)

# 为“步骤1: 导入库并创建模拟数据”,定义特征和目标
X = data.drop('isDefault', axis=1).values
# 提取不平衡违约标签作为分层交叉验证目标。
y = data['isDefault'].values

# 报告贷款特征中的缺失值与少数违约类已按练习设定生成。
print("模拟数据创建完成,包含缺失值和不平衡类别。")
# 输出生成标签的正类比例,检查约 20% 的不平衡设定是否成立。
print(f"违约样本比例: {np.mean(y):.2%}")
模拟数据创建完成,包含缺失值和不平衡类别。
违约样本比例: 20.00%

分层折与流程共同阻断泄漏

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

  • 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)

折间波动必须随均值一起报告

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

代码
# 创建 `fig, ax` 画布,承载“5折交叉验证的ROC曲线”的并排视觉比较。
fig, ax = plt.subplots(figsize=(10, 8))
# 初始化跨折 AUC 累积器;此时尚未产生任何评价分数。
aucs = []

# 交叉验证循环
line_styles = ['-', '--', '-.', ':', (0, (5, 2, 1, 2))]
for i, (train_index, test_index) in enumerate(cv.split(X, y)):
    # 按当前随机分层折索引切出训练子集与验证子集,保持两类标签比例近似一致。
    X_train, X_test = X[train_index], X[test_index]
    # 按当前折索引同步提取训练与验证标签,保持与特征行一一对应。
    y_train, y_test = y[train_index], y[test_index]

    # 在当前训练折内建立中位数填补器,避免验证信息进入缺失值统计量。
    imputer = SimpleImputer(strategy='median')
    # 提取 `X_train_imputed` 的插补结果,用于与原始非缺失分布和离散程度比较。
    X_train_imputed = imputer.fit_transform(X_train)
    # 沿用训练期已拟合的转换器生成 `X_test_imputed`,保持训练与评估口径一致。
    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)

    # 用当前折已缩放训练数据估计逻辑回归系数,验证段不参与拟合。
    logreg.fit(X_train_scaled, y_train)

    # 生成当前验证折正类概率,供 ROC 曲线和 AUC 计算。
    y_test_proba = logreg.predict_proba(X_test_scaled)[:, 1]

    # 由当前随机分层验证折的标签和概率计算 ROC 坐标,记录该折的排序表现。
    fpr, tpr, _ = roc_curve(y_test, y_test_proba)
    # 计算本折测试概率对应的 ROC AUC 标量,供跨折汇总排序能力。
    auc = roc_auc_score(y_test, y_test_proba)
    # 把本折 AUC `auc` 加入 `aucs`,用于汇总交叉验证的均值和离散度。
    aucs.append(auc)

    # 以 `fpr` 为横轴、`tpr` 为纵轴绘制曲线,展示“5折交叉验证的ROC曲线”。
    ax.plot(fpr, tpr, linewidth=2.2, linestyle=line_styles[i], label=f'第 {i+1} 折(AUC={auc:.3f})')

# 美化图形
ax.plot([0, 1], [0, 1], color='navy', linestyle='--', label='随机猜测')
# 计算 `mean_auc`(曲线下面积),量化“步骤3: 执行交叉验证循环并绘图”的模型表现。
mean_auc = np.mean(aucs)
# 用图题概括“步骤3: 执行交叉验证循环并绘图”的比较对象与当前计算结果。
ax.set_title(f'k折交叉验证的ROC曲线 (平均AUC = {mean_auc:.3f})', fontsize=16)
# 将横轴标为“假正例率 (FPR)”,明确横向编码的变量。
ax.set_xlabel('假正例率 (FPR)', fontsize=14)
# 将纵轴标为“真正例率 (TPR)”,明确纵向编码的变量。
ax.set_ylabel('真正例率 (TPR)', fontsize=14)
# 显示“步骤3: 执行交叉验证循环并绘图”图例,使颜色或线型与比较对象一一对应。
ax.legend(loc='lower right', fontsize=11)
# 为“步骤3: 执行交叉验证循环并绘图”添加辅助网格,便于比较位置、斜率或组间差异。
plt.grid(True)
# 显示五个随机分层验证折的 ROC 曲线、各折 AUC 与对角随机基线,检查排序表现的折间差异。
plt.show()
横轴为假阳性率、纵轴为真阳性率;五条折线对应交叉验证各折,均值曲线与对角随机基线用于比较。
图 4: 5折交叉验证的ROC曲线

结果解读

  • 五条曲线:来自 5 个互斥验证折;各训练集大量重叠,因此模型与折级 AUC 并非统计独立。
  • 可解释范围:曲线差异只描述本次分折敏感性。
  • 不确定性扩展:需另行设计重复或嵌套验证,并保留时间、公司或组内依赖结构。
  • 折间差异:五折 AUC 约介于 0.706 与 0.791,说明不同子样本会带来不可忽略的性能波动。
  • 平均 AUC ≈ 0.744:高于 0.5 随机排序基准。
  • 结论边界:只描述本次生成样本与随机分折;没有重复验证或真实金融最终测试期时,不能声称具有稳健的市场性能。

形成性检查:测试集何时已经被污染?

独立作答(3 分钟):你比较 12 组超参数在“测试集”上的 RMSE,选择最低者后又报告同一 RMSE。这个数字还能作为无偏的最终评估吗?

  • A. 能,因为模型没有在测试特征上调用 .fit()
  • B. 不能,因为反复查看测试指标已经把选择信息反馈给流程。
  • C. 能,只要设置随机种子。

反馈:反复查看指标也会污染测试集

  • 正确选项:B,得 2 分;反复查看测试指标已经把选择信息反馈给流程。
  • 修复方案:重新封存从未参与选择的后段样本,只在全部步骤确定后打开一次,得 1 分。
  • 错误 A:混淆参数拟合与模型选择泄漏。
  • 错误 C:随机种子只保证可复现,不能恢复测试独立性。
  • 学习路径:答 A/C 者回看 相关内容;答对并写明选择完成时点者进入 相关内容

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

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

  • 核心权衡: 我们总是在偏差和方差之间寻找平衡点,目标是最小化总泛化误差。
  • 条件性选择: 近似 iid 横截面可用随机 k 折;公司面板按公司分组,时间预测用滚动/扩展窗,调参后再做一次最终测试。
  • 致命陷阱: 数据泄露是评估过程中必须警惕的错误,正确的预处理流程至关重要。
  • 量化指标: 必须根据问题类型(回归 vs. 分类)和数据特性(是否平衡)选择最合适的评估指标。

核心结论:模型验证贯穿建模全过程。训练集再“精准”,也不能替代可靠的样本外证据。

思考题:理论检验

场景判断:

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

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

思考题:代码实践

  • 数据公开的沪深 300 指数数据,只使用 datetimeclose
  • 样本期:2018—2024 年;年度窗口均按目标实现日分配。
  • 特征:特征日之前 1–5 个交易日收益率。
  • 目标时点target_date 标记下一交易日目标的实现日。
  • 复现设置:随机种子为 42。
  1. 任务一(回归):2018—2023 年做 5 折 TimeSeriesSplit;每折只由训练段拟合 StandardScalerLinearRegression;报告模型和训练均值基线的逐折 RMSE、均值与标准差。

  2. 任务二(分类):2018—2022 年训练、2023 年验证、2024 年最终测试;固定 0.5 阈值和 LogisticRegression(random_state=42);报告混淆矩阵、precision、recall、accuracy 与多数类基线。

练习:先完成再查看答案:先交两项独立结果

提交折内标准化证据、逐窗 RMSE 与训练均值基线,以及最终测试分类的混淆矩阵、多数类基线和误报/漏报成本。测试集只允许打开一次;提交后再进入反馈。

思考题参考答案与自查标准

  1. 理论题:训练误差远小于验证误差是过拟合,应优先降低方差。可减少特征、增强正则化或改用更简单的函数类。增加同机制且无泄漏的数据通常有帮助,但不是保证。
  2. 回归代码题:每一折必须在该折训练部分拟合预处理器,报告各折 RMSE、均值与标准差;若先在全样本标准化,视为数据泄漏。
  3. 分类代码题:阈值固定为 0.5,最终测试集只评估一次。答案需给出混淆矩阵、precision、recall、accuracy、多数类基线,并解释误报与漏报成本。

最小通过证据:可运行代码、无交叉折污染、逐折指标、一个简单基线,以及一句结果不理想时如何解释说明。

代码题固定输入与特征答案

代码
from pathlib import Path  # 指向固定沪深300指数数据版本
from urllib.request import urlretrieve  # 复用本章隐藏设置单元安装的浏览器标识下载器
import numpy as np  # 计算收益率与基线预测
import pandas as pd  # 读取并整理时间序列特征
exercise_seed = 42  # 固定所有可能使用随机初始化的步骤
np.random.seed(exercise_seed)  # 统一 NumPy 随机状态以便复算
# 按 Linux 共享数据、Windows 共享数据、项目缓存的顺序选择指数行情文件。
exercise_index_path = next((candidate_path for candidate_path in [Path('/home/ubuntu/r2_data_mount/data/index/hs300_index_only.h5'), Path('C:/qiufei/data/index/hs300_index_only.h5'), Path('data/course/hs300_index_only.h5')] if candidate_path.exists()), Path('data/course/hs300_index_only.h5'))
if not exercise_index_path.exists():
    exercise_index_path.parent.mkdir(parents=True, exist_ok=True)
    urlretrieve('https://assets.qiufei.site/data/index/hs300_index_only.h5', exercise_index_path)
exercise_index = pd.read_hdf(exercise_index_path, key='hs300')[['datetime', 'close']].copy()  # 只读取日期与收盘价字段
exercise_index['date'] = pd.to_datetime(exercise_index['datetime'], format='%Y%m%d%H%M%S')  # 解析交易时间戳
exercise_index = exercise_index.sort_values('date').query("'2018-01-01' <= date <= '2024-12-31'").copy()  # 确定样本期并保持时间顺序
exercise_index['daily_return'] = exercise_index['close'].pct_change()  # 由连续交易日收盘价计算日收益率
for lag_day in range(1, 6):  # 只使用预测时点之前五个交易日的信息
    exercise_index[f'return_lag_{lag_day}'] = exercise_index['daily_return'].shift(lag_day)  # 构造具名滞后收益特征
exercise_index['target_date'] = exercise_index['date'].shift(-1)  # 保留下一交易日标签的实际实现日期
exercise_index['next_return'] = exercise_index['daily_return'].shift(-1)  # 将目标实现日的收益设为回归目标
exercise_index['next_up'] = (exercise_index['next_return'] > 0).astype(int)  # 将下一交易日正收益设为分类标签
exercise_feature_names = [f'return_lag_{lag_day}' for lag_day in range(1, 6)]  # 固定五项特征及其顺序
exercise_panel = exercise_index.dropna(subset=exercise_feature_names + ['target_date', 'next_return']).copy()  # 删除滞后和前移产生的边界缺失

任务一完整答案:扩展窗口回归

代码
from sklearn.linear_model import LinearRegression  # 建立可解释的线性回归基准
from sklearn.metrics import mean_squared_error  # 计算逐折均方根误差
from sklearn.model_selection import TimeSeriesSplit  # 保证每折训练早于验证
from sklearn.pipeline import make_pipeline  # 将缩放与回归绑定在折内
from sklearn.preprocessing import StandardScaler  # 仅用每折训练样本逐特征估计均值与标准差
regression_panel = exercise_panel.query("target_date <= '2023-12-31'").copy()  # 按目标实现日最终测试 2024 年标签
regression_splitter = TimeSeriesSplit(n_splits=5, gap=1)  # 在训练与验证之间清除一行相邻标签重叠
regression_records = []  # 收集模型和训练均值基线的逐折结果
for fold_number, (training_rows, validation_rows) in enumerate(regression_splitter.split(regression_panel), start=1):  # 按时间顺序遍历五折
    assert regression_panel.iloc[training_rows]['target_date'].max() < regression_panel.iloc[validation_rows]['date'].min()  # 保证训练标签在首个验证特征日前已实现
    regression_model = make_pipeline(StandardScaler(), LinearRegression())  # 每折只由训练段逐特征估计缩放统计量并拟合模型
    regression_model.fit(regression_panel.iloc[training_rows][exercise_feature_names], regression_panel.iloc[training_rows]['next_return'])  # 只用该折较早样本估计参数
    regression_predictions = regression_model.predict(regression_panel.iloc[validation_rows][exercise_feature_names])  # 预测紧随训练段之后的验证窗口
    regression_baseline = np.repeat(regression_panel.iloc[training_rows]['next_return'].mean(), len(validation_rows))  # 用训练期均值建立朴素预测
    regression_records.append({'fold': fold_number, 'model_rmse': mean_squared_error(regression_panel.iloc[validation_rows]['next_return'], regression_predictions) ** 0.5, 'baseline_rmse': mean_squared_error(regression_panel.iloc[validation_rows]['next_return'], regression_baseline) ** 0.5})  # 保存同一验证窗的可比 RMSE
regression_results = pd.DataFrame(regression_records)  # 整理五折输出
display(regression_results.round(6))  # 展示每折模型与基线误差
print({'模型RMSE均值': regression_results['model_rmse'].mean(), '模型RMSE标准差': regression_results['model_rmse'].std(ddof=1), '基线RMSE均值': regression_results['baseline_rmse'].mean()})  # 汇总跨折稳定性与基线
{'模型RMSE均值': 0.01215009093607761, '模型RMSE标准差': 0.0022166147407994357, '基线RMSE均值': 0.012043248027744143}
表 1: 沪深300下一日收益回归的五个扩展窗口 RMSE
fold model_rmse baseline_rmse
0 1 0.012855 0.012729
1 2 0.014407 0.014460
2 3 0.011962 0.011624
3 4 0.013019 0.012889
4 5 0.008507 0.008514
模型 RMSE 基线 RMSE
1 0.012855 0.012729
2 0.014407 0.014460
3 0.011962 0.011624
4 0.013019 0.012889
5 0.008507 0.008514
  • 样本设置:按目标实现日保留 1,450 条样本,并设置一行 purge。
  • 汇总:模型均值 0.012150、样本标准差 0.002217;基线均值 0.012043。
  • 复现标准:固定数据版本重算时,六位小数应一致。
  • 结论:模型仅在第 2、5 折略胜基线,不能声称稳定增益。

任务二完整答案:最终测试年度分类

代码
from sklearn.linear_model import LogisticRegression  # 建立固定随机种子的方向分类器
from sklearn.metrics import accuracy_score, confusion_matrix, precision_score, recall_score  # 计算分类任务完整指标
classification_training = exercise_panel.query("target_date <= '2022-12-31'").copy()  # 只让不晚于 2022 年末实现的标签进入训练
classification_validation = exercise_panel.query("'2023-01-01' <= target_date <= '2023-12-31'").copy()  # 按目标实现日分配 2023 验证样本
classification_test = exercise_panel.query("'2024-01-01' <= target_date <= '2024-12-31'").copy()  # 按目标实现日最终测试 2024 测试样本
assert len(classification_training) > 0 and len(classification_validation) > 0 and len(classification_test) > 0, '分类训练、验证或最终测试窗口为空'  # 先阻止空窗口真空通过日期断言
assert classification_training['next_up'].nunique() == 2 and classification_test['next_up'].nunique() == 2, '分类训练或最终测试期缺少一个方向类别'  # 保证模型拟合与混淆指标可定义
assert classification_training['target_date'].max() <= pd.Timestamp('2022-12-31')  # 检查训练标签的信息截止日
assert classification_validation['target_date'].between('2023-01-01', '2023-12-31').all()  # 检查验证标签只在 2023 年实现
assert classification_test['target_date'].between('2024-01-01', '2024-12-31').all()  # 检查测试标签只在 2024 年实现
classification_model = make_pipeline(StandardScaler(), LogisticRegression(random_state=exercise_seed, max_iter=1000))  # 只由分类训练窗逐特征估计缩放统计量并固定优化初始状态
classification_model.fit(classification_training[exercise_feature_names], classification_training['next_up'])  # 只用 2022 年末以前标签拟合
validation_predictions = (classification_model.predict_proba(classification_validation[exercise_feature_names])[:, 1] >= 0.5).astype(int)  # 在验证年核对预先固定的 0.5 阈值
test_predictions = (classification_model.predict_proba(classification_test[exercise_feature_names])[:, 1] >= 0.5).astype(int)  # 确定流程后仅评估一次 2024 年
majority_label = int(classification_training['next_up'].mean() >= 0.5)  # 只用训练期确定多数类
majority_predictions = np.repeat(majority_label, len(classification_test))  # 在同一测试样本上形成多数类基线
true_negative, false_positive, false_negative, true_positive = confusion_matrix(classification_test['next_up'], test_predictions, labels=[0, 1]).ravel()  # 展开标准二分类混淆计数
classification_summary = pd.DataFrame([{'validation_accuracy': accuracy_score(classification_validation['next_up'], validation_predictions), 'tn': true_negative, 'fp': false_positive, 'fn': false_negative, 'tp': true_positive, 'precision': precision_score(classification_test['next_up'], test_predictions, zero_division=0), 'recall': recall_score(classification_test['next_up'], test_predictions, zero_division=0), 'test_accuracy': accuracy_score(classification_test['next_up'], test_predictions), 'majority_accuracy': accuracy_score(classification_test['next_up'], majority_predictions)}])  # 汇总验证与最终测试指标
display(classification_summary.round(4))  # 展示可自查的混淆结果、分类指标与基线
表 2: 沪深300下一日方向分类的 2024 年最终测试结果
validation_accuracy tn fp fn tp precision recall test_accuracy majority_accuracy
0 0.5248 54 69 51 68 0.4964 0.5714 0.5041 0.4917
窗口 样本数 关键结果
训练(至 2022) 1,208 拟合模型与训练期多数类基线
2023 验证 242 Accuracy = 0.5248
2024 最终测试 242 TN=54,FP=69,FN=51,TP=68
  • 测试指标:precision=0.4964,recall=0.5714,accuracy=0.5041,多数类基线=0.4917。
  • 日期核对:特征日 2022-12-30 的标签进入 2023 验证;2023-12-29 的标签进入 2024 测试。
  • 复现标准:计数完全一致;比例的显示舍入误差不超过 0.0001。

结果不理想时如何解释

结果不理想时的解释边界

  • 回归未稳定低于训练均值基线时,报告“滞后收益线性模型没有增量价值”,不能只展示最优一折。
  • 分类未胜多数类基线时,也必须保留失败结果。
  • 上涨为正类:FP 是错误发出上涨信号,FN 是漏掉真实上涨。
  • 若两类成本不同,只能在训练/验证期重新声明阈值,不能读取 2024 年标签后调阈值。

公开中国数据练习:按时间顺序验证

代码
from pathlib import Path  # 管理公开沪深300指数路径
from urllib.request import urlretrieve  # 复用本章已安装的浏览器标识下载器
import numpy as np  # 构造训练期均值预测
import pandas as pd  # 读取并按日期排列指数行情
from sklearn.metrics import mean_squared_error  # 评估下一期收益基线
from sklearn.model_selection import TimeSeriesSplit  # 建立不打乱时间的扩展窗口
# 按 Linux 共享数据、Windows 共享数据、项目缓存的顺序选择指数行情文件。
index_path = next((candidate_path for candidate_path in [Path('/home/ubuntu/r2_data_mount/data/index/hs300_index_only.h5'), Path('C:/qiufei/data/index/hs300_index_only.h5'), Path('data/course/hs300_index_only.h5')] if candidate_path.exists()), Path('data/course/hs300_index_only.h5'))
if not index_path.exists():
    index_path.parent.mkdir(parents=True, exist_ok=True)
    urlretrieve('https://assets.qiufei.site/data/index/hs300_index_only.h5', index_path)
index_data = pd.read_hdf(index_path, key='hs300')[['datetime', 'close']].copy()  # 读取日期与收盘价最小字段
index_data['date'] = pd.to_datetime(index_data['datetime'], format='%Y%m%d%H%M%S')  # 解析交易日期
index_data = index_data.sort_values('date').query("date <= '2025-12-31'").copy()  # 确定样本截止日并保持时间顺序
index_returns = index_data['close'].pct_change().dropna().to_numpy()  # 计算不复权指数日收益
splitter = TimeSeriesSplit(n_splits=3)  # 确定三个扩展窗口
fold_records = []  # 收集逐窗样本数量变化与基线误差
for fold_number, (training_index, validation_index) in enumerate(splitter.split(index_returns), start=1):  # 逐窗保证训练早于验证
    baseline_predictions = np.repeat(index_returns[training_index].mean(), len(validation_index))  # 仅用训练期均值预测验证收益
    fold_records.append({'fold': fold_number, 'train_n': len(training_index), 'valid_n': len(validation_index), 'mse': mean_squared_error(index_returns[validation_index], baseline_predictions)})  # 保存无泄漏基线结果
print({'文件': index_path.name, '键': 'hs300', '样本截止': '2025-12-31'})  # 输出数据版本边界
display(pd.DataFrame(fold_records))  # 展示全部扩展窗口而非随机切分
{'文件': 'hs300_index_only.h5', '键': 'hs300', '样本截止': '2025-12-31'}
表 3: 沪深300日收益扩展窗口的样本数量变化与均值基线误差
fold train_n valid_n mse
0 1 1275 1275 0.000223
1 2 2550 1275 0.000209
2 3 3825 1275 0.000128

本章小结

  • 能按横截面、公司面板、时间预测与调参评估选择验证器,并正确加权折损失。
  • RMSE 是误差平方均值的平方根,不是误差标准差。
  • 随机 k 折只适用于近似可交换样本;测试集只在确定模型后打开一次。
  • 下一必修章返回第 3 章线性回归:用本章验证器检查回归、分类与后续候选。