3. 数据准备与划分:训练集/验证集/测试集划分、时间序列交叉验证、样本外测试策略

在信用评分模型开发中,数据划分的合理性直接影响模型泛化能力与业务落地效果。本章聚焦于如何将原始样本拆分为训练集、验证集、测试集,并针对信贷场景特有的时间序列特性,介绍交叉验证与样本外测试策略。

3.1 基础划分原则:训练集 / 验证集 / 测试集

标准监督学习通常将数据按比例随机拆分为三部分,但在信用评分中需额外关注样本分布稳定性与时间顺序。

数据集 用途 典型比例 关键约束
训练集 模型参数拟合(特征权重、决策树分裂点等) 60% – 70% 必须包含足够多的坏样本(违约/逾期)
验证集 超参数调优、特征筛选、模型选择 15% – 20% 与训练集保持相同时间窗口,避免未来信息泄露
测试集 最终模型性能评估(无偏估计) 15% – 20% 仅使用一次,不参与任何调优或特征工程
实务提示: 信贷数据中坏样本率通常低于 5%。若随机划分导致验证集或测试集中坏样本过少,应使用分层抽样(Stratified Split)按目标变量比例分配,确保各子集坏样本占比与全集一致。

3.2 时间序列交叉验证(Time Series Cross-Validation)

信用评分模型依赖历史表现预测未来风险,数据天然具有时间顺序。传统 K-Fold 交叉验证会打乱时间顺序,导致未来信息泄露。因此必须采用时间序列交叉验证。

核心思想: 训练集始终使用较早时间窗口的数据,验证集/测试集使用较晚时间窗口的数据,严格保持时间单向流动。

常用方法包括:

  • Expanding Window(扩展窗口): 初始训练集为最早 T 个月,每次验证后扩展训练集至包含下一个时间块,验证集向后滑动。
  • Sliding Window(滑动窗口): 固定训练集长度(如 24 个月),每次整体向后滑动固定步长,丢弃最早的数据块。
  • Walk-Forward Validation(前向验证): 按时间顺序依次训练、验证,模拟真实上线后的滚动更新节奏。
时间序列交叉验证 — 扩展窗口 vs 滑动窗口 时间轴(月份) 训练集 T1–T6 验证集 T7 Fold 1 训练集 T1–T7 验证集 T8 Fold 2 训练集 T1–T6 验证集 T7 滑动 Fold 1 训练集 T2–T7 验证集 T8 滑动 Fold 2
常见错误: 在时间序列数据中使用随机 K-Fold 或 Shuffle Split,会导致模型“看到”未来样本,造成验证性能虚高。上线后模型实际表现往往远低于开发时的评估指标。

3.3 样本外测试策略(Out-of-Time / Out-of-Sample)

除了时间序列交叉验证,信用评分领域还强调两种样本外测试策略:

  • Out-of-Time (OOT) 测试: 使用与训练集完全不同的时间窗口(如训练集使用 2022 年数据,OOT 使用 2023 年数据),检验模型在时间迁移后的稳定性。
  • Out-of-Sample (OOS) 测试: 使用与训练集相同时间窗口但未参与建模的样本(如随机保留的测试集),检验模型在相同时间周期内的泛化能力。
测试类型 时间窗口 样本来源 主要目的
OOS 测试 与训练集相同 同一时间段的保留样本 评估模型在相同宏观环境下的拟合稳定性
OOT 测试 晚于训练集 未来时间段的完整样本 评估模型在时间推移后的预测能力与变量衰减
Champion 模型对照实验中的典型做法: 将历史数据按时间分为三段:
• 开发期(Development): 用于训练与验证(如 2021.01 – 2022.06)
• OOT 验证期(Out-of-Time Validation): 用于模型选择与阈值确定(如 2022.07 – 2022.12)
• OOT 测试期(Out-of-Time Test): 仅用于最终 Champion 模型与 Challenger 模型的对比评估(如 2023.01 – 2023.06)

3.4 数据划分完整流程

以下流程图展示了从原始数据到最终测试集的完整划分链路:

信用评分数据划分完整流程 原始样本全集 按时间排序 时间窗口分段 开发期(训练+验证) OOT 验证期 OOT 测试期 训练集 验证集

3.5 代码示例:Python 实现时间序列划分

以下代码展示了如何使用 TimeSeriesSplit 与自定义时间窗口函数实现上述策略:

import pandas as pd
import numpy as np
from sklearn.model_selection import TimeSeriesSplit

# 模拟信贷数据:按月聚合
dates = pd.date_range('2021-01-01', periods=36, freq='M')
data = pd.DataFrame({
    'month': dates,
    'feature_1': np.random.randn(36),
    'target': np.random.binomial(1, 0.05, 36)
})

# 方法1:使用 sklearn 的 TimeSeriesSplit(滑动窗口)
tscv = TimeSeriesSplit(n_splits=5, test_size=3)
for fold, (train_idx, val_idx) in enumerate(tscv.split(data)):
    train = data.iloc[train_idx]
    val = data.iloc[val_idx]
    print(f"Fold {fold+1}: 训练 {train.month.min().date()} ~ {train.month.max().date()}, "
          f"验证 {val.month.min().date()} ~ {val.month.max().date()}")

# 方法2:自定义扩展窗口划分
def expanding_window_split(data, train_months=12, val_months=3):
    splits = []
    total = len(data)
    start = 0
    while start + train_months + val_months <= total:
        train_end = start + train_months
        val_end = train_end + val_months
        splits.append((list(range(start, train_end)),
                       list(range(train_end, val_end))))
        start += val_months  # 向后滑动一个验证窗口
    return splits

splits = expanding_window_split(data, train_months=12, val_months=3)
for i, (train_idx, val_idx) in enumerate(splits):
    print(f"扩展窗口 Fold {i+1}: 训练 {data.iloc[train_idx].month.min().date()} ~ "
          f"{data.iloc[train_idx].month.max().date()}, "
          f"验证 {data.iloc[val_idx].month.min().date()} ~ "
          f"{data.iloc[val_idx].month.max().date()}")
代码说明: 实际项目中建议将 OOT 测试期单独保留,不参与任何交叉验证循环。交叉验证仅在开发期内进行,用于模型调优;OOT 测试期仅在最终 Champion 模型确定后使用一次。

3.6 数据划分避坑清单

  • 避免未来信息泄露: 特征工程(如计算滚动统计量)必须在每个时间窗口内独立计算,不能使用未来数据。
  • 避免样本重叠: 同一客户在训练集与验证集中只能出现一次,防止数据泄漏。
  • 避免时间窗口过短: 训练集至少包含 12–24 个月的数据,以保证覆盖完整的经济周期或业务波动。
  • 避免 OOT 测试集参与调优: OOT 测试集只能用于最终评估,任何基于 OOT 结果的调优都会导致性能高估。
严重警告: 若在 Champion 模型对照实验中,Challenger 模型使用了与 Champion 模型不同的数据划分方式(如 Champion 使用 OOT 测试,Challenger 使用随机划分),则对比结果无效。所有对比模型必须使用完全相同的数据划分策略。