学习路线 01:清洗 K 折交叉验证 — 从入门到掌握
7步学习路线:从理解为什么标准交叉验证对交易策略撒谎,到 purged k-fold 实现,到避免常见错误。
目标:完成后,你将理解为什么标准交叉验证会高估交易策略表现,清洗 K 折如何修正这个问题,以及如何正确实现。
前置知识:熟悉交叉验证(train/test 划分)和金融时间序列的基本概念。
第 1 步:这是什么?
假设你有 5 年的日频价格数据,用于一个均值回归策略。你想知道它是否真的有效。
你做了任何 ML 从业者都会做的事:切分训练集和测试集,用前 4 年训练,后 1 年测试。你得到 Sharpe 1.8。你感觉良好。
然后你上实盘。三个月后,策略在持续失血。你的 Sharpe 是 -0.3。
发生了什么?
训练集包含了关于测试集的信息。不是以明显的方式——没有”未来数据”字面在训练集里。但数据是自相关的。第 800 天的价格与第 801 天的相关。当你在 1-800 天上训练、在 801-1000 天测试时,你的模型已经学习了存在于两个时期的模式。它看起来比实际更好。
标准交叉验证假设样本是独立的。金融数据不断违反这个假设。Purged K-fold 是交叉验证的修改版本,考虑了序列相关性。
第 2 步:为什么这很重要?
没有清洗交叉验证,会发生以下情况:
你会高估策略表现。 这不是小偏差。在有动量的市场中(大多数市场),偏差很大。你可能以为 Sharpe 是 1.8,实际只有 0.6。
你会选错策略。 如果你在比较 10 个策略变体,回测中看起来最好的那个可能只是受益于信息泄露最多的那个。你没有选择最佳策略——你选择了最幸运的。
你会亏损。 这是后果。用过拟合的模型上实盘意味着你在交易一个对未见数据不起作用的模型。你的回撤会比预期更差,Sharpe 会比回测低。
陷阱在于回测看起来完全干净。没有明显错误。专业图表。所有你想要的统计数据。除非你知道要寻找什么,否则泄露是不可见的。
第 3 步:核心机制
Purged K-fold 在标准 K-fold 基础上增加了两个操作:
清仓(Purging) 移除标签(未来收益)与测试期重叠的训练样本。如果你的测试集是第 801-850 天,你移除边界附近训练样本的间隙——通常是测试集前后几天。
Embargo 在每个测试集之后增加一个间隙。下一个训练期直到 embargo 期结束后才开始。这考虑了信息效应的持续性。
图示:
第 1 折:
训练:第 1-800 天
清仓:第 801-(800+n_purge) ← 从训练集移除
测试:第 (801+n_purge)-(850+n_purge)
Embargo:第 (851+n_purge)-(850+n_embargo) ← 双方都移除
第 2 折:
训练:(embargo_end)-end
...
清仓间隙防止边界信息泄露。Embargo 间隙防止收益序列相关性抬高表现。
第 4 步:关键公式
Embargo 大小 通常是总数据的百分比:
对于 5 年日频数据(T ≈ 1,260),pct_embargo = 0.01:n_embargo = 12 天。
每折测试样本数:
对于 T=1260,n_embargo=12,k=5:n_test = 249 天。
第 i 折的有效训练集大小:
样本外 Sharpe(正确的指标):
其中 和 是所有折样本外收益的均值和标准差。
第 5 步:实现
import numpy as np
import pandas as pd
from sklearn.base import clone
def purged_kfold(X, y, k=5, pct_embargo=0.01, n_purge=None):
"""
Purged K-fold for financial time series.
"""
n = len(X)
n_embargo = int(n * pct_embargo)
if n_purge is None:
n_purge = int(np.sqrt(n))
n_per_fold = (n - n_embargo) // k
for i in range(k):
test_start = i * n_per_fold + n_purge
test_end = test_start + n_per_fold
train_end = test_start - n_purge
train_start = 0
embargo_end = min(test_end + n_embargo, n)
train_idx = np.arange(train_start, train_end)
test_idx = np.arange(test_start, test_end)
yield train_idx, test_idx
def evaluate_strategy(model, X, y, k=5, pct_embargo=0.01):
"""
Evaluate a strategy using purged K-fold CV.
"""
oos_returns = np.zeros(len(y))
fold_returns = []
for train_idx, test_idx in purged_kfold(X, y, k=k, pct_embargo=pct_embargo):
X_train, X_test = X[train_idx], X[test_idx]
y_train = y[train_idx]
model_clone = clone(model)
model_clone.fit(X_train, y_train)
preds = model_clone.predict(X_test)
fold_ret = preds * y[test_idx]
oos_returns[test_idx] = fold_ret
fold_returns.append(fold_ret)
oos_sharpe = np.mean(oos_returns) / np.std(oos_returns) * np.sqrt(252)
return {
'oos_sharpe': oos_sharpe,
'oos_returns': oos_returns[oos_returns != 0],
'fold_returns': fold_returns,
'fold_sharpes': [np.mean(fr) / np.std(fr) * np.sqrt(252) for fr in fold_returns]
}
第 6 步:如何解读结果
样本外 Sharpe 才是重要的。 不是样本内 Sharpe,不是全样本 Sharpe。是在所有折的留出测试集上计算的 Sharpe。
常见错误:报告合并样本外预测的 Sharpe(正确),但也看全样本 Sharpe “作为参考”——这具有误导性,因为它包括了用于训练的数据。
看逐折明细:
- 如果第 1 折 Sharpe 是 2.1、第 2 折是 -0.8,你的策略有结构性风险。不稳健。
- 平均值没有方差重要。平均 Sharpe 0.8、折标准差 0.3 的策略,比平均 1.2、折标准差 1.5 的策略更稳健。
报告最小折 Sharpe。 如果最差折 Sharpe > 0.5,策略是稳健的。如果最差折是负的,要怀疑。
第 7 步:常见陷阱 + 在知识体系中的位置
陷阱:使用 pct_embargo=0。 这是最常见的错误。如果不 embargo,就不是时间序列 CV。代价是效率——用更少的数据。好处是诚实的估计。总是用一些 embargo,即使 0.5%。
陷阱:清仓过小。
n_purge = sqrt(n) 是经验法则。但如果你的信号衰减慢(例如 200 天均线),sqrt(n) 可能太小。根据你信号的有效半衰期校准。
陷阱:比较 CV 和全样本。 你的 purged K-fold Sharpe 会比全样本 Sharpe 低。这是正确且预期的。如果有人给你看 Sharpe 2.1 的回测并说”我们用了 purged CV”,但他们的 purged CV Sharpe 也是 2.1,他们实际上没有做清仓。
在知识体系中的位置:
- 验证任何基于历史数据训练来预测未来收益的策略
- 比较策略变体(使用相同的 purge/embargo 设置以公平比较)
- 作为更高级方法的输入(PBO 使用 CPCV,延伸了 purged K-fold)
Purged K-fold 是基础。组合清仓 CV(CPCV)延伸了它。Deflated Sharpe 使用样本外收益作为输入。PBO 使用 CPCV。如果你做任何回测相关的事,你需要先有 purged K-fold。
下一步
准备好下一级了吗?→ 学习路线 02:组合清仓 CV
想要百科参考卡片?→ 时间序列 CV(百科词条)