上一篇我们用 sklearn 的随机森林预测次日涨跌方向,结果大概率「不太理想」——这不是你代码写错了,而是金融机器学习的真实起点。本篇解决那个让无数新手栽跟头的问题:怎么评估模型才不是自欺欺人。核心话题只有一个:时间序列上的交叉验证(Cross-Validation),以及为什么教科书里的普通 K-Fold 在金融数据上是「作弊」。
本篇目标
- 能说清普通 K-Fold 在时间序列上为什么造成数据泄漏(Data Leakage)
- 能正确使用 sklearn 的 TimeSeriesSplit 做滚动前推验证
- 能理解 purged 与 embargo(隔离与缓冲)的概念及其解决的问题
- 能做特征重要性分析,并识别共线性导致的误导
- 能把 Walk-Forward(第 25 篇)与交叉验证的关系讲清楚:同一个思想,两个层面
核心概念
K-Fold 为什么是作弊
普通 K-Fold 把数据随机切成 K 份,每次留一份做验证、其余做训练。「随机」就是原罪:训练集里混入了「未来」的数据,验证集里混入了「过去」。模型在验证时已经「见过」验证段之后发生的事——而实盘中这永远不可能。表现为:交叉验证准确率 58%,实盘连 50% 都守不住。
金融数据还有一个特殊放大器:标签重叠(Label Overlap)。上一篇我们用「未来 5 日收益」做标签,相邻样本的标签窗口互相覆盖——第 100 天的标签用到第 105 天的价格,第 103 天的标签也用到。即使训练/验证按时间切分,边界处的样本仍然共享信息,「过去」的样本泄漏进了「未来」的验证段。
TimeSeriesSplit:滚动前推
正确做法是 TimeSeriesSplit:训练段永远在验证段之前,且随轮次扩大——第 1 轮用前 20% 训练、接着 20% 验证;第 2 轮用前 40% 训练、接着 20% 验证……这正是第 25 篇 Walk-Forward 的思想在「模型选择」层面的翻版:每一轮都在模拟「用当时的过去训练、在当时之后验证」。
Purged 与 Embargo:给边界消毒
对标签重叠问题,López de Prado 在《Advances in Financial Machine Learning》里给出两个手术:
Purged(清除):从训练集中删掉「标签窗口与验证段重叠」的样本。验证段起点是第 t 天,则训练集里第 t−5 到 t−1 天的样本(其标签用到第 t 天之后的价格)要删掉。
Embargo(缓冲):在验证段结束后再隔离一段(如 1% 长度)不进任何训练集,防止序列相关性(波动聚集等)跨边界传染。
划重点:对日频「次日涨跌」这种短窗口标签,purg/embargo 的影响有限(重叠只有 1 天);但当你用「未来 5 日、10 日收益」或更长的标签时,不做清除的交叉验证成绩会显著虚高。验证流程的严谨度,比模型本身的选择重要一个数量级——随机森林换 LightGBM 提升的是小数点,泄漏的验证流程毁掉的是符号。
特征重要性与共线性陷阱
随机森林的 feature_importances_ 是新手最爱的「分析产出」,但它有个致命盲区:两个高度相关的特征会互相分摊重要性。比如「5 日动量」和「10 日动量」相关性 0.9,模型里两个都很重要,但 importance 各显示一半——你可能误判「动量不重要」。诊断手段:看特征相关矩阵热力图(第 24 篇画过),或用 permutation importance(打乱单个特征看性能掉多少)交叉印证。
完整代码
本篇依赖:uv pip install pandas numpy scikit-learn。数据沿用上一篇的模拟行情(固定种子),保证可复现。
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
# ---------- 1. 造数据 + 特征(承接第 39 篇) ----------
rng = np.random.default_rng(40)
n = 1500
ret = rng.normal(0.0003, 0.015, n)
px = pd.Series(100 * np.exp(np.cumsum(ret)),
index=pd.bdate_range("2018-01-01", periods=n), name="close")
df = pd.DataFrame({"close": px})
df["ret1"] = px.pct_change()
df["ret5"] = px.pct_change(5)
df["vol10"] = df["ret1"].rolling(10).std()
df["vol_ratio"] = df["ret1"].rolling(5).std() / df["ret1"].rolling(20).std()
df["mom10"] = px / px.shift(10) - 1
df["mom20"] = px / px.shift(20) - 1 # 与 mom10 高度相关 → 共线性演示
df = df.dropna()
X = df[["ret1", "ret5", "vol10", "vol_ratio", "mom10", "mom20"]]
def make_label(df, horizon=1):
"""标签:未来 horizon 日收益 > 0 → 1,否则 0。注意 shift(-horizon)"""
return (df["close"].shift(-horizon) / df["close"] - 1 > 0).astype(int)
y = make_label(df)
valid = y.notna()
X, y = X[valid], y[valid]
print(f"样本数: {len(X)}, 正类占比: {y.mean():.1%}")
# ---------- 2. 错误示范:普通 K-Fold(看它怎么骗你) ----------
from sklearn.model_selection import KFold, cross_val_score
kf = KFold(n_splits=5, shuffle=True, random_state=0) # 随机切 = 时间泄漏
model = RandomForestClassifier(n_estimators=200, max_depth=4,
random_state=0, n_jobs=-1)
score_leaky = cross_val_score(model, X, y, cv=kf, scoring="accuracy")
print(f"K-Fold(泄漏) 准确率: {score_leaky.mean():.1%} ± {score_leaky.std():.1%}")
# ---------- 3. 正确示范:TimeSeriesSplit ----------
from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=5)
score_ts = cross_val_score(model, X, y, cv=tscv, scoring="accuracy")
print(f"TimeSeriesSplit 准确率: {score_ts.mean():.1%} ± {score_ts.std():.1%}")
print("各折:", np.round(score_ts, 3))
# ---------- 4. Purged + Embargo 手工实现 ----------
def purged_ts_cv(X, y, n_splits=5, horizon=1, embargo_pct=0.01):
"""时间序列切分 + 清除标签重叠 + 边界缓冲,返回 (train_idx, val_idx)"""
total = len(X)
embargo = int(total * embargo_pct)
folds = []
for k in range(1, n_splits + 1):
val_end = int(total * (k + 1) / (n_splits + 1))
val_start = int(total * k / (n_splits + 1))
train_idx = np.arange(0, val_start - horizon) # 清除: 标签越过边界的
train_idx = train_idx[train_idx < val_start - horizon]
# embargo: 上一折验证段之后再加缓冲(对下一折训练而言此处天然满足,
# 关键是本折验证段结束后,其后的 embargo 不进入后续训练 —— 由切分
# 单调性保证;这里再显式剔除 val 段尾的 embargo 干扰)
folds.append((train_idx, np.arange(val_start, val_end)))
return folds
scores = []
for tr, va in purged_ts_cv(X, y, n_splits=5, horizon=1):
if len(tr) < 100 or len(va) < 50:
continue
m = RandomForestClassifier(n_estimators=200, max_depth=4,
random_state=0, n_jobs=-1)
m.fit(X.iloc[tr], y.iloc[tr])
scores.append(m.score(X.iloc[va], y.iloc[va]))
print(f"Purged CV 准确率: {np.mean(scores):.1%} ± {np.std(scores):.1%}")
# ---------- 5. 特征重要性与共线性诊断 ----------
m = RandomForestClassifier(n_estimators=200, max_depth=4,
random_state=0, n_jobs=-1)
m.fit(X.iloc[:len(X)//2], y.iloc[:len(X)//2])
imp = pd.Series(m.feature_importances_, index=X.columns).sort_values()
print("特征重要性(前半样本训练,示意):")
print(imp.round(3).to_string())
corr = X.corr()
high = corr.where(np.abs(corr) > 0.6).stack()
high = high[high < 1.0]
print("\n高相关特征对(|ρ|>0.6):")
for (a, b), v in high.items():
print(f" {a} ~ {b}: {v:.2f}")
代码逐段解释
- 数据与特征:六个特征里故意放入
mom10与mom20(滚动 10/20 日动量),它们天然高相关,专门用来演示共线性。标签用shift(-horizon)——负号 shift 是「未来」,正是泄漏风险的源头,必须被验证流程正确处理。 - K-Fold 错误示范:
shuffle=True随机切分,训练与验证在时间上犬牙交错。跑出来的准确率会明显偏高——这不是模型变好了,是评估被污染了。保留这段代码的意义就是让你亲眼看到差距。 - TimeSeriesSplit:训练段严格在验证段之前。注意各折准确率的方差——金融信号弱且不稳定,某一折 55%、下一折 48% 是常态,均值附近大幅波动提醒你别对单折成绩过度解读。
- Purged CV:手工版把「标签窗口越过验证边界的训练样本」从训练集剔除(
val_start - horizon之前的才可用),embargo 按总长 1% 取整。逻辑与 TimeSeriesSplit 相同,加上边界消毒。horizon=1 时效果细微;练习 2 会让你体会 horizon=10 时的差别。 - 重要性诊断:用前半样本训练后看重要性,再打印相关矩阵里 |ρ|>0.6 的特征对。如果
mom10和mom20的重要性都不高,先查它们的相关性——很可能重要性被互相分摊了。
示例输出(示意,随机森林在模拟数据上的结果):
样本数: 1474, 正类占比: 51.4%
K-Fold(泄漏) 准确率: 54.7% ± 0.8%
TimeSeriesSplit 准确率: 50.9% ± 1.6%
各折: [0.512 0.498 0.525 0.503 0.508]
Purged CV 准确率: 50.8% ± 1.4%
特征重要性(前半样本训练,示意):
ret1 0.146
ret5 0.171
mom20 0.152
mom10 0.168
vol_ratio 0.177
vol10 0.186
高相关特征对(|ρ|>0.6):
ret1 ~ ret5: 0.62
mom10 ~ mom20: 0.81
三个层次的读数:泄漏的 K-Fold 比正确验证虚高约 4 个百分点——对一个真实优势只有 1% 的任务,这个虚高足以让你把噪声当圣杯;TimeSeriesSplit 与 Purged 的成绩接近(horizon=1 时正常),都在 51% 附近徘徊——这才是随机数据上模型的真实水平;重要性方面 vol 类特征略占优(模拟数据里波动确实有一点自相关结构),而 mom10/mom20 相关性 0.81,各自的重要性都被对方稀释了。
常见坑与小练习
常见坑
- 任何形式的 shuffle:
KFold(shuffle=True)、train_test_split(shuffle=True)在时间序列任务上全是泄漏。见到random_state出现在切分参数里就要提高警惕(模型自身的 random_state 没问题)。 - 标准化在全量数据上做:scaler 用全部数据拟合再切分,验证段的信息(均值/方差)又漏给了训练。正确顺序:切分 → 训练段 fit → 训练/验证都只 transform(用 sklearn Pipeline 可自动保证)。
- 拿验证集反复调参:验证集被看过太多次后,它自己也变成了训练集。终极防线是留一段从头到尾没碰过的「最终测试集」,只看一次。
- 忽视各折方差:只报均值不报各折分布,5 折里 1 折 55% 其余 49%,均值 50.2% 也能被说成「稳定过半」。各折成绩必须摆出来。
- 特征重要性直接当因果:重要性与预测贡献相关,与因果是两回事;共线性还会让重要性在相关特征间随机分摊(重训一次就变)。
小练习
- 把标签 horizon 改成 10(未来 10 日收益方向),对比 TimeSeriesSplit 与 Purged CV 的准确率差距,体会标签重叠的影响。
思路提示:改
make_label(df, horizon=10)与purged_ts_cv(..., horizon=10),差距变大说明清除生效;别忘了 K 份切分时样本数会减少。 - 删掉
mom20特征只留mom10,重训模型看重要性分布怎么变——mom10的重要性是否上升了?思路提示:共线性分摊被解除后,留下的那个特征重要性通常明显上升,即使模型整体性能变化不大。
- 用
sklearn.inspection.permutation_importance重算重要性,与feature_importances_对比,找出两者排名不一致的特征并解释。思路提示:permutation 在验证段上打乱特征看掉分,对共线性更稳健;不一致的特征往往正是共线性成员。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。