跳到主要内容

金融交叉验证:Purged K-Fold 与禁运

:::info 学习目标 你将能用标签起止区间判断重叠,分别实现普通随机 KFold 与连续 Purged K-Fold,并解释 purge 和 embargo 解决的不是同一个问题。 :::

直觉

普通机器学习的 KK 折交叉验证默认打乱数据——这在 IID 场景没问题。但金融数据是时间相关的:昨天和今天的收益高度相关,而且当你用多期未来收益做标签时,相邻样本的标签会重叠(都覆盖了同一段未来)。一旦打乱,训练集里就会出现「标签和测试集标签几乎同一段未来」的样本——模型等于偷看了答案,交叉验证分数被严重高估。López de Prado 提出的 Purged K-Fold + 禁运(Embargo) 就是为修这个问题而生。

标签重叠与 Purge

设标签是 hh 期远期收益,则样本 ii 的标签覆盖时间窗 [i,i+h][i,\, i+h]。两个样本的窗口一旦相交,就共享了信息

重叠    [i,i+h][j,j+h]\text{重叠} \iff [i,\,i+h]\,\cap\,[j,\,j+h]\ne\varnothing

Purge:对每个测试折,把训练集中标签窗口与测试标签窗口相交的样本全部剔除。Embargo(禁运):测试折结束后的若干个样本也剔除——因为它们的标签可能由测试期内的数据算出,仍带轻微泄漏。

「人话」解释:为什么普通 KFold 在金融里会「作弊」?

假设标签是「未来 3 个月收益」。4 月的样本标签覆盖 4–6 月,5 月的样本标签覆盖 5–7 月——两者都包含 5、6 月。如果 5 月样本在训练集、4 月样本在测试集,模型训练时已经「见过」测试标签里的一大部分未来,验证分数当然虚高。 Purge 就是「测试折动到的未来,训练集一概不许碰」,把这种共享未来的样本删掉,还原出诚实的样本外分数。

可运行案例:泄漏让 IC 虚高多少?

3 个月远期收益作为标签(标签重叠 h=3h=3),对比两种 5 折交叉验证的平均 Rank IC:打乱 KFold(泄漏) vs Purged + 禁运(诚实)

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

prices = pd.read_csv('/data/sp500_universe_daily.csv', parse_dates=['date']).set_index('date')
meta = pd.read_csv('/data/sp500_metadata.csv').set_index('ticker').loc[prices.columns]
dret = np.log(prices / prices.shift(1)); monthly = prices.resample('ME').last()

mom = lambda n: np.log(monthly / monthly.shift(n)).stack()
feats = {'mom_1': mom(1), 'mom_3': mom(3), 'mom_6': mom(6), 'mom_12': mom(12),
       'vol_3': (dret.rolling(63).std() * np.sqrt(252)).resample('ME').last().stack()}
F = pd.DataFrame(feats).join(meta['quality'], on='ticker')
h = 3
y = np.log(monthly.shift(-h) / monthly).stack()          # 3 月远期收益(标签重叠)
data = F.join(y.rename('y')).dropna().sort_index()
cols = list(F.columns)
dates = data.index.get_level_values('date').unique().sort_values()
D = len(dates)                                            # 月数 = 时间步数

def naive_folds(n_splits=5):
  """普通随机 KFold:测试月被打散,训练集使用其余所有月份。"""
  order = np.random.default_rng(0).permutation(D)
  for ti in np.array_split(order, n_splits):
      ti = np.sort(ti)
      tr = np.setdiff1d(np.arange(D), ti)
      yield list(dates[ti]), list(dates[tr])

def purged_folds(n_splits=5, h=3, embargo=1):
  """连续时间折;删除标签区间与测试标签区间相交的训练月。"""
  for ti in np.array_split(np.arange(D), n_splits):
      lo, hi = int(ti[0]), int(ti[-1])
      test_label_start, test_label_end = lo, hi + h
      tr = []
      for i in range(D):
          if lo <= i <= hi:
              continue
          train_start, train_end = i, i + h
          overlaps = train_start <= test_label_end and train_end >= test_label_start
          in_embargo = test_label_end < i <= test_label_end + embargo
          if not overlaps and not in_embargo:
              tr.append(i)
      yield list(dates[ti]), list(dates[tr])

def evaluate(folds):
  ics = []
  for te_d, tr_d in folds:
      dtr = data[data.index.get_level_values('date').isin(tr_d)]
      dte = data[data.index.get_level_values('date').isin(te_d)]
      if len(dtr) == 0: continue
      rf = RandomForestRegressor(n_estimators=100, max_depth=4, random_state=0).fit(dtr[cols], dtr['y'])
      p = pd.Series(rf.predict(dte[cols]), index=dte.index)
      ics.append(p.groupby('date').apply(lambda s: s.rank().corr(dte.loc[s.index,'y'].rank())).mean())
  return np.nanmean(ics)

naive = evaluate(naive_folds())
purged = evaluate(purged_folds(h=h, embargo=1))
print(f"打乱 KFold 平均 IC  = {naive:+.3f}   ← 标签重叠泄漏, 虚高")
print(f"Purged+禁运 平均 IC = {purged:+.3f}   ← 诚实样本外")
print(f"\n泄漏让 IC 虚增约 {naive - purged:+.3f} → 这部分在实盘里根本赚不到。")

实现自检

实现时不要只传一个整数 hh 就假设所有标签窗口相同;止损、事件结束等标签可能有不同结束时间,应逐样本保存 t0/t1。测试折必须保持时间连续,随机打散折不能再用最小/最大索引冒充一个区间。最直接的测试是枚举训练与测试标签区间,断言没有任何交集。

实践与延伸

  1. 打印每折删除的 purge/embargo 月份,手工核对边界。
  2. 把固定三个月标签改成不等长事件标签,重写重叠判断。

延伸阅读:López de Prado《Advances in Financial Machine Learning》交叉验证章节。

小结

  • 金融数据时间相关 + 多期标签重叠,使打乱 KFold 严重泄漏、虚高样本外分数;
  • Purged K-Fold:剔除训练集中标签窗口与测试折重叠的样本;
  • 禁运(Embargo):再删测试折后若干样本,防标签由测试期数据算出;
  • 永远在时间有序 + Purge 下评估模型,否则上线即缩水。

章末自测

检查核心概念

1. 关于本章主题,哪项说法更准确?