跳至正文
MODULE 05金融机器学习5 讲 · MACHINE LEARNING
LECTURE 18共 31 讲预计阅读 45 分钟

第 18 讲:金融时序交叉验证(CV)的正确切法 ​

本讲核心目标:
深刻理解在计算机视觉(CV)或自然语言处理(NLP)中行之有效的标准 K 折交叉验证(Random K-Fold),在金融时序数据上是一场毁灭性的时序泄漏灾难;掌握独立同分布(I.I.D.)假设崩塌与重叠标签(Overlapping Labels)渗透机制;推导量化金融先驱马科斯·洛佩斯·德普拉多(Marcos López de Prado)提出的样本清洗(Purging)与缓冲区封锁(Embargoing)数学原理;手写工业级组合式清洗交叉验证器(Combinatorial Purged Cross-Validation, CPCV)。


1. 为什么普通 K-Fold 在量化金融中是致命毒药? ​

许多机器学习工程师转入量化后,习惯性调用 sklearn.model_selection.KFold(shuffle=True) 进行超参数调优,随即在 Jupyter Notebook 中跑出测试集准确率 85%、夏普比率高达 4.0 的“惊人模型”。但在实盘上线的第一天,模型便开始持续亏损。

1.1 独立同分布(I.I.D.)假设的彻底崩塌 ​

标准机器学习交叉验证的核心数学前提是:样本之间是独立同分布(Independent and Identically Distributed, I.I.D.)的。而在金融市场中,这一假设被残酷撕碎:

金融样本非独立同分布的两大破坏机制
├── 1. 标签时间重叠导致直接窥视未来 (Information Leakage via Overlapping Labels):
│     ├── 业务现实: 若预测目标是未来 5 日收益率 R_{t -> t+5},t 日的标签覆盖 [t+1, t+5]
│     └── 泄漏真相: 随机打乱将 t 与 t+2 割裂在训练集和测试集,训练集提前看到了测试集未来 3 天的行情!
└── 2. 时序与截面自相关性 (Serial & Cross-Sectional Correlation):
      ├── 业务现实: 宏观情绪、波动率聚集 (GARCH 效应) 使得周一的状态直接延续到周三
      └── 泄漏真相: 把周一和周三作为训练集、周二作为测试集,模型本质是在做“时序插值 (Interpolation)”,
          而不是真实的“时序外推预测 (Forecasting)”!
课程图示
可放大查看细节,点击百分比恢复原始大小。

2. Purging(样本清洗)与 Embargoing(封锁缓冲)数学原理 ​

为了根除标签重叠造成的信息泄漏,量化先驱 Marcos López de Prado 在经典著作《Advances in Financial Machine Learning (2018)》中正式确立了两大核心操作原则:

课程图示
可放大查看细节,点击百分比恢复原始大小。

2.1 样本清洗(Purging):消除标签前向重叠 ​

设样本 i 的特征生成日期为 ti,其预测标签的最终清算结算日期为 ei。在数学上,该样本的信息暴露时间区间为闭区间 [ti,ei]。

对于任意测试集样本集合 Test,定义测试集在时间轴上的全局信息覆盖闭包为:

ΩTest=⋃j∈Test[tj,ej]

Purging 清洗准则:任何训练集样本 i,若其信息区间与测试集信息闭包存在交集,该训练样本必须被物理剔除:

Trainpurged={i∈Train|[ti,ei]∩ΩTest=∅}
  • 在多因子选股中,由于我们预测的是未来 5 日(ei=ti+5)或 20 日(ei=ti+20)的超额收益,测试集开始前必须至少向前清洗剔除掉 h 个交易日的训练样本!

2.2 缓冲区封锁(Embargoing):阻隔自相关后向溢出 ​

当测试集位于中间(如在交叉验证中训练集包含测试集前后的时间段),由于自回归(AR)过程与波动率记忆,测试集末尾的市场状态会持续影响紧随其后的市场。

Embargoing 封锁准则:在测试集最晚标签结算点之后,追加设定长度为 E 个交易日的保护缓冲区(Embargo Window):

ΩEmbargo=[maxj∈Testej,maxj∈Testej+E]

任何落入该缓冲区内的后续训练样本一律作废,彻底阻断自相关记忆泄漏!


3. 组合式清洗交叉验证(CPCV):告别单一路径狭隘 ​

很多研究员使用朴素的 TimeSeriesSplit(前向滚动向后切分),但它存在两大硬伤:

  1. 历史测试路径唯一(Single Backtest Path):最终只生成一条从头到尾的测试轨迹,无法评估策略在不同历史情景下的泛化方差;
  2. 早期数据利用极度不足:前几个折的训练集样本过小,模型能力受限。

3.1 CPCV(Combinatorial Purged Cross-Validation)机制 ​

CPCV 将历史时间序列划分为 N 个时间块(Blocks)。如果设定每个测试集由 k 个时间块组成,那么可以产生:

CNk=(Nk)=N!k!(N−k)!

组不重复的训练/测试组合。每一组都严格执行 Purging 与 Embargoing。

  • 例如,取 N=6,k=2,将产生 (62)=15 种不重复的切分方案;
  • 每次测试覆盖全样本的 26=33.3%,理论上可构造:φ=(Nk)×kN=15×26=5 条完整覆盖历史全周期的回测路径!
  • 核心价值:能够评估策略对不同历史市场情景的敏感性,为计算**回测过拟合概率(PBO)**提供了扎实的数据基础。

4. 动手实操:买方级 Purged Group 时序切分器实现 ​

在横截面量化模型中,同一交易日内全市场 5,000 只股票是一起联合决策的。因此,切分必须以“日期(Date Group)”为原子单位,绝不能将同一天的股票割裂到不同的折中!

在项目根目录运行 PYTHONPATH=tutorial python3 -m examples.lecture_18_financial_cv_purging_embargo。依赖安装、共享模块和数据契约见项目说明。

py
"""按日期分组的前向验证与 CPCV 切分;闭区间相交采用保守清洗。"""
import itertools
import numpy as np
from examples.ml_utils import demo_dates, interval_coordinates


class PurgedGroupTimeSeriesSplit:
    """扩展窗口前向验证:仅使用测试起点之前、标签已结束的训练日。

    embargo_pct 保留为兼容参数。当前折不使用未来训练数据,故不需要后置 embargo。
    未传 label_end_dates 时,H 按输入的完整交易日期序列计数。
    """
    def __init__(self, n_splits=5, forward_horizon=5, embargo_pct=0.02):
        if n_splits < 1 or not 0 <= embargo_pct < 1:
            raise ValueError('折数必须为正,embargo_pct 必须在 [0,1)')
        self.n_splits, self.forward_horizon = n_splits, forward_horizon
        self.embargo_pct = embargo_pct

    def split(self, dates, label_end_dates=None):
        unique, starts, ends = interval_coordinates(dates, label_end_dates, self.forward_horizon)
        if len(unique) < self.n_splits + 1:
            raise ValueError('日期不足以构建训练窗口与测试折')
        blocks = np.array_split(np.arange(len(unique)), self.n_splits + 1)
        for test_days in blocks[1:]:
            train_days = np.flatnonzero((np.arange(len(unique)) < test_days[0])
                                        & (ends < test_days[0]))
            if not len(train_days):
                raise ValueError('清洗后训练折为空;增加历史窗口或减少折数')
            yield np.flatnonzero(np.isin(starts, train_days)), np.flatnonzero(np.isin(starts, test_days))


class CombinatorialPurgedCV:
    """研究用双侧 CPCV 切分器;不拼接回测路径,也不计算 PBO。"""
    def __init__(self, n_blocks=6, n_test_blocks=2, forward_horizon=5, embargo_pct=0.01):
        if not 1 <= n_test_blocks < n_blocks or not 0 <= embargo_pct < 1:
            raise ValueError('需要 1 <= n_test_blocks < n_blocks 且 0 <= embargo_pct < 1')
        self.n_blocks, self.n_test_blocks = n_blocks, n_test_blocks
        self.forward_horizon, self.embargo_pct = forward_horizon, embargo_pct

    def split(self, dates, label_end_dates=None):
        unique, starts, ends = interval_coordinates(dates, label_end_dates, self.forward_horizon)
        if len(unique) < self.n_blocks:
            raise ValueError('日期数不能少于时间块数')
        blocks = np.array_split(np.arange(len(unique)), self.n_blocks)
        embargo = int(np.ceil(len(unique) * self.embargo_pct))
        day_ids = np.arange(len(unique))
        for ids in itertools.combinations(range(self.n_blocks), self.n_test_blocks):
            test_days = np.concatenate([blocks[b] for b in ids])
            allowed = ~np.isin(day_ids, test_days)
            for b in ids:
                block = blocks[b]
                # 标签区间并集:块内观测日连续,逐日标签至少覆盖本日。
                left, right = block[0], int(ends[block].max())
                allowed &= ~((day_ids <= right) & (ends >= left))
                # 额外 embargo 从测试标签最晚结算点之后计数,而非特征日期末尾。
                allowed &= ~((day_ids > right) & (day_ids <= right + embargo))
            if not allowed.any():
                raise ValueError('清洗后训练折为空;调整块数、标签跨度或 embargo')
            yield np.flatnonzero(allowed[starts]), np.flatnonzero(np.isin(starts, test_days)), ids


if __name__ == '__main__':
    calendar = demo_dates(750, '2023-01-02')
    dates = np.repeat(calendar, 40)
    for fold, (train, test) in enumerate(PurgedGroupTimeSeriesSplit(n_splits=3).split(dates), 1):
        unique = np.unique(dates)
        assert np.searchsorted(unique, dates[train].max()) + 5 < np.searchsorted(unique, dates[test].min())
        print(f'前向折 {fold}: 训练 {dates[train].min()}~{dates[train].max()},测试 {dates[test].min()}~{dates[test].max()}')
    count = sum(1 for _ in CombinatorialPurgedCV().split(dates))
    print(f'CPCV 生成 {count} 组有标签清洗的研究切分;不等同于独立样本或实盘前向路径。')

核心运行输出与切分边界解读: ​

前向折 1: 训练 2023-01-02~2023-09-13,测试 2023-09-21~2024-06-10
前向折 2: 训练 2023-01-02~2024-06-03,测试 2024-06-11~2025-02-26
前向折 3: 训练 2023-01-02~2025-02-19,测试 2025-02-27~2025-11-14
CPCV 生成 15 组有标签清洗的研究切分;不等同于独立样本或实盘前向路径。
  1. Purging 隔离区清晰可见:
    • 在折 1 中,训练集截至 2023-09-13,而测试集从 2023-09-21 才正式开始。中间留出了整整 6 个交易日的物理隔离区,确保持有期为 5 天的标签在测试集开始前已完全结算!
  2. CPCV 组合全覆盖:
    • 算法自动生成 15 组经过标签清洗的研究切分,为模型在不同宏观牛熊环境下的压力测试提供了最严密的数据边界。

5. 本讲小结与课后动手作业 ​

核心知识图谱梳理: ​

  1. 金融 I.I.D. 崩塌:普通随机 K-Fold 在金融时序上会因为标签重叠和自相关性导致虚假高分;
  2. Purging 消除未来标签入侵:严格剔除所有预测区间侵入测试集的训练样本;
  3. Embargoing 封锁自相关记忆:在测试集后置结算点增设缓冲区,防止微观状态后向溢出;
  4. CPCV 组合交叉验证:生成多条非重叠历史路径,为策略鲁棒性压力测试提供数学工具。

课后动手实训: ​

实操任务:

  1. 打开 tutorial/examples/lecture_18_financial_cv_purging_embargo.py。
  2. 将标签前瞻持有期 forward_horizon 从 5 天增加为 20 天(模拟月度调仓模型),观察此时折 1 中训练集结束日与测试集开始日之间的 Purging 隔离区宽度发生了怎样的相应扩张?
  3. 思考题:在滚动训练(Walk-Forward Validation)中,如果将模型训练频率从“每月滚动一次”提高为“每周滚动一次”,计算算力消耗与特征更新时效之间存在怎样的权衡?

QUANTML · Quantitative Research Handbook