数据分析2.8 —— 组合清除交叉验证方法(The Combinatorial Purged Cross-Validation Method)
解决处理时间序列时普通交叉验证效果不佳的问题
前言
本文包含Demo演示代码,点击此处,或者右上角点击关联数据集。
本文由 Berend Gort 和 Bruce Yang 撰写,他们是开源项目 AI4Finance 的核心团队成员。该项目是一个开放社区,旨在为金融领域共享 AI 工具,并隶属于纽约哥伦比亚大学。GitHub 项目链接:点击此处
介绍
本文探讨了一种适用于时间序列数据的稳健回测方法,称为 PurgedKFold Cross-Validation (CV) 方法。网上关于 PurgedKFoldCV 的信息十分有限,虽然有现成的代码,但其用法往往缺乏解释。PurgedKFoldCV 的理念在 Lopez de Prado, M. (2018) 的 Advances in Financial Machine Learning 一书中得到了详细阐述。因此,在本文中,我们将向您展示如何以正确的方式实现 PurgedKFoldCV。
为什么传统的 Cross-Validation 失效
许多研究论文表明,k-fold cross-validation (CV) 方法可以取得良好的结果。关于这种方法的研究文献在互联网上数量众多,以至于更具体的验证方法几乎不为人知。
或许您已经阅读了很多文献,认为传统的 k-fold CV 表现良好。然而,这些结果几乎可以肯定是有问题的,原因如下:
- 因为观察数据无法通过 IID(独立同分布)过程生成,因此 k-fold CV 在金融领域中失败。
- CV 失败的另一个原因是:测试集在模型开发过程中被多次使用,导致多重测试和选择偏差。
让我们先专注于第一个原因。
当训练集中包含出现在测试集中的信息时,就会发生数据泄漏。时间序列数据通常具有序列相关性,例如加密货币的 Open-High-Low-Close-Volume (OHLCV) 数据。以下是一个基于序列相关特征 X 和由重叠数据派生出的标签 Y 的例子:
- 序列相关的观测值:这意味着接下来的几个观测值依赖于当前观测值的数值。
- 目标标签来自重叠的数据点:例如,我们的目标标签基于未来 10 个样本的情况,用于确定价格是否下跌、保持不变或上涨。我们将目标标签设为
[0, 1, 2]。这种标注是基于未来的数值完成的。
因此,当我们将这些数据点分配到不同的集合时,可能会导致信息从一个集合泄露到另一个集合。为了避免这种情况,我们需要采取以下措施:
- Purging(清除):在测试集之前、训练集之后,从训练集中移除 10 个样本,以确保不会发生从训练集到测试集的信息泄漏。
- Embargoing(禁用):当 purging 无法完全防止所有信息泄漏时,需在测试集之后,从训练集中移除一定数量的样本(整数个样本),以进一步避免泄漏。
前训后测:Walk Forward Back-Testing Method
Walk Forward (WF) 方法论是文献中最常用的回测方法。WF 是一种策略在历史上的模拟测试,每个策略决策都基于决策前所获得的信息。
WF 方法具有两个主要优点:
- 清晰的历史意义:WF 能很好地解释历史表现,其性能可与实际交易中的表现相比较。
- 确保测试集完全为样本外数据(OOS):历史数据是一个过滤器,使用终端数据保证测试集完全不包含样本内数据。
然而,WF 方法也有三个主要缺点:
- 仅测试单一场景:容易导致过拟合。
- 可能无法代表未来表现:结果可能因特定数据序列(例如,仅测试显著上升趋势)而产生偏差。
- 初始决策基于样本总量中较小的一部分:初始阶段数据有限可能影响结果。
交叉验证
投资者经常问,如果某个策略在 2008 年金融危机中表现如何,该如何评估?一种方法是将数据分为训练集和测试集,训练集不包含 2008 年的数据,而测试集完全覆盖 2008 年的危机。
例如,一个分类器可能训练于 2009 年 1 月 1 日到 2017 年 1 月 1 日的数据,并在 2008 年 1 月 1 日到 2008 年 12 月 31 日期间进行评估。由于分类器基于 2008 年后才获得的数据进行训练,因此其在 2008 年的表现并不具有历史准确性。然而,测试的目的并非是历史准确性,而是让一个对 2008 年“无知”的策略在类似 2008 年的压力情况下进行测试。
通过 Cross-Validation(CV) 方法进行回测的目标并不是推导出历史表现,而是从多个样本外(OOS)场景中推测未来表现。对于回测的每个阶段,我们模拟一个分类器的表现,该分类器知道所有其他时期的数据,唯独不包括该阶段的数据。
CV 方法的优点:
- 不基于单一历史场景:CV 方法评估了 k 个不同场景,其中只有一个与历史数据的顺序一致。
- 基于相同大小的分组进行决策:因此,结果可以在不同时期之间进行比较,从而评估决策所需的数据量。
- 每个观察点仅属于一个测试集:没有“预热子集”,这使得可模拟的样本外数据量最大化。
CV 方法的缺点:
- 单一回测路径:类似于 WF,每个观察点仅生成一个预测。
组合清除交叉验证 (Combinatorial Purged Cross-Validation, CPCV) 回测算法
CPCV 算法在研究者确定的一组回测路径中,提供了构建训练集/测试集所需的所有组合,同时移除了包含泄漏信息的训练观测点。
示例说明
假设我们有 1000 个数据点。我们希望将这 1000 个数据点分为 6 组,其中 2 组为测试组(见下图)。
有多少种数据拆分可能性?
根据组合公式,可能的拆分数量为 nCr(6,6−2)=15。


图 1:由 810 个数据点组成的数据集的可能拆分。组数 N=6,测试组数 k=2。
每次拆分涉及 k=2 个测试组,总测试组数量为 k×Nsplits=30。此外,由于我们计算了所有可能的组合,这些测试组在所有 N 中均匀分布。因此,路径总数为 30/6=5 条路径。
回测路径数量公式
在我们的示例中,总路径数量为 5 条。
图 1 中,标记为 “x” 的组表示测试集,未标记的组则是训练集。通过这种训练/测试拆分技术,我们可以计算出 5 条回测路径,因为每组都属于 𝜑[6,2]=5 个测试集。
也许你现在有点困惑,并有以下问题:
1. 针对每个拆分,你在训练什么模型,为什么这样做?
首先,我们需要统一一些符号说明。以下是一些示例:
图一:

对于每个垂直拆分(如 S1,S2…S15S1, S2 \ldots S15S1,S2…S15),我们训练一个分类器来预测目标标签 y(1,1),y(1,2)y(1,1), y(1,2)y(1,1),y(1,2) 等。这些分类器的基础模型在每个拆分中是一致的,但它们的预测结果会有所不同,因此它们仍然是不同的分类器。
以下是一些简单的示例(参见图 1):

2. 为什么这些回测路径是独一无二的?
假设我们已经训练了所有分类器,并计算出图 1 中所有 x 位置的预测值。因此,现在我们可以基于这些预测应用我们的策略。
请看图 2。路径生成算法将测试组上的所有预测分配到 5 条独一无二的路径之一。这些路径的片段可以以任意方式重新组合,而不同的组合应该会收敛于相同的分布。
关键点是:这些所有的前向行走路径(walk-forward paths)都是完全**样本外(OOS)**的!分类器在这些路径上没有经过训练(参见图 2)。
图二

示例:路径 1

结论:
我们现在有 5 条前向行走回测路径(而不是 1 条)。因此,我们可以得到 5 个 Sharpe 比率或其他指标来衡量模型的表现。这些多个指标允许进行“统计回测”,从而显著降低假阳性发现的可能性(假设路径数量足够多)。
PurgedKFoldCV代码
MIT的Sam31415 开发了一个用于此目的的软件包,我们经过一番摸索后才弄清楚如何使用。然而,CombPurgedKFoldCV类 需要进行一些修复!因此,我们建议使用我改进的版本(代码已在Colab中提供)。
如果想了解更多细节,可以在GitHub上查看以下链接:
GitHub — sam31415/timeseriescv:
该包实现了两种交叉验证算法,适合基于时间序列数据评估机器学习模型。
你使用的数据
在将此方法应用于数据框时,需要满足以下两个要求:
- 数据必须是时间序列;
- 目标标签(target labels)必须基于未来的值生成。
仅此而已!你可以对任何符合上述要求的数据框使用它。
我使用的数据示例

这是一个基于比特币特征构建的数据框示例。数据框包括时间戳(timestamp)、一些特征(features)以及一个基于未来 t_finalt\_finalt_final 值生成的标签(label_barrier)。
代码解析
记住,我们有观察时间(observations)和评估时间(evaluations)。如果你对Combinatorial PurgedKFoldCV 感兴趣,需要注意你的评估发生在未来,并且与观察时间相关联。例如,在我的案例中,评估是在未来 t_finalt\_finalt_final 数据点之后完成的。
为了实现这一点,我们需要定义观察时间和评估时间。以下代码片段实现了这些步骤:
步骤
- 选择比特币数据:选取包含时间戳、特征和目标变量(如label_barrier)的比特币数据。
- 提取时间戳索引:获取比特币数据的索引(时间戳)。
- 处理特征数据框 X:从特征数据框中删除目标变量 y(如label_barrier)。删除最后 t_final 个特征,因为这些特征无法进行预测,其值为 NaN。
- 处理目标变量 y:选取目标变量列(label_barrier)。重新设定其索引,与原数据框对齐。丢弃最后 t_final 个目标值(预测值为 NaN,因为超出了可预测范围)。
- 设置观察时间和评估时间:观察时间(Prediction Times):定义观察数据的时间点(即预测的时间点)。评估时间(Evaluation Times):定义目标变量预测结果评估的时间点。
代码实现
# 设置数据源
data = data_ohlcv
data_index = data.index
# 选择训练数据
X = data.drop(['label_barrier'], axis=1) # 删除目标变量
X.drop(X.tail(t_final).index, inplace=True) # 删除最后 t_final 个特征
# 选择测试数据
y = data[['label_barrier']]
y.reindex(data_index) # 重新设置索引
y = y[:-t_final] # 删除最后 t_final 个预测值
y = y.squeeze() # 转换为 Series 格式
# 定义观察时间和评估时间
t1_ = data.index # 原始时间戳索引
# 定义观察时间:预测时刻
prediction_times = pd.Series(t1_[:-t_final], index=X.index)
# 定义评估时间:目标变量的评估时刻
evaluation_times = pd.Series(t1_[t_final:], index=X.index)
代码说明
- 预测时间(Prediction Times):数据被观测的时间点。示例:假设我们当前有10天的持仓时间 t_final=10,预测时间是对应观测的时间点。
- 评估时间(Evaluation Times):目标变量被评估的时间点(如止损或获利发生时)。重要提醒:t_final 是一个窗口大小的最大值,例如“箱体”概念。
通过以上步骤,您已经准备好为时间序列数据使用Combinatorial PurgedKFoldCV 进行交叉验证了。
绘制 Combinatorial PurgedKFoldCV 的路径
现在我们可以创建 CombPurgedKFoldCV 类的实例。为了简单起见,我们沿用 Lopez 的例子,其中分组数 N=6N = 6N=6(Python 索引从 0 开始,因此实际上是 5!),测试分组数 k=2k = 2k=2。
关于 Embargo(禁用窗口)
禁用窗口(embargo)取决于你的具体问题。在此示例中,为了简化,我们将禁用窗口的值设定为与 purging 相同。
Back Test Paths 生成器
文章底部提供了 back_test_paths_generator 函数的实现,用于生成回测路径。
代码实现
# 常量
num_paths = 5 # 路径数
k = 2 # 测试分组数量
N = num_paths + 1 # 总分组数量
embargo_td = pd.Timedelta(days=1) * t_final # 禁用窗口设置
# 创建 CombPurgedKFoldCV 类的实例
cv = CombPurgedKFoldCV(n_splits=N, n_test_splits=k, embargo_td=embargo_td)
# 生成回测路径
_, paths, _ = back_test_paths_generator(X.shape[0], N, k)
# 绘制
groups = list(range(X.shape[0])) # 分组范围
fig, ax = plt.subplots() # 创建绘图
plot_cv_indices(cv, X, y, groups, ax, num_paths, k) # 调用绘图函数
plt.gca().invert_yaxis() # 倒置 y 轴
绘图函数说明
我基于 Scikit-learn 的交叉验证可视化页面,改编了现有功能并添加了一些必要内容来实现绘图功能。关键点如下:
- 自定义绘图函数 plot_cv_indices:该函数在绘制分组和路径的同时支持 CombPurgedKFoldCV 的显示。可视化不同测试分组的路径分布。
- for 循环(第 13 行):这部分代码与实际训练过程一致。在循环中,按照每个分组训练分类器(后续可以直接复制该部分代码来训练每个分类器)。
绘图可视化
绘图的效果展示了:
- 不同测试分组 k=2 的样本分布。
- 每个路径中如何划分训练集和测试集。
- 禁用窗口(embargo)的可视化,使得训练集与测试集严格隔离。
# 设置颜色映射
cmap_data = plt.cm.Paired # 数据类别颜色映射
cmap_cv = plt.cm.coolwarm # CV 训练/测试分组颜色映射
def plot_cv_indices(cv, X, y, group, ax, n_paths, k, paths, lw=5):
"""
绘制交叉验证对象的索引分布图。
参数说明:
cv : cross-validation object
交叉验证对象,例如 CombPurgedKFoldCV。
X : DataFrame
特征数据。
y : Series
目标标签。
group : array-like
分组信息。
ax : matplotlib.axes.Axes
绘图的轴。
n_paths : int
回测路径的数量。
k : int
每次测试的分组数。
paths : array-like
回测路径数据。
lw : int, default=5
绘图的线宽。
"""
# 生成组合
N = n_paths + 1
test_groups = np.array(list(itt.combinations(np.arange(N), k))).reshape(-1, k) # 测试分组组合
n_splits = len(test_groups) # 分割次数
# 为每次 CV 分割生成训练/测试的可视化
for ii, (tr, tt) in enumerate(cv.split(X, y, pred_times=prediction_times, eval_times=evaluation_times)):
# 初始化索引数组:2 表示未被选中(NaN)
indices = np.array([np.nan] * len(X))
indices[tt] = 1 # 测试集标记为 1
indices[tr] = 0 # 训练集标记为 0
indices[np.isnan(indices)] = 2 # 未被选中部分标记为 2
# 可视化结果
ax.scatter(
[ii + 0.5] * len(indices), # 横坐标位置
range(len(indices)), # 样本索引
c=[indices], # 根据索引类别着色
marker="_", # 标记符号
lw=lw, # 线宽
cmap=cmap_cv, # 颜色映射
vmin=-0.2, # 最小值
vmax=1.2 # 最大值
)
# 在末尾绘制数据类别和分组
ax.scatter(
[ii + 1.5] * len(X), # 横坐标位置
range(len(X)), # 样本索引
c=y, # 使用目标变量进行着色
marker="_", # 标记符号
lw=lw, # 线宽
cmap=cmap_data # 使用数据类别颜色映射
)
ax.scatter(
[ii + 2.5] * len(X), # 横坐标位置
range(len(X)), # 样本索引
c=group, # 使用分组数据进行着色
marker="_", # 标记符号
lw=lw, # 线宽
cmap=cmap_data # 使用数据类别颜色映射
)
# 格式化
xlabelz = list(range(n_splits, 0, -1)) # 横坐标标签,从分割次数递减
xlabelz = ['S' + str(x) for x in xlabelz] # 添加前缀 'S'
xticklabels = xlabelz + ["class", "group"] # 添加 'class' 和 'group' 标签
ax.set(
xticks=np.arange(n_splits + 2) + 0.45, # 横坐标刻度
xticklabels=xticklabels, # 横坐标标签
ylabel="Sample index", # 纵坐标标签
xlabel="CV iteration", # 横坐标标签
xlim=[n_splits + 2.2, -0.2], # 横轴范围
ylim=[0, X.shape[0]], # 纵轴范围
)
ax.set_title("{}".format(type(cv).__name__), fontsize=5) # 设置标题
ax.xaxis.tick_top() # 横坐标刻度放在顶部
return ax
关键点解读
- 数据索引分布:测试集:用 1 表示。训练集:用 0 表示。未被选中的数据:用 2 表示。
- 横坐标标签设计:对每次 CV 迭代(S1, S2, ...)进行标注。添加额外列用于标识数据类别(class)和分组(group)。
- 颜色映射:使用 cmap_cv 可视化训练和测试分组。使用 cmap_data 显示数据类别和分组。
- 灵活性:该函数可以直接用于绘制不同交叉验证方法(例如 CombPurgedKFoldCV)的可视化索引分布。
可视化结果
生成的图像展示了:
- 每次交叉验证迭代的训练集与测试集的分布。
- 数据类别与分组的分布。
- 直观展示了如何严格避免训练集与测试集之间的信息泄露。
使用该绘图功能,可以直观理解数据分组及其对训练和测试的影响。
结果分析
图 3

在图 3 中:
- 蓝色 (Blue): 训练阶段 (train periods)。
- 深红色 (Deep Red): 禁止区间 / 已清除区间 (embargo/purged periods)。
- 浅红色 (Light Red): 测试阶段 (test periods)。
深红色禁止区间/清除区间的分析:
- 测试阶段之前和训练阶段之后存在清除区间 (purging period)。这可以有效防止训练数据中的信息泄露到测试数据。
- 测试阶段之后既有清除区间 (purging period),也有禁止区间 (embargoing period)。清除区间紧接测试阶段,以防止潜在的信息泄露。禁止区间进一步扩大了与下一段数据的时间间隔。
- 禁止区间大于清除区间:测试阶段结束后的时间间隔(即禁止区间)比测试阶段开始前的清除区间更大。
总结:
- 因为清除和禁止区间的设置,测试集的末尾与下一段训练数据之间的间隔会大于测试集开头与上一段训练数据的间隔。
组合清除交叉验证 (Combinatorial PurgedKFoldCV) 的回测路径生成器解析
函数介绍
这个路径生成函数非常简单,只需要以下三个参数:
- 观测数量 (amount of observations): 数据集中总观测点数。
- 组数 N: 数据被分成的总组数。
- 测试组数 k: 每次测试中被选作测试集的组数。
目标: 生成如图所示的路径 1–5(如下所示)。快速阅读代码后,我们将在代码下方详细解释这些函数生成的路径。
def back_test_paths_generator(t_span, n, k, verbose=True):
# 将数据分成 N 组,且 N << T
# 这一步将每个索引位置分配到一个组中
group_num = np.arange(t_span) // (t_span // n)
group_num[group_num == n] = n-1
# 生成所有可能的组合
test_groups = np.array(list(itt.combinations(np.arange(n), k))).reshape(-1, k)
C_nk = len(test_groups)
n_paths = C_nk * k // n
if verbose:
print('n_sim:', C_nk) # 打印组合的总数
print('n_paths:', n_paths) # 打印生成的路径数
# is_test 是一个 T x C(n, k) 的数组,其中每列是一个逻辑数组
# 用来指示哪个观测点属于测试集
is_test_group = np.full((n, C_nk), fill_value=False)
is_test = np.full((t_span, C_nk), fill_value=False)
# 为每个 C(n, k) 的组合分配测试集
for k, pair in enumerate(test_groups):
i, j = pair
is_test_group[[i, j], k] = True
# 分配测试集
mask = (group_num == i) | (group_num == j)
is_test[mask, k] = True
# 对于每条路径,从不同的组合连接测试集以形成回测路径
# 每个路径的折叠坐标包括:折叠编号和组合的索引(例如,第0组的第0折)
path_folds = np.full((n, n_paths), fill_value=np.nan)
for i in range(n_paths):
for j in range(n):
s_idx = is_test_group[j, :].argmax().astype(int)
path_folds[j, i] = s_idx
is_test_group[j, s_idx] = False
cv.split(X, y, pred_times=prediction_times, eval_times=evaluation_times)
# 最后,对于每条路径,我们指示路径来自哪个组合以及其时间索引
paths = np.full((t_span, n_paths), fill_value=np.nan)
for p in range(n_paths):
for i in range(n):
mask = (group_num == i)
paths[mask, p] = int(path_folds[i, p])
# paths = paths_# .astype(int)
return (is_test, paths, path_folds)图 6

Colab 数据集示例
- 数据集中共有 813 个观测点。
- 输入数据量的大小对解释函数的逻辑没有影响。
- 假设一个小数据集有 30 个观测点,我们仍然设置 N=6 组,且 k=2 个测试组。
这段函数的目的是通过小规模数据集演示如何划分训练集和测试集,同时确保清除区间和禁止区间的完整性。这种生成方式可推广到任意规模的数据集,适用于时间序列交叉验证和回测分析。
我们在这篇笔记本的路径输出上加了1,以便更方便地理解!
# 计算回测路径
N_observations = 30 # 在 Colab 数据集中有 813 条观测数据
_, paths, _ = back_test_paths_generator(N_observations, N, k, prediction_times, evaluation_times)
# 加 1 以避免 Python 从 0 开始计数(对你们来说更加直观)
paths + 1
回测路径生成函数 paths 的输出示例。作者提供的图片。
看明白这函数做了什么吗?图 6 中的每一列是一个回测路径。在这个例子中,数据共包含 30 个点,因此每组的大小为 5 个数据点(总共有 6 组)。图中用粉红色的水平线表示了组的分割。数字表示应使用哪组预测(参见 图 3)。
例如,路径 4 的规则如下:
- 对于数据点 1–5,使用分类器 4 的预测
- 对于数据点 6–10,使用分类器 8 的预测
- 对于数据点 11–15,使用分类器 11 的预测
- 对于数据点 16–20,使用分类器 13 的预测
- 对于数据点 21–25,使用分类器 13 的预测
- 对于数据点 26–30,使用分类器 14 的预测
就是这样!
总结
传统时间序列的回测方法存在样本利用率低和数据泄露的问题。为了避免信息的极端泄露,引入了清除和禁运(Purging 和 Embargoing)。传统的滚动前移(Walk-Forward,WF)回测方法仅测试单一场景,容易导致过拟合。此外,WF 不能很好地代表未来的性能,因为数据点的特定顺序可能会对结果产生偏倚。最后,WF 的初始决策基于样本空间的一小部分,限制了其泛化能力。
PurgedK-FoldCV 通过在等大小的组上进行回测解决了这些问题,同时确保每个观测点仅属于一个测试集。
Combinatorial PurgedKFoldCV 方法通过生成多个独特的回测路径,进一步降低了错误发现的可能性。
本文提供了如何索引组合式 PurgedKFoldCV 的代码和详细解释,并比较了文献中最常用方法的优劣,以及 Combinatorial PurgedKFoldCV 方法的优劣。之后深入探讨了方法的细节,解释了多个难以理解的关键点。最后,还包括了对 Colab 代码的小讨论和解释。
希望这篇文章能让未来有更多人受益!
Reference
Prado, M. L. de. (2018). Advances in financial machine learning.