土法炼钢兴趣小组的算法知识备份

【量化交易】Walk-forward 与 Purged CV:时间序列正确切分

文章导航

分类入口
quant
标签入口
#walk-forward#cross-validation#purged#embargo#cpcv

目录

把「机器学习里跑得好好的 5 折交叉验证」直接套到金融时间序列上,是研究阶段最常见、也最隐蔽的一类灾难。它不会让代码报错,不会让 sklearn 抛异常,甚至不会让验证集分数变差——恰恰相反,它会让验证集分数高得不正常,让研究员误以为找到了一个夏普 2.0 的策略,让 PM 误以为可以上线,让风控误以为模型经过了「严格回测」。直到上线之后曲线一路向下,团队才意识到那个漂亮的验证集分数从一开始就是泄漏出来的。

时间序列在交叉验证语境下不是「另一种数据」,是结构上完全不同的数据:样本之间存在序列依赖(serial dependence),标签的取值依赖未来若干 bar 的价格路径,特征里嵌入了滚动窗口聚合,整个数据集还经常带着市场结构变化(regime shift)。这四件事任何一件单独发生都足以让 IID(independent and identically distributed,独立同分布)假设破裂;而金融数据上四件事同时发生。这就是为什么 de Prado 在 Advances in Financial Machine Learning 里花了整整一章(第七章)专门讲 CV,并且明确写下:「将经典 CV 应用于金融问题最大的特点,就是它会失败。」

本文不止步于「不要用 K-Fold」这一句话,而是把「正确的时间序列切分」拆到工程能落地的颗粒度:为什么 IID 假设会破裂,Walk-Forward 的三种形态各自对应什么场景,Purged K-Fold 的擦除规则到底怎么定义,embargo 长度该取多少,CPCV 如何把 N 段拆出 C(N,k) 个组合并重组成独立的回测路径,外层选模型内层调超参的嵌套 CV 在金融上要做哪些修改,Bailey 等人提出的 Probability of Backtest Overfitting(PBO,回测过拟合概率)怎么算,工程实现里多模型并行、CV 缓存、报告输出怎么组织。每一节给可以直接抄走的 Python 实现骨架,关键判断附上证据与权衡。

风险提示与适用范围:本文不构成任何投资建议。所有代码、参数取值、报告示例均用于说明算法与工程结构,未经过完整生产审计。CV 切分方案的「正确性」是相对于一组明确假设而言的(标签生成方式、特征构造方式、市场结构稳定性),任何一条假设破裂都可能让本文给出的方案失效。直接套用本文代码做实盘决策,由使用者自负责任。商用部署请结合 mlfinlab、scikit-learn 0.24+ 的 TimeSeriesSplit、scikit-lego、自家 PIT(point-in-time)数据系统等成熟组件,并对每一项 CV 假设做单独验证。


一、为什么时间序列不能 K-Fold

绝大多数读 sklearn 文档长大的工程师,第一次写量化代码都会自然写出下面这一行:

from sklearn.model_selection import KFold
cv = KFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X, y, cv=cv)

这行代码在图像分类、文本分类、表格数据信用评分上完全正确,在金融时间序列上却几乎注定泄漏。要把这件事讲清楚,需要先把「IID 假设」「序列依赖」「标签泄漏」「特征泄漏」「市场结构变化」这五个概念分开来看,并讲清楚它们在金融数据上各自如何破裂。

一·一 IID 假设为什么破裂

经典监督学习的整个理论基础,从 PAC 学习到经验风险最小化(empirical risk minimization, ERM)的泛化界,都建立在「样本独立同分布」的假设上。KFold(shuffle=True) 之所以正确,是因为它默认任意两个样本之间在条件分布上互换没有差异——把 (X_1, y_1) 和 (X_2, y_2) 放进训练集还是验证集没有先后之分。

金融数据上,这个假设以三种方式破裂。

第一种破裂:序列相关性。日频股票收益率的一阶自相关系数虽然小(A 股大盘指数日收益率一阶自相关大约在 −0.05 ~ +0.10 之间,因品种和样本期而异),但在分钟级、Tick 级数据上自相关显著为正(短期动量)或显著为负(微观结构反转,bid-ask bounce)。波动率的自相关更强,平方收益率的自相关常常达到 0.2 以上并且半衰期以月计。这意味着相邻样本的特征几乎必然相似,把它们随机分到训练集和验证集等于把同一份信息既放在训练里、又放在验证里。

第二种破裂:标签依赖未来。如果用「未来 5 日收益率」作标签,那么样本 i 和样本 i+1 的标签区间是 [t_i, t_i+5d] 和 [t_i+1, t_i+6d],重叠 4 天。这两个标签在统计意义上不是两个独立观测,是一个观测的两个偏移版本。把样本 i 放训练、样本 i+1 放验证,几乎等于把同一个标签的不同视角既训了又测了。这一点在 Triple-Barrier 标签(de Prado, 2018, §3.2)下更严重:每个样本的标签持有期长度本身随波动率自适应,相邻样本的依赖区间重叠比例可以高达 70% 以上。

第三种破裂:市场结构变化。市场不是平稳过程。2008 年的相关结构、2015 年股灾的相关结构、2020 年新冠的相关结构、2022 年加息周期的相关结构,从因子有效性到资产相关矩阵到波动率水平都不一样。如果训练集和验证集是「随机抽 80% 与 20%」,模型在所有 regime 上看过样本,验证分数主要反映「平均水平」;但实盘里模型只能在「未来某段单一 regime」上跑,平均分数和单段分数之间可以差出一倍以上。

把上面三件事合起来,K-Fold 在金融数据上的失败模式可以一句话概括:它通过随机打乱让训练集偷看了验证集的过去、未来和同时期,违反了「研究过程必须模拟实盘信息流」的基本要求

一·二 标签泄漏的具体形态

标签泄漏是金融 CV 里最高发的 bug 类型。下面列举几种实战中反复见到的泄漏模式:

形态一:未来收益率作标签,但训练样本与验证样本的标签区间重叠。 这是上面第二种破裂的具体化。修复方法是 purge:把训练集里所有标签区间与验证集时间窗有重叠的样本删除。

形态二:滚动窗口标准化时使用了全样本均值方差。 例如:

X_normalized = (X - X.mean()) / X.std()

这一行用了整个样本(含验证集)的均值方差去 normalize。即使后续 K-Fold 切分正确,验证集的统计量也已经通过 normalization 间接渗透进训练集,模型间接看到了未来。修复方法是把 normalization 当作 estimator 的一部分(用 sklearn Pipeline 包起来),或在 CV 切分之后只用训练集统计量做 transform。

形态三:因子构造时的 lookahead。 经典的「财报因子」要用 announcement date 而不是 fiscal period end date 索引,否则训练集里会出现「2023-Q1 财报数据出现在 2023-04-15 之前」的反因果情况。这一点不属于 CV 切分本身的问题,但它会让所有 CV 方案都失效——因为数据本身已经是「带未来信息」的版本。修复方法在 ETL 层用 PIT(point-in-time)数据库。

形态四:超参调优过的模型在外层 CV 上「过度调优」。即使内层 CV 用了 Purged K-Fold,外层选模型用普通 K-Fold,调参过程中模型见过外层验证集的某些时段,最终给出的 OOS(out-of-sample,样本外)分数仍然是乐观偏差的。修复方法是嵌套 CV(nested CV),见第六节。

一·三 TimeSeriesSplit 也不够

sklearn 0.18 之后提供了 TimeSeriesSplit,按时间切分而不打乱。这一步是对的,但远远不够。

from sklearn.model_selection import TimeSeriesSplit
cv = TimeSeriesSplit(n_splits=5)

TimeSeriesSplit 解决了「随机打乱」的问题,把训练集严格放在验证集时间之前。但它没有处理:

  1. 训练集末端样本的标签区间会侵入验证集时间窗——也就是上面提到的「形态一」泄漏,TimeSeriesSplit 完全不感知标签的时间跨度。
  2. 训练集与验证集相邻边界的特征自相关——validation 起点附近的若干 bar,其特征大概率跟 training 末端高度相关,这部分需要 embargo。
  3. 跨段独立性的统计意义——TimeSeriesSplit 给出的 5 折是嵌套递增的(第 k 折训练集是第 k-1 折训练集的超集),不是 5 个独立观测,平均分数的方差估计会被严重低估。

也就是说,TimeSeriesSplit 解决的是「按时间顺序」的最低门槛,距离金融场景下的「正确切分」还差一个 purge、一个 embargo、一个跨段独立性。这正是 Purged K-Fold 与 CPCV 要解决的事情。


二、Walk-Forward 基本形式

Walk-Forward(步进验证)是最贴近实盘的 CV 形态。它的核心思想是:把整段历史按时间顺序拆成若干折,每折的训练集只包含验证集时间之前的数据,验证集严格在训练集之后。这模拟了实盘里「用过去 n 年训练模型 → 部署 m 个月 → 重新训练 → 再部署」的真实流程。

Walk-Forward 在工程上有三种主流形态:滚动窗口(rolling window)、扩展窗口(expanding / anchored window)、混合窗口。三者权衡完全不同。

二·一 滚动窗口

滚动窗口的训练集长度固定,每滚动一步训练集起点和终点都向前推进。例如「训练 24 个月、测试 6 个月、步进 6 个月」:

Split 1: train=[2018-01, 2019-12], test=[2020-01, 2020-06]
Split 2: train=[2018-07, 2020-06], test=[2020-07, 2020-12]
Split 3: train=[2019-01, 2020-12], test=[2021-01, 2021-06]
...

滚动窗口的优点是不让远古数据稀释近期信息。如果你认为 2008 年的市场结构和 2024 年的市场结构差异显著,强制让训练集只看近 24 个月的数据,让模型自适应当前 regime。劣势是训练样本量恒定,无法随时间累积,如果策略需要长样本去估某些慢变量(如长期均值回归速率),滚动窗口会让估计长期不收敛。

二·二 扩展窗口(Anchored)

扩展窗口的训练集起点固定(anchored)在历史最早一天,每滚动一步终点向前推进,训练集越来越长:

Split 1: train=[2018-01, 2019-12], test=[2020-01, 2020-06]
Split 2: train=[2018-01, 2020-06], test=[2020-07, 2020-12]
Split 3: train=[2018-01, 2020-12], test=[2021-01, 2021-06]
...

扩展窗口的优点是样本量随时间累积,估计精度逐折提升。劣势是它默认市场结构平稳——把 2008 年的样本和 2024 年的样本平等对待,对 regime shift 不敏感。如果你的因子在 2015 年股灾后已经显著退化,扩展窗口可能会持续在「失效因子」上更新模型。

二·三 Anchored + 加权遗忘

工程上常见的折中是 anchored 训练集 + 时间加权遗忘:训练集从 T0 一路扩展,但样本权重随时间指数衰减(half-life 设为某个工程参数,比如 6 个月)。这等价于「软滚动窗口」:远古样本仍贡献信息但权重很低,新样本主导优化方向。LightGBM、XGBoost 都支持 sample_weight,实现成本几乎为零。

import numpy as np

def exponential_decay_weights(timestamps, half_life_days=180):
    t_max = timestamps.max()
    delta_days = (t_max - timestamps).dt.days.values
    return np.power(0.5, delta_days / half_life_days)

二·四 Walk-Forward 在 sklearn 接口下的实现

下面是一个不依赖第三方库、纯 sklearn 接口的 Walk-Forward 切分器。它接受 pd.DatetimeIndex,按月或按天切分,支持滚动 / 扩展两种模式以及 embargo。

from __future__ import annotations
import numpy as np
import pandas as pd
from dataclasses import dataclass
from typing import Iterator, Literal, Optional, Tuple

@dataclass
class WalkForwardSplit:
    """Walk-Forward 切分器,兼容 sklearn cv 接口。

    参数
    ----
    train_period : pd.DateOffset
        训练集长度(rolling 模式下固定,anchored 模式下作为初始长度)。
    test_period : pd.DateOffset
        每折测试集长度。
    step : pd.DateOffset
        相邻两折的起点差。默认等于 test_period(无重叠)。
    mode : "rolling" | "anchored"
        滚动还是扩展。
    embargo : pd.DateOffset | None
        测试集开始前从训练集末端切除的间隔。默认为 None。
    """
    train_period: pd.DateOffset
    test_period: pd.DateOffset
    step: Optional[pd.DateOffset] = None
    mode: Literal["rolling", "anchored"] = "rolling"
    embargo: Optional[pd.DateOffset] = None

    def split(self, X, y=None, groups=None) -> Iterator[Tuple[np.ndarray, np.ndarray]]:
        if not isinstance(X.index, pd.DatetimeIndex):
            raise TypeError("WalkForwardSplit 要求 X 的索引是 DatetimeIndex")
        idx = X.index
        t_start = idx.min()
        t_end = idx.max()
        step = self.step if self.step is not None else self.test_period
        embargo = self.embargo if self.embargo is not None else pd.DateOffset(0)

        train_start = t_start
        train_end = train_start + self.train_period
        while True:
            test_start = train_end + embargo
            test_end = test_start + self.test_period
            if test_end > t_end:
                break

            train_mask = (idx >= train_start) & (idx < train_end)
            test_mask = (idx >= test_start) & (idx < test_end)
            train_idx = np.where(train_mask)[0]
            test_idx = np.where(test_mask)[0]
            if len(train_idx) == 0 or len(test_idx) == 0:
                break
            yield train_idx, test_idx

            if self.mode == "rolling":
                train_start = train_start + step
            train_end = train_end + step

    def get_n_splits(self, X=None, y=None, groups=None) -> int:
        return sum(1 for _ in self.split(X))

这套实现跟 sklearn BaseCrossValidator 兼容,可以直接喂给 cross_val_scoreGridSearchCVcross_validate。注意两个细节:

第一step 默认等于 test_period,对应「相邻折测试集首尾相接」的最常见用法。如果想做密集评估,可以让 step 更小(比如 step=1 个月、test_period=3 个月),相邻折测试集会重叠,统计独立性下降但评估点更密。

第二embargo 在这里只切训练集末端,不切测试集起点。这是因为 Walk-Forward 的训练集严格在测试集之前,潜在泄漏只来自「训练集末端样本的特征/标签区间侵入测试期」。CPCV 那里 embargo 需要双向切,下一节会展开。

二·五 Walk-Forward 评估指标

Walk-Forward 跑完会给出 K 段独立的测试集净值(或预测序列)。怎么聚合成单一指标,是评估流程里容易被忽略的一步:


三、Purged K-Fold

Walk-Forward 解决了「测试集严格在训练集之后」的问题,但每折之间复用率低,K 折下来训练数据没怎么变,统计独立性弱。Purged K-Fold 把数据分成 K 个时间段,每折轮流当测试集,其余 K-1 段当训练集——形态上更像经典 K-Fold,但加了 purge 这一步擦除窗口处理标签泄漏。

三·一 Purge 的形式化定义

记每个样本 i 有一个时间区间 [t1_i, t2_i]:

对于一个验证集时间段 [V_start, V_end] 和它的训练集 T,purge 规则是:

从 T 中删除所有满足 [t1_i, t2_i] ∩ [V_start, V_end] ≠ ∅ 的样本。

也就是说,任何标签确定时间或特征观察时间与验证期有重叠的训练样本,都要被擦除。这条规则的根据是:如果 y_i 的取值是基于 t2_i 时刻的价格信息,而 t2_i 落在验证期内,那么 y_i 实际上「偷看了」验证期的价格——把它放进训练集等于把验证期的标签信息泄漏给了模型。

三·二 Purge 的工程实现

下面是一个 PurgedKFold 实现,输入除了 X、y 之外还需要每个样本的时间区间 t1(特征时间)与 t2(标签确定时间)。

from __future__ import annotations
import numpy as np
import pandas as pd
from sklearn.model_selection import KFold
from typing import Iterator, Optional, Tuple

class PurgedKFold:
    """带 purge 与 embargo 的时间序列 K-Fold。

    每折的测试集是按时间顺序连续的一段;其余段为训练集,但训练样本中
    任何 [t1, t2] 与测试期重叠的,都会被 purge 删除。embargo 在测试期
    结束之后再切除一段,避免边界自相关泄漏。

    参数
    ----
    n_splits : int
        折数。
    t1 : pd.Series
        每个样本的特征观察时间(index 与 X 一致,值为 pd.Timestamp)。
    t2 : pd.Series
        每个样本的标签确定时间(index 与 X 一致,值为 pd.Timestamp)。
    embargo_pct : float
        embargo 长度占总样本数的比例,例如 0.01。
    """
    def __init__(self, n_splits: int, t1: pd.Series, t2: pd.Series,
                 embargo_pct: float = 0.0):
        if not (t1.index.equals(t2.index)):
            raise ValueError("t1 与 t2 必须有相同的 index")
        if (t2 < t1).any():
            raise ValueError("t2 必须 >= t1(标签确定不能早于特征观察)")
        self.n_splits = n_splits
        self.t1 = t1.sort_index()
        self.t2 = t2.reindex(self.t1.index)
        self.embargo_pct = embargo_pct

    def split(self, X, y=None, groups=None) -> Iterator[Tuple[np.ndarray, np.ndarray]]:
        n = len(self.t1)
        embargo_n = int(np.ceil(n * self.embargo_pct))

        # 把样本按时间顺序均分成 n_splits 段
        indices = np.arange(n)
        test_ranges = np.array_split(indices, self.n_splits)

        for test_idx in test_ranges:
            test_start_time = self.t1.iloc[test_idx[0]]
            test_end_time = self.t2.iloc[test_idx[-1]]

            # 训练候选 = 全部 - 测试段
            train_candidates = np.setdiff1d(indices, test_idx)

            # purge: 删除 [t1, t2] 与测试期重叠的训练样本
            t1_arr = self.t1.values
            t2_arr = self.t2.values
            overlap = (t1_arr[train_candidates] <= test_end_time) & \
                      (t2_arr[train_candidates] >= test_start_time)
            keep = ~overlap
            train_idx = train_candidates[keep]

            # embargo: 删除测试段右侧 embargo_n 个紧邻样本
            if embargo_n > 0:
                test_right = test_idx[-1]
                embargo_zone = set(range(test_right + 1, test_right + 1 + embargo_n))
                train_idx = np.array([i for i in train_idx if i not in embargo_zone])

            yield train_idx, test_idx

    def get_n_splits(self, X=None, y=None, groups=None) -> int:
        return self.n_splits

几个工程细节值得展开:

关于 t1 与 t2 的来源。如果用 Triple-Barrier 标签,t1 就是开仓时刻,t2 就是上下障 / 时间障三选一触发的时刻,由 labeling 函数直接给出。如果用 fixed-horizon 标签(未来 5 日收益),则 t2 = t1 + 5 个交易日。如果是横截面因子模型(每月再平衡),t1 = 因子计算日,t2 = 下一个再平衡日。任何情况下都需要在 labeling 阶段把 t1、t2 落盘,下游所有 CV、回测、报告都依赖这两列。

关于 embargo 长度。embargo_pct 取多少没有理论最优解。de Prado 在《Advances in Financial Machine Learning》第 7.4.2 节给出经验值 0.01(即 1% 总样本数);mlfinlab 的默认值也是 0.01。在分钟级数据上,1% 是几百个 bar,相当于一两个交易日,足以隔离日内自相关。在日频数据上,1% 是几个交易日;如果研究员认为周频自相关也不可忽视,可以放大到 0.02。判断准则是:embargo 加大后,K 折 OOS 分数的方差是否明显下降?方差不再显著下降的那个 embargo 长度是合适的。

关于 purge 的边界判定。上面用 <= 而不是 <,对应「闭区间」。如果时间戳精确到秒甚至纳秒,开闭闭都行;如果精确到日,必须用闭区间,避免「同一天的标签确定时间」被错误判为不重叠。

三·三 Purge 与 Walk-Forward 的关系

Purge 不是 Purged K-Fold 独有的,Walk-Forward 同样需要 purge——只是因为 Walk-Forward 训练集严格在测试集之前,purge 只有「单边」需要做(训练集末端可能侵入测试期)。Purged K-Fold 因为某些训练折在测试折之后,purge 必须双向做。

工程实现里,WalkForwardSplit 也应该接受 t1、t2,在每折切分时应用 purge:训练集中 t2 大于等于测试期起点的样本要删除。上一节给出的简化实现没做这一步,生产代码里要补上。


四、Embargo 机制

Embargo 与 Purge 容易被混为一谈,实际上处理的是两类完全不同的泄漏。Purge 处理「标签依赖时间」的泄漏:训练样本的 y_i 取值依赖于落在验证期内的价格信息。Embargo 处理「特征自相关」的泄漏:即使 t2_i 落在训练期内,特征 X_i 仍然会与验证期开头的样本特征高度相关。

四·一 为什么 Purge 不够

考虑一个简单情形:训练集到 2023-06-30,测试集从 2023-07-01 开始,所有样本的 t2 都等于 t1(也就是没有 Triple-Barrier,标签当日就确定)。这时 purge 不会删除任何训练样本,因为没有样本的 [t1, t2] 跨界。但是:

模型在训练集见过 2023-06-30 的样本之后,对测试集 2023-07-03 的预测某种程度上是「对自己见过的特征做插值」,而不是真正的样本外预测。

Embargo 解决这件事的办法很直接:在测试期结束之后再切一段时间,这段不进训练集也不进测试集。具体到 Purged K-Fold 是「测试段右侧切除 embargo_n 个样本」;具体到 CPCV 是「每个测试段两侧都切」。

四·二 Embargo 长度怎么定

Embargo 长度的直觉规则是「特征里最长的滚动窗口 + 标签里最长的持有期」。但实战中没必要算那么精细,几条经验可以套:

  1. 如果特征里有 N 日滚动窗口,embargo 至少 N 个交易日。这样测试集第一天的特征和训练集最后一天的特征没有任何滚动窗口重叠。
  2. 如果标签是固定持有期 H,embargo 至少 H 天。否则测试集最后一天的标签 t2 会落到训练集起点附近,紧邻折之间出现「标签反向泄漏」。
  3. 如果两条都满足不了(比如特征里有 252 日窗口,但你只有 5 年数据),就放弃 K-Fold 改用 Walk-Forward,并且把训练窗口拉长。
  4. 数据稀疏的情况下(比如季频财报因子),embargo 直接定为「下一个再平衡周期」,简单粗暴但不会错。

de Prado 的经验值 1% 是个不错的起点。具体到不同数据频率的工程参考:

数据频率 经验 embargo 备注
Tick 级(毫秒) 30 秒 ~ 5 分钟 与 LOB 重新平衡时间相关
1 分钟 K 30 ~ 120 分钟 半个交易日左右
1 小时 K 1 ~ 5 个交易日 至少跨过日间间隙
日频 5 ~ 20 个交易日 1% 的 5 年日频 ≈ 12 天
周频 4 ~ 8 周 一个季度左右
月频 1 ~ 2 个月 至少跨过下一个再平衡

四·三 Embargo 与样本权重

一个常被忽视的细节:embargo 段的样本不应该简单丢弃,可以考虑「降权进训练」。如果数据本身稀缺(比如新兴品种只有 3 年历史),把 1% 的 embargo 直接扔掉等于丢掉 7~8 个交易日的信息。工程上的做法是:embargo 段样本进训练集,但 sample_weight 设为 0 或者 0.1 这样的小值,让它对损失函数的贡献接近零,但保留特征空间的覆盖度。这种做法在实证上跟硬性丢弃差异不大,但代码更整洁,结果更稳定。


五、Combinatorial Purged CV

CPCV(Combinatorial Purged Cross-Validation,组合纯化交叉验证)是 de Prado 在 2018 年提出的进阶 CV 方案,目标是在保证时间序列正确性(purge + embargo)的前提下,给出多条独立的回测净值路径,让回测过拟合检验有统计学意义。

五·一 CPCV 的动机

Purged K-Fold 给出 K 折 OOS 分数,但只能拼出一条完整的样本外净值(每个时点的预测来自唯一一折当测试集时的模型)。一条净值无法做统计推断——你看到的夏普 1.5 是真的稳定还是单条路径运气好,没办法回答。

CPCV 的洞察是:把样本分成 N 段(N 远大于 K),每次取 k 段当测试集,其余 N−k 段做训练集(仍然 purge + embargo)。N、k 同时控制下,可以得到 C(N, k) 个组合;每个段在 C(N−1, k−1) 个组合里当过测试集。把 C(N, k) 个组合的预测重新组合,能拼出 C(N, k) / k 条独立的样本外净值路径

举例:N=6, k=2,则 C(6,2)=15 个组合,每个段在 C(5,1)=5 个组合里当过测试集,最终拼出 15/2 = 7.5 条独立路径(向下取整 7 条)。在 N=10, k=2 时,组合数 45,独立路径 22 条;N=10, k=3 时,组合数 120,独立路径 40 条。这个组合爆炸是 CPCV 的代价,也是它的价值——多条独立路径让回测过拟合概率(PBO)的估计变得有意义。

五·二 CPCV 切分器实现

from __future__ import annotations
import numpy as np
import pandas as pd
from itertools import combinations
from typing import Iterator, List, Tuple

class CombinatorialPurgedCV:
    """Combinatorial Purged Cross-Validation(de Prado, 2018, §12.4)

    把样本按时间顺序分成 N 段,每次取 k 段当测试集,剩余段当训练集。
    每折应用 purge + embargo。

    参数
    ----
    n_groups : int
        总段数 N。
    n_test_groups : int
        每折当测试集的段数 k。
    t1, t2, embargo_pct : 同 PurgedKFold。

    属性
    ----
    paths_ : List[List[int]]
        重组后的独立净值路径。每条路径是一个长度为 n_groups 的列表,
        path[g] = 包含段 g 作为测试段的 split 的 index。
    """
    def __init__(self, n_groups: int, n_test_groups: int,
                 t1: pd.Series, t2: pd.Series, embargo_pct: float = 0.0):
        if n_test_groups >= n_groups:
            raise ValueError("n_test_groups 必须 < n_groups")
        self.n_groups = n_groups
        self.n_test_groups = n_test_groups
        self.t1 = t1.sort_index()
        self.t2 = t2.reindex(self.t1.index)
        self.embargo_pct = embargo_pct
        self._groups = np.array_split(np.arange(len(self.t1)), n_groups)
        self._combos = list(combinations(range(n_groups), n_test_groups))

    def split(self, X=None, y=None, groups=None) -> Iterator[Tuple[np.ndarray, np.ndarray]]:
        n = len(self.t1)
        embargo_n = int(np.ceil(n * self.embargo_pct))
        t1_arr = self.t1.values
        t2_arr = self.t2.values

        for combo in self._combos:
            # 测试段:把 combo 涉及的段拼起来
            test_idx_list = [self._groups[g] for g in combo]
            test_idx = np.concatenate(test_idx_list)

            # 计算每个测试块的 [start_time, end_time]
            test_blocks = []
            for g in combo:
                blk = self._groups[g]
                test_blocks.append((self.t1.iloc[blk[0]], self.t2.iloc[blk[-1]],
                                    blk[0], blk[-1]))

            # 训练候选 = 全部 - 测试段
            train_candidates = np.setdiff1d(np.arange(n), test_idx)

            # purge: 任何 [t1, t2] 与任一测试块时间窗有重叠的,删除
            keep_mask = np.ones(len(train_candidates), dtype=bool)
            for (t_start, t_end, _, _) in test_blocks:
                overlap = (t1_arr[train_candidates] <= t_end) & \
                          (t2_arr[train_candidates] >= t_start)
                keep_mask &= ~overlap
            train_idx = train_candidates[keep_mask]

            # embargo: 每个测试块两侧都切
            if embargo_n > 0:
                forbidden = set()
                for (_, _, blk_start, blk_end) in test_blocks:
                    forbidden.update(range(max(0, blk_start - embargo_n), blk_start))
                    forbidden.update(range(blk_end + 1, min(n, blk_end + 1 + embargo_n)))
                train_idx = np.array([i for i in train_idx if i not in forbidden])

            yield train_idx, test_idx

    def get_n_splits(self, X=None, y=None, groups=None) -> int:
        return len(self._combos)

    def build_paths(self) -> List[List[int]]:
        """重组 split index 成独立路径。

        每条路径是长度为 n_groups 的列表,path[g] 给出「段 g 作为测试段」的 split 编号。
        共生成 C(n_groups, n_test_groups) / n_test_groups 条独立路径。
        """
        n_paths = len(self._combos) // self.n_test_groups
        # 每个段在多少个 combo 里出现过
        appearances = {g: [] for g in range(self.n_groups)}
        for split_idx, combo in enumerate(self._combos):
            for g in combo:
                appearances[g].append(split_idx)

        paths = [[None] * self.n_groups for _ in range(n_paths)]
        used = {g: 0 for g in range(self.n_groups)}
        for path_idx in range(n_paths):
            for g in range(self.n_groups):
                if used[g] >= len(appearances[g]):
                    raise RuntimeError("路径重组失败,C(N,k) 不能被 k 整除")
                paths[path_idx][g] = appearances[g][used[g]]
                used[g] += 1
        return paths

五·三 CPCV 的回测路径重组

build_paths 这一步是 CPCV 的核心机巧也是最容易写错的一步。原理是:

这种构造保证了每条路径上没有任何一个段是被同一份训练集预测出来的,K 条路径在统计意义上独立。配合 PBO 检验(第七节),可以给出真正的回测过拟合统计推断。

五·四 CPCV 的代价与适用场景

CPCV 不是免费的午餐。代价主要有三:

计算成本:C(N, k) 个 split,每个都要重新训练模型。N=10, k=2 时是 45 个;N=20, k=4 时是 4845 个。模型训练耗时 30 秒的话,4845 个就是 40 小时。这要求工程上必须有强力的并行(joblib、ray、dask)和 CV 缓存(同一组参数下,不同 split 的训练只依赖训练集 index,可以缓存)。

Purge 损失:每个测试块两侧都要 purge,N 越大、k 越小、训练块越分散,purge 掉的样本越多。极端情形下训练样本几乎全被 purge 掉,模型根本训不起来。

N、k 的选择没标准:N 大则路径多但每段短;k 大则单个测试集长但组合数膨胀。de Prado 的 §12.5 给出几个数值实验,N=10, k=2 是常用起点。

适用场景:CPCV 适合研究阶段的策略筛选,特别是需要做 PBO、做夏普稳定性区间估计、做策略容量曲线时。不适合实盘前的最终回测——实盘前最终回测应该用 Walk-Forward,因为它最贴近实盘信息流。


六、Nested CV

嵌套 CV(nested cross-validation)解决一个比 CV 切分更高一层的问题:同时做超参调优和模型评估,不让超参调优的过程污染评估分数

六·一 为什么需要嵌套

考虑一个常见错误流程:

# 错误流程
cv = PurgedKFold(n_splits=5, t1=t1, t2=t2, embargo_pct=0.01)
gs = GridSearchCV(model, param_grid, cv=cv, scoring='sharpe')
gs.fit(X, y)
print("best score:", gs.best_score_)  # ← 用这个数当 OOS 评估

gs.best_score_ 是「在 5 折 CV 上每组超参的均值,再取最大值」。这是最优超参的训练-验证分数,不是泛化分数。研究员用它做策略筛选,相当于在「过拟合到 5 折 CV」的方向上选模型,最终上线后衰减是必然的。

正确做法是嵌套 CV:

外层有几折就给出几个独立的 OOS 分数,平均得到的就是无偏(更准确说是「相对无偏」)的泛化分数估计。

六·二 金融场景下的嵌套 CV

金融数据上嵌套 CV 比通用 ML 多两条限制:

第一,外层和内层的 split 方案都必须是时间序列正确的。外层用 Walk-Forward 或 Purged K-Fold;内层在 train_outer 区间内用 Purged K-Fold 或 Walk-Forward。

第二,外层切完后,内层的「最近时间段」也要做 embargo——避免内层验证集的样本侵入外层测试集的开头。

六·三 嵌套 CV 的实现骨架

from sklearn.base import clone
from sklearn.model_selection import GridSearchCV
import numpy as np
import pandas as pd

def nested_purged_cv(estimator, param_grid, X, y, t1, t2,
                     outer_n_splits=5, inner_n_splits=3,
                     embargo_pct=0.01, scoring='neg_mean_squared_error'):
    """金融时间序列嵌套 CV。

    外层 PurgedKFold 控制独立 OOS 评估;内层 PurgedKFold 在每个外层训练折
    内做超参选择。返回每个外层折的最佳超参与 OOS 分数。
    """
    outer_cv = PurgedKFold(n_splits=outer_n_splits, t1=t1, t2=t2,
                           embargo_pct=embargo_pct)
    results = []
    for fold_id, (tr_outer, te_outer) in enumerate(outer_cv.split(X)):
        X_tr, y_tr = X.iloc[tr_outer], y.iloc[tr_outer]
        X_te, y_te = X.iloc[te_outer], y.iloc[te_outer]

        inner_cv = PurgedKFold(n_splits=inner_n_splits,
                               t1=t1.iloc[tr_outer], t2=t2.iloc[tr_outer],
                               embargo_pct=embargo_pct)
        gs = GridSearchCV(clone(estimator), param_grid, cv=inner_cv,
                          scoring=scoring, n_jobs=-1, refit=True)
        gs.fit(X_tr, y_tr)

        oos_score = gs.score(X_te, y_te)
        results.append({
            'fold': fold_id,
            'best_params': gs.best_params_,
            'inner_cv_score': gs.best_score_,
            'oos_score': oos_score,
            'n_train': len(tr_outer),
            'n_test': len(te_outer),
        })
    return pd.DataFrame(results)

跑完之后看 results['oos_score'] 的均值与方差,以及 results['best_params'] 的稳定性——如果跨折最佳超参跳来跳去(比如这折选 max_depth=5,下折选 max_depth=15),说明搜索空间过大或者每折最优参数不稳定,应该考虑收紧搜索空间或者换成贝叶斯优化、Optuna 这种带先验的搜参方式。

六·四 超参稳定性比 OOS 分数更值得看

研究阶段最值得花时间看的不是「平均 OOS 分数有多高」,而是「最佳超参在不同折之间有多稳」。原因:

如果「最佳学习率」从 0.001 跳到 0.1,「最佳树深」从 3 跳到 15,再漂亮的平均 OOS 分数也不应该上线——这种模型在实盘里没有可解释的参数选择依据,下次重训练的时候选哪个参数完全靠运气。


七、Backtest Overfitting Test

Bailey、Borwein、López de Prado、Zhu(2014, 2017)提出了 PBO(Probability of Backtest Overfitting,回测过拟合概率)作为评估单一策略「样本内最优是否能延续到样本外」的统计指标。其思路非常简洁:把所有候选策略在样本内排名,挑出第一名;看这个第一名在样本外排名第几;重复多次,统计「样本内第一名跌到样本外排名后半段」的概率。

七·一 PBO 的形式化

设有 N 个候选策略(不同模型、不同超参),每个策略在历史上跑出一条净值曲线。把全样本切成偶数个段(比如 16 段),从中选 8 段当样本内、剩 8 段当样本外,组合数 C(16,8) = 12870 个。对每个组合:

  1. 在样本内 8 段上,按某个绩效指标(夏普、卡玛、索提诺)给 N 个策略排名;找出样本内第一名 n*;
  2. 在样本外 8 段上,看 n* 的排名 r*;
  3. 计算相对排名 ω = r* / N(0 表示样本外仍然第一,1 表示样本外垫底);
  4. 取 logit: λ = log(ω / (1 − ω))。

PBO = P(λ < 0) = 「样本内第一名在样本外跌到中位数以下」的概率。PBO 越高,说明候选策略池过拟合越严重;PBO 接近 0.5 时基本是「随机表现」,超过 0.5 意味着「样本内挑赢家这件事在样本外是反指标」。

七·二 PBO 的实现

PBO 实现的关键是输入:N 列净值序列(或绩效序列),以及一个「绩效指标计算函数」。下面给出基于段 split 的 PBO 实现:

import numpy as np
import pandas as pd
from itertools import combinations
from scipy.special import logit

def probability_of_backtest_overfitting(perf_matrix: pd.DataFrame,
                                        n_segments: int = 16,
                                        metric_fn=None) -> dict:
    """计算 PBO(Bailey et al., 2014, 2017)。

    perf_matrix : 行=时间,列=候选策略,值=绩效(如收益率序列)
    n_segments : 偶数。把时间维度切成这么多段。
    metric_fn : (segment_returns) -> scalar score,默认夏普。
    """
    if n_segments % 2 != 0:
        raise ValueError("n_segments 必须是偶数")
    if metric_fn is None:
        metric_fn = lambda r: r.mean() / (r.std() + 1e-12) * np.sqrt(252)

    T, N = perf_matrix.shape
    seg_size = T // n_segments
    segments = [perf_matrix.iloc[i*seg_size:(i+1)*seg_size]
                for i in range(n_segments)]

    # 每个段、每个策略的得分矩阵(n_segments x N)
    scores = np.array([
        [metric_fn(seg.iloc[:, j]) for j in range(N)]
        for seg in segments
    ])

    half = n_segments // 2
    combos = list(combinations(range(n_segments), half))
    lambdas = []
    for in_segs in combos:
        out_segs = tuple(s for s in range(n_segments) if s not in in_segs)
        in_score = scores[list(in_segs)].mean(axis=0)
        out_score = scores[list(out_segs)].mean(axis=0)
        n_star = int(np.argmax(in_score))
        # 样本外排名(1 = 最高)
        out_rank = (out_score > out_score[n_star]).sum() + 1
        omega = out_rank / (N + 1)
        omega = np.clip(omega, 1e-6, 1 - 1e-6)
        lambdas.append(np.log(omega / (1 - omega)))
    lambdas = np.array(lambdas)
    pbo = float((lambdas < 0).mean())
    return {
        'pbo': pbo,
        'lambdas': lambdas,
        'mean_lambda': float(lambdas.mean()),
        'median_lambda': float(np.median(lambdas)),
    }

把 CPCV 的多条独立路径与 PBO 结合起来,可以得到一份相对完整的回测过拟合诊断:

三件事合起来才能回答「这个策略在样本外是否真有 alpha」。任何一项缺失都让结论存疑。

七·三 把 PBO 当成熔断阈值

工程上把 PBO 当成上线流程的硬性熔断:研究员提交一个策略池,先跑 CPCV,再算 PBO,PBO > 0.5 直接拒绝复盘;PBO 在 0.3 ~ 0.5 之间打回让研究员收敛搜索空间;PBO < 0.3 才进入下一阶段(Walk-Forward 实盘前回测)。这条规则把「策略筛选过度自由」变成「过度自由的策略池在 PBO 这关被拦」,对研究 culture 的塑造比任何文档约定都有效。


八、工程实现

把上面所有切分器、嵌套 CV、PBO 拼成一个生产可用的研究流水线,要解决三件工程事情:多模型并行、CV 缓存、CV 报告。

八·一 多模型并行

研究阶段最常见的并行模式是「不同模型 × 不同超参 × 不同 CV split」三维笛卡尔积。joblib 的 Parallel + delayed 是 sklearn 生态最自然的工具:

from joblib import Parallel, delayed
import numpy as np
import pandas as pd
from sklearn.base import clone

def parallel_cpcv(estimator, X, y, t1, t2, n_groups=10, n_test_groups=2,
                  embargo_pct=0.01, n_jobs=-1, scoring=None):
    """并行跑 CPCV 的所有 split。返回 (split_idx -> {train_idx, test_idx, model, score})。"""
    cv = CombinatorialPurgedCV(n_groups, n_test_groups, t1, t2, embargo_pct)
    splits = list(cv.split(X))

    def _fit_one(split_idx, tr_idx, te_idx):
        m = clone(estimator)
        m.fit(X.iloc[tr_idx], y.iloc[tr_idx])
        pred = m.predict(X.iloc[te_idx])
        s = scoring(y.iloc[te_idx], pred) if scoring else None
        return {
            'split_idx': split_idx,
            'train_idx': tr_idx,
            'test_idx': te_idx,
            'pred': pred,
            'score': s,
        }

    results = Parallel(n_jobs=n_jobs)(
        delayed(_fit_one)(i, tr, te) for i, (tr, te) in enumerate(splits)
    )
    return results, cv

并行度上的两条经验:

第一n_jobs=-1 默认每个核一个进程,但 sklearn 的某些模型(XGBoost、LightGBM)本身已经多线程,再叠加 joblib 的进程级并行容易导致超线程争抢。常见配置是「单模型单线程,joblib 跑 N 个进程」(在 LightGBM 里设 num_threads=1)。

第二,pickle 大型 X、y 给子进程的开销不可忽视。如果 X 是几 GB 的特征矩阵,joblib 默认 backend loky 会序列化整个 X 给每个子进程。可以用 parallel_backend('threading') 改为线程并行(前提是模型能释放 GIL,比如 LightGBM 可以),或者用 joblib.Memory 把 X 落盘共享。

八·二 CV 缓存

CPCV 的成本主要花在「同一组超参跑 C(N, k) 次模型训练」上。但实际上,很多 split 之间的训练集差异不大——只是去掉了少数几个段。如果模型支持增量训练(partial_fit),可以缓存基础模型再用差异样本微调。

更现实的缓存策略是:

  1. CV 切分缓存(t1, t2, n_groups, n_test_groups, embargo_pct) 哈希作为 key,缓存切出来的 (train_idx, test_idx) 列表。这一步成本低但被反复调用,缓存收益高。
  2. 特征预处理缓存:StandardScaler、PCA 这些 transformer 在不同 split 上 fit 出来的结果不一样(因为 fit 的是不同子集),但在同一 split 上跨模型可以复用。建议把预处理与训练分开,预处理结果按 split 缓存。
  3. 模型本身缓存(estimator_class, params, train_idx_hash) 作为 key,模型对象作为 value。同一份训练集、同一组超参在不同实验之间反复用,缓存命中率经常超过 50%。joblib.Memory 能直接做这件事。
from joblib import Memory
memory = Memory('./cv_cache', verbose=0)

@memory.cache
def fit_and_predict(estimator_serialized, X_train, y_train, X_test):
    import pickle
    est = pickle.loads(estimator_serialized)
    est.fit(X_train, y_train)
    return est.predict(X_test)

注意 Memory 用对象哈希做 key,要求对象是 picklable 且哈希稳定。某些 sklearn estimator(特别是嵌套 Pipeline)的哈希在不同 sklearn 版本之间不稳定,缓存命中会失效,需要锁版本。

八·三 CV 报告

研究流水线跑完最后一步是出报告。一份合格的 CV 报告至少包含:

第一CV 切分元数据:n_splits、n_groups、n_test_groups、embargo_pct、t1/t2 取值方式、purge 删除了多少样本、平均训练集 / 测试集大小。

第二每折分数表:训练集分数、内层 CV 分数(如有嵌套)、测试集分数。逐折给,不要只给均值。

第三最佳超参跨折表:每折选出的最佳超参、跨折稳定性(每个超参的众数与变异系数)。

第四OOS 净值曲线 / 路径:CPCV 出多条路径的话,画 fan chart(中位数 + 5%/95% 分位数带)。

第五PBO 与 DSR:策略池 PBO,单策略 DSR,候选策略数 N(多重比较修正必须的输入)。

第六风险指标表:最大回撤、回撤持续期、Calmar、Sortino、最差年 / 最差月、波动率聚集程度(GARCH 残差自相关 Q 统计量)。

下面给出一个简化的报告生成器:

import numpy as np
import pandas as pd
from typing import List, Dict

def generate_cv_report(cv_results: List[Dict], cv_meta: Dict,
                       pbo_result: Dict = None) -> pd.DataFrame:
    """把 CV 跑出的 per-fold 结果汇总成报告。"""
    df = pd.DataFrame(cv_results)
    summary = pd.DataFrame({
        'metric': [
            'n_splits', 'mean_train_size', 'mean_test_size',
            'mean_oos_score', 'std_oos_score',
            'min_oos_score', 'max_oos_score',
            'positive_folds_pct',
            'best_params_stability',
            'pbo',
        ],
        'value': [
            len(df),
            df['n_train'].mean() if 'n_train' in df else np.nan,
            df['n_test'].mean() if 'n_test' in df else np.nan,
            df['oos_score'].mean(),
            df['oos_score'].std(),
            df['oos_score'].min(),
            df['oos_score'].max(),
            (df['oos_score'] > 0).mean() if df['oos_score'].dtype.kind == 'f' else np.nan,
            _params_stability(df.get('best_params')),
            pbo_result['pbo'] if pbo_result else np.nan,
        ]
    })
    return summary

def _params_stability(params_series):
    if params_series is None:
        return np.nan
    rows = list(params_series)
    if not rows:
        return np.nan
    keys = rows[0].keys()
    stab = {}
    for k in keys:
        vals = [r[k] for r in rows]
        try:
            stab[k] = float(np.std(vals) / (np.abs(np.mean(vals)) + 1e-12))
        except Exception:
            stab[k] = float(len(set(map(str, vals))) / len(vals))
    return stab

八·四 CV 流水线在 MLOps 里的位置

最后一件工程事情:CV 不是研究阶段的一次性脚本,是上线流程的一部分,必须和 MLOps 流水线打通。

理想的工程位置是:

  1. 代码层:CV 切分器、嵌套 CV、PBO 抽成内部库(比如叫 quantcv),所有研究项目都引用同一个版本。
  2. 研究层:每个策略研究 PR 必须在 CI 上跑过预设的 CV 报告,PBO、DSR 不达标 PR 自动拒绝。
  3. 回测层:实盘前的最终回测用 Walk-Forward,配置(train_period、test_period、embargo_pct、frequency)从策略 metadata 里读取,不允许研究员自己改。
  4. 监控层:上线后定期跑 rolling Walk-Forward 重新评估策略,跟当年研究阶段的 CV 报告对比,OOS 性能漂移超过阈值自动告警 PM。

把 CV 当成一次性的 sklearn 调用,是工程文化没建立起来的表现;把 CV 当成贯穿研究 → 回测 → 实盘 → 监控的统一框架,是成熟量化团队的标志。


九、可视化

下面两张图是上面所有概念的图示总结。

Walk-Forward 与 CPCV 切分对比

第一张图(walkforward-vs-cpcv.svg)对比 Walk-Forward 与 CPCV 两种切分形态:上半部分是 5 折 Walk-Forward,每折训练集严格在测试集之前,相邻折之间嵌入 embargo;下半部分是 N=6, k=2 的 CPCV,展示了 6 个组合的切分图(C(6,2)=15 个组合中的 6 个),每段都既当过训练又当过测试,每个测试段两侧都做 purge + embargo。

Purge 与 Embargo 区间机制

第二张图(purge-embargo.svg)展示 Purge 与 Embargo 在 Triple-Barrier 标签下的具体作用:每个样本的 [t1, t2] 区间决定了它的「时间占用」;跨越 train/test 边界的样本必须从训练集 purge 掉;落入 embargo 区的样本既不进训练也不进测试;Purge 处理「标签依赖时间」的泄漏,Embargo 处理「特征自相关」的泄漏,两者必须同时启用。

八·五 常见踩坑清单

下面这些坑在多个团队反复见过,单独列出来不是因为它们难,而是因为它们在 code review 里几乎一定被忽略:

坑一:把 pd.DataFrame.shift(-1) 当成 label。这种写法在 K-Fold 下没问题,在 Walk-Forward 下问题不大,在 Purged K-Fold + CPCV 下会让 t2 永远等于下一个时间戳,purge 几乎不起作用。正确做法是 t2 = labeling 函数实际确定标签的时间戳,而不是「下一个 bar 的时间戳」。

坑二:标准化在 CV 切分之外做

# 错误:先 normalize 再 CV
X = (X - X.mean()) / X.std()
for tr, te in cv.split(X):
    ...

X.mean() 用了全样本,包括所有未来 split 的测试集。修复方法:

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
pipe = Pipeline([('scaler', StandardScaler()), ('model', estimator)])
for tr, te in cv.split(X):
    pipe.fit(X.iloc[tr], y.iloc[tr])
    pred = pipe.predict(X.iloc[te])

坑三:用 RandomForestRegressor(n_estimators=500, random_state=None)random_state=None 让每折模型不可复现,CV 分数本身带噪声,超参选择被噪声主导。任何 stochastic estimator 都必须固定 random_state

坑四:在 GridSearchCV 里用 sklearn 默认 cv=5。这是 KFold 不打乱版本(sklearn 0.22 之后默认不 shuffle),仍然是 KFold 不是时间序列切分。必须显式传入 cv=PurgedKFold(...)

坑五:Pipeline 里的 transformer 没正确处理时间索引SelectKBestPCA 这些在 fit 时按训练集统计量选特征,但如果 X 是 numpy 而非 pandas,索引信息丢失,CV 切分回头对不上。建议 X 全程用 pandas 保留 DatetimeIndex。

坑六:t1, t2 使用错误的时区或频率。如果 X 是 UTC,t1 / t2 是当地时间,purge 时的时间比较结果完全错误。所有时间戳上 CV 之前必须统一到同一时区。

八·六 与 sklearn 0.24+ 的兼容性

sklearn 从 0.24 开始引入了 BaseCrossValidator 的非整数 split 接口,从 1.0 起支持 cross_validate 返回 indices。本文实现的 WalkForwardSplitPurgedKFoldCombinatorialPurgedCV 都遵循 split(X, y, groups) → Iterator[(train_idx, test_idx)]get_n_splits(X, y, groups) → int 这两个最小接口,可以直接喂给:

不需要继承 sklearn 的 BaseCrossValidator,但如果想被 sklearn 的部分内部类型检查识别(极少数 estimator 会做 isinstance(cv, BaseCrossValidator) 校验),可以加一行:

from sklearn.model_selection import BaseCrossValidator
class PurgedKFold(BaseCrossValidator):
    ...

八·七 数据量不足时的退化策略

不是所有数据集都允许 N=10, k=2 的 CPCV。如果数据只有 2 年日频(约 500 个交易日),CPCV 切完每个段 50 个样本,purge 之后训练集严重缩水,模型根本训不起来。这时的退化方案:

数据稀缺是金融研究的常态而非例外。把 CV 当成万能工具,强行在 100 个月度样本上跑 5 折 Purged K-Fold(每折训练集 80 样本、测试集 20 样本、再 purge 掉 5 样本),得到的 OOS 分数方差大到任何「夏普差异」都没有统计意义。这种情况下 CV 报告应该坦白写「样本量不足以支持 CV 推断」,而不是给出一个看起来精确的小数。

八·八 CV 与回测的关系

最后澄清一个被广泛混淆的边界问题:CV 不是回测,回测也不是 CV。

CV 是模型层的事情:给定标签 y、特征 X、模型 estimator,CV 评估「这个模型在样本外的预测准确性」。CV 输出的是预测分数(accuracy、MSE、AUC、IC)。

回测是策略层的事情:给定预测 y_hat、组合构造规则、撮合规则、成本规则,回测评估「这个策略在样本外的资金曲线」。回测输出的是净值序列。

CV 正确不蕴含回测正确。一个 IC 0.05、MSE 漂亮、Purged K-Fold OOS 分数稳定的因子,落到组合层可能因为:

  1. 换手率过高导致交易成本吃掉所有 alpha;
  2. 容量不足导致组合在自身冲击下迅速衰减;
  3. 集中度过高导致单一品种风险事件引发组合爆仓;
  4. 持仓相关性高让看似独立的多策略实际上在同向押注。

这四件事跟 CV 一点关系都没有,必须在回测层验证。所以正确流程是:

  1. CV 阶段(本文范围):选模型、选超参、估计预测精度;
  2. 回测阶段(下一篇):选组合构造规则、估计策略夏普 / 回撤 / 容量;
  3. 实盘前阶段:用 Walk-Forward 把 CV 与回测合到一起跑一遍最终验证。

跳过任何一步,或者用 CV 分数代替回测分数做上线决策,都是工程文化没建好的表现。


九·一 实战示例

下面给一个端到端示例,把 PurgedKFold + 嵌套 CV + PBO 串起来。数据用一段合成的「带均值回归的 AR(1) + 噪声」时间序列,仅用作流程演示,不涉及真实市场数据。

import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingRegressor

np.random.seed(7)
n = 2000
dates = pd.date_range('2018-01-01', periods=n, freq='B')
phi = 0.3
eps = np.random.randn(n) * 0.01
r = np.zeros(n)
for i in range(1, n):
    r[i] = phi * r[i-1] + eps[i]

df = pd.DataFrame({'r': r}, index=dates)
for k in [1, 2, 3, 5, 10, 20]:
    df[f'lag{k}'] = df['r'].shift(k)
df['vol20'] = df['r'].rolling(20).std()
df['target'] = df['r'].shift(-5).rolling(5).sum()  # 未来 5 日累积
df = df.dropna()

X = df[[c for c in df.columns if c.startswith('lag') or c == 'vol20']]
y = df['target']
t1 = pd.Series(df.index, index=df.index)
t2 = pd.Series(df.index + pd.Timedelta(days=5), index=df.index)

cv = PurgedKFold(n_splits=5, t1=t1, t2=t2, embargo_pct=0.01)
model = GradientBoostingRegressor(n_estimators=200, max_depth=3, random_state=0)

scores, n_train, n_test = [], [], []
for tr, te in cv.split(X):
    model.fit(X.iloc[tr], y.iloc[tr])
    pred = model.predict(X.iloc[te])
    scores.append(np.corrcoef(pred, y.iloc[te])[0, 1])
    n_train.append(len(tr))
    n_test.append(len(te))

print(pd.DataFrame({
    'fold': range(1, 6),
    'IC': scores,
    'n_train': n_train,
    'n_test': n_test,
}))

跑完会得到类似下面的输出(具体数值随随机种子变化):

   fold        IC  n_train  n_test
0     1  0.082134      385     395
1     2  0.071028      771     395
2     3  0.094521     1166     395
3     4  0.088417     1561     395
4     5  0.079853     1956     395

注意 n_train 在 5 折之间不一样——因为 purge 删除了不同数量的训练样本。这是 PurgedKFold 与普通 KFold 最直观的区别。如果改成 embargo_pct=0.05,可以看到 n_train 进一步缩水,IC 标准差上升;这就是 embargo 长度的工程权衡。


十、检查清单

把本文所有要点压缩成一份上线前 CV 自检清单,每条都是发现频率高且修复成本低的项目:


参考文献

  1. de Prado, M. L. (2018). Advances in Financial Machine Learning. Wiley. 第 7 章 “Cross-Validation in Finance”、第 12 章 “Backtesting through Cross-Validation”。
  2. Bailey, D. H., & López de Prado, M. (2014). “The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest Overfitting, and Non-Normality.” Journal of Portfolio Management, 40(5), 94-107.
  3. Bailey, D. H., Borwein, J. M., López de Prado, M., & Zhu, Q. J. (2014). “Pseudo-Mathematics and Financial Charlatanism: The Effects of Backtest Overfitting on Out-of-Sample Performance.” Notices of the American Mathematical Society, 61(5), 458-471.
  4. Bailey, D. H., Borwein, J. M., López de Prado, M., & Zhu, Q. J. (2017). “The Probability of Backtest Overfitting.” Journal of Computational Finance, 20(4), 39-69.
  5. Harvey, C. R., Liu, Y., & Zhu, H. (2016). “…and the Cross-Section of Expected Returns.” Review of Financial Studies, 29(1), 5-68.
  6. Cawley, G. C., & Talbot, N. L. C. (2010). “On Over-fitting in Model Selection and Subsequent Selection Bias in Performance Evaluation.” Journal of Machine Learning Research, 11, 2079-2107.
  7. Varma, S., & Simon, R. (2006). “Bias in Error Estimation when Using Cross-Validation for Model Selection.” BMC Bioinformatics, 7, 91.
  8. Pedregosa, F., et al. (2011). “Scikit-learn: Machine Learning in Python.” Journal of Machine Learning Research, 12, 2825-2830.(TimeSeriesSplit 实现细节)
  9. mlfinlab Authors (2024). mlfinlab Documentation: Cross-Validation Module. https://mlfinlab.readthedocs.io/
  10. Hyndman, R. J., & Athanasopoulos, G. (2021). Forecasting: Principles and Practice (3rd ed.). OTexts. 第 5.10 节 “Time series cross-validation”。
  11. Cerqueira, V., Torgo, L., & Mozetič, I. (2020). “Evaluating Time Series Forecasting Models: An Empirical Study on Performance Estimation Methods.” Machine Learning, 109(11), 1997-2028.
  12. Bergmeir, C., & Benítez, J. M. (2012). “On the Use of Cross-Validation for Time Series Predictor Evaluation.” Information Sciences, 191, 192-213.
  13. Bergmeir, C., Hyndman, R. J., & Koo, B. (2018). “A Note on the Validity of Cross-Validation for Evaluating Autoregressive Time Series Prediction.” Computational Statistics & Data Analysis, 120, 70-83.
  14. Arlot, S., & Celisse, A. (2010). “A Survey of Cross-Validation Procedures for Model Selection.” Statistics Surveys, 4, 40-79.
  15. Cochrane, J. H. (2011). “Presidential Address: Discount Rates.” Journal of Finance, 66(4), 1047-1108.(关于因子动物园与多重比较)
  16. Joblib Developers (2024). Joblib: running Python functions as pipeline jobs. https://joblib.readthedocs.io/
  17. Buitinck, L., et al. (2013). “API design for machine learning software: experiences from the scikit-learn project.” ECML PKDD Workshop on Languages for Data Mining and Machine Learning.
  18. Romano, S., et al. (2014). “Standardized Mutual Information for Clustering Comparisons.” Proceedings of the 31st International Conference on Machine Learning (ICML).(用作多重比较统计量参考)
  19. Ng, A. Y. (1997). “Preventing ‘Overfitting’ of Cross-Validation Data.” Proceedings of the 14th International Conference on Machine Learning (ICML).
  20. Domingos, P. (2012). “A Few Useful Things to Know about Machine Learning.” Communications of the ACM, 55(10), 78-87.

系列导航

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-05-01 · quant

【量化交易】量化交易全景:从信号到订单的工程链路

量化交易不是策略写得好就能赚钱,更难的是把数据、特征、因子、信号、组合、执行、风控、复盘这八段链路在工程上连成一条不漏数据、不串时间、不丢订单的流水线。本文是【量化交易】系列的总目录与读图,给出八段链路的输入输出、失败模式、不变量清单,并用研究流程图把从一个想法到一笔实盘订单之间所有该过的卡点串起来。

2026-05-01 · quant

【量化交易】市场结构:交易所、做市商、暗池、ECN

系统梳理全球市场结构(Market Structure)的工程图景:从证券交易所、衍生品交易所、加密交易所,到做市商、暗池、ECN/ATS,再到 Maker-Taker 收费、PFOF、Reg NMS 与 MiFID II 的监管影响;给出量化策略选择交易场所的判断框架与基于 ccxt 的多交易所行情聚合代码。

2026-05-01 · quant

【量化交易】市场微结构:订单簿、价差、流动性、冲击

系统讲解市场微结构的核心概念与可计算工具:限价订单簿的数据模型、报价/有效/已实现价差、Roll 模型、四维流动性度量、Kyle's lambda、订单流不平衡(OFI)、Almgren-Chriss 框架下的临时与永久冲击、PIN 与 VPIN、Hawkes 过程,并给出基于 polars 的 L2 增量处理与系数估计代码。

2026-05-01 · quant

【量化交易】订单类型与执行语义:限价、市价、IOC、FOK、冰山

把 Limit、Market、IOC、FOK、Iceberg、Stop、MOO/MOC 这些常被混为一谈的订单类型还原为价格、数量、时效、可见性、触发五个独立维度,并对照 A 股、港股、美股、CME、Binance 五个市场的实际语义差异,给出量化系统中的订单工厂、状态机与风控前置校验的工程实现。


By .