一位 五十亿 规模 私募 多 策略 基金 的 初级 研究员 周四 下午 走 进 代码 评审 室,端 着 一 个 看 起来 像 大 胜 的 信号 —— 中证 500 上 的 5 日 反转 策略,夏普 2.8,最 大 回撤 4%,2014-2023 年 区间 上 的 净 值 曲线 漂亮 得 不 像 话。资深 研究员 翻 着 notebook,问 了 一 个 问题,信号 当场 死 在 桌 上:"你 的 训练 与 测试 窗口 怎么 划 的?" "2014-2018 训练,2019-2023 测试。" "为什么 不 包含 2008?" "我们 用 后 金融 危机 数据 —— 更 相关。" "那 2015 股灾 呢?" "在 测试集 里,但 模型 没 真 看 见,因为 模型 每 季度 重 拟合。" "在 测试集 上?" 沉默。信号 不是 纪律 失败,分割 才 是 纪律 失败。2015 股灾 因 "不 代表 性" 被 排 除 在 测试集 之外,模型 重 拟合 时 数据 又 跨 入 测试 窗口。四 大 经典 泄漏 模式 中 的 regime bias 与 snooping bias 一 起 出 场。L1 安装 的 是 工作流;L2 把 数据 纪律 的 机制 讲 精确。本 节 课 结束 后,你 应当 能 读 一 个 回测 设计 并 给 它 包含 的 每 一 种 泄漏 模式 命 名。
三 个 分区
第一 项 词汇 纪律:时序 数据 沿 时间 轴 切 成 三 块,按 此 顺序。
1. training set - in-sample window; the researcher iterates freely; typically 60-70%
2. validation set - intermediate held-out slice for hyperparameter selection; typically 10-20%
3. test set - out-of-sample window touched exactly once at end; typically 20-30%
典型 的 60 / 20 / 20 或 70 / 15 / 15 是 经验 比例;结构 性 规则 更 硬:时序 顺序 严格 —— 测试集 是 时序 最 晚 的 一 段,绝不 是 随机 切 出 来 的 一 片。对 金融 时序 做 随机 k 折,将 通过 序列 相关、regime 持续 与 标签 重叠 三 条 路径 把 未来 信息 偷 进 训练集;时序 顺序 才 能 阻 断 它。
训练集 是 研究员 被 允许 偷看 的 地方。画 分布、算 摘要 统计、拟合 候选 模型、调 超 参数、把 明显 跑 不 通 的 变体 扔 掉。验证集 是 选 超 参数 的 地方(验证集 表现 最 好 的 候选 进 下 一 步)。测试集 是 在 项目 末尾 把 选 定 模型 评估 恰好 一 次 的 地方;评估 没 过 线,项目 即 结束。
四 种 数据 分割 策略
四 种 经典 策略,按 复杂 度 升 序。决策 规则:99% 的 时候 用 简单 时序 留 出;模型 在 生产 中 重 训 时 用 walk-forward;regime 稳定 且 多 数据 一定 更 好 时 用 expanding-window;只有 当 简单 时序 留 出 给 出 的 测试 样本 太 少 时 才 用 purged k-fold。
1. simple temporal hold-out - cleanest; 99% of projects; train on first 70-80%, test on last 20-30%
2. walk-forward backtest - most production-realistic; re-fit at each step on `[t-L, t]`, evaluate on `[t, t+H]`
3. expanding-window backtest - training window grows monotonically; stable regimes
4. purged + embargoed k-fold CV - Lopez de Prado; when labels overlap
simple temporal hold-out 是 最 干净 的 分割,99% 的 项目 都 应 该 优先 用 它。2014-2021 训练、2022-2023 测试;测试集 从 项目 开始 即 冻结,恰好 触碰 一 次。它 的 干净 来自 简陋:没有 巧 妙 的 数据 切片、没 有 purging 逻辑、没 有 embargoing 规则 可以 写 错。复杂 CV 方案 的 bug 会 静默 泄漏;时序 留 出 要么 显 眼,要么 没 错。
walk-forward backtest 在 每 一 步 t 用 滚动 窗口 [t-L, t] 重 拟合 模型,在 [t, t+H] 上 评估。它 是 最 贴 近 生产 的 策略,因为 模型 重 拟合 时 用 的 数据 与 实盘 在 每 一 个 时间 点 上 真 实 可 用 的 数据 一 致 —— 仿真 的 策略 等 于 部署 的 策略。当 生产 模型 本身 会 重 训(例如 因子 模型 每 周 重 拟合)时,回测 必须 跟 上 这 个 机制。
expanding-window backtest 是 walk-forward 的 表 亲,训练 窗口 单 调 递 增 —— 始终 使用 t 之前 的 全 部 数据。当 多 数据 一 定 更 好 且 regime 稳定 时 首 选;A 股 基本面 因子 研究 在 较 长 的 Wind 万得 数据 区间 上 经常 用 这 个 模式。
purged + embargoed k-fold CV 是 Lopez de Prado 在 《Advances in Financial Machine Learning》ch. 7 的 贡献。时序 切 成 k 个 连续 块;每 一 折 评估 测试 块,把 与 测试 块 相邻 的 训练 块 里 标签 窗口 与 测试 块 重叠 的 样本 purged 掉,再 在 测试 块 与 训练 块 之间 插 入 embargo 区域 阻 断 序列 相关 泄漏。这 个 策略 只 在 简单 时序 留 出 给 出 的 测试 样本 太 少 时 用 —— 例如 标签 是 90 日 前 向 收益、时序 又 短。
purged + embargoed k-fold 规则 紧 凑 形式:
train_label_excluded if |label_end_train - label_start_test| < label_horizon
OR (label_start_train - test_end) in [0, embargo_size]
embargo_size = max(0.01 * N_total, label_horizon)
embargo-size 公式 的 KaTeX 形式:
label-end / label-start 算术 抓 的 是 前 向 收益 标签 的 计算 窗口 与 测试 块 重叠 的 样本。Embargo 区域 抓 的 是 序列 相关 泄漏:测试 块 后 一 个 交易 日 的 标签 与 测试 块 的 标签 仍 有 高 自 相关,把 它 放 入 训练 等 于 实质 在 测试集 上 训练。
四 种 泄漏 模式
金融 研究 中 所有 泄漏 都 落 在 这 四 个 桶 里,按 此 顺序,附 典型 夏普 通胀 区间:
1. look-ahead bias - a feature uses future data; inflation 1.5-3x
bug example: a centered moving average (uses t+5 to compute the feature at t)
2. survivorship bias - universe excludes delisted names; inflation 1.5-2x
bug example: today's index universe used to backtest the 2010-2023 window
3. snooping bias - analyst saw the test set during exploration; inflation 1.5-2x
bug example: repeated test-set touches; hyperparameters tuned to validation residuals
4. regime bias - train and test in the same regime; inflation 1.2-2x
bug example: GFC excluded from test, train and test both post-crisis low-rate
look-ahead bias 是 最 常见 的 新手 错误:时刻 t 的 特征 不 小心 使用 了 时间戳 大 于 t 的 数据。教科书 案例 是 居中 移动 平均 —— pandas 的 .rolling(window=5, center=True).mean() 看 入 未来 两 天。修复:每 一 个 特征 必须 只 用 timestamp <= t 的 数据,写 入 代码 断言。
survivorship bias 是 4.1.1 L4 的 universe 错误。今日 沪深 300 universe 只 包含 幸存者;用 今日 成分股 回测 2010-2023 区间 系统 性 地 排除 输 家 —— 2007-2008 损失 惨 重 的 银行 股、2015 股灾 后 被 ST 的 名 单、2018 年 后 退市 的 创业板 尾部。修复:使用 4.1.1 L4 的 point-in-time universe universe(date, symbol),反映 每 一 个 历史 日期 真 实 在 指数 里 的 名 单。
snooping bias 是 L1 的 工作流 失败 —— 分析师 在 探索 过程 中 看 过 测试集 并 无 意 间 把 超 参 调 到 测试集 上。每 一 次 测试集 重 触 都 是 +1 进 入 L3 的 多重 检验 修正 的 有效 N。修复:data/test/ 的 文件系统 权限;test_set_seal.lock 文件;站会 纪律 问 "测试集 看 了 吗?"。
regime bias 是 最 隐 蔽 的 模式。2014-2021 训练 / 2022-2023 测试 的 切 法 看 上 去 诚实,但 两 段 都 大致 落 在 后 金融 危机 低 利率 régime 里;模型 从 没 见 过 真正 的 加息 周期。修复:测试 窗口 必须 至少 包含 一 个 危机 或 régime 转 换。对 A 股 2014-2023 的 数据,那 意味 着 2015 股灾、2018 trade-war drawdown、2020 疫情 drawdown、2022 房地产 drawdown 至少 有 一 个 必须 在 测试集 里。教科书 反 面 案例 —— "2010-2017 训练,2018-2021 测试,避 开 2015,Sharpe 2.5!" —— 就 是 regime bias 的 自 白。2015 股灾 与 2020 / 2022 episode 是 数据集 中 最 信 息量 大 的 时段;排除 它们 等 于 选择 不 测试 模型。
代码:时序 train / test 分割
def temporal_train_test_split(df, test_fraction=0.2):
"""按 时间 顺序 把 索引 为 时间 的 DataFrame 切 成 train 与 test。
函数 把 时序 最 晚 的 一 段 作 为 测试集 返回,绝 不 随机 切片。
若 索引 不 单调 递增,抛 ValueError。
"""
if not df.index.is_monotonic_increasing:
raise ValueError("index must be monotonically increasing for a temporal split")
n = len(df)
split = int(n * (1 - test_fraction))
train_df = df.iloc[:split]
test_df = df.iloc[split:]
return train_df, test_df
函数 签名 跨 区域 字节 一致:temporal_train_test_split(df, test_fraction=0.2) 返回 (train_df, test_df),当 时序 索引 被 排序 或 合并 破坏 时 抛 ValueError('index must be monotonically increasing for a temporal split')。"字节 跨 区 一致" 规则 反映 函数 是 工程 件 而 非 叙述 件;docstring 翻译,函数 体 共用。
代码:purged + embargoed k-fold
def purged_kfold_split(df, label_horizon, n_splits=5, embargo_fraction=0.01):
"""Lopez de Prado, Advances in Financial Machine Learning ch. 7.
每 一 折 yield (train_indices, test_indices) 满足:
(a) k 个 测试 块 是 连续 时序 分区;
(b) 训练 标签 的 标签 窗口 与 测试 块 重叠 的 样本 被 purged;
(c) 测试 块 之后 插 入 max(embargo_fraction * len(df), label_horizon) 的 embargo 区域。
"""
import numpy as np
n = len(df)
embargo = max(int(embargo_fraction * n), label_horizon)
block_size = n // n_splits
for k in range(n_splits):
test_start = k * block_size
test_end = (k + 1) * block_size if k < n_splits - 1 else n
test_indices = np.arange(test_start, test_end)
purge_start = max(0, test_start - label_horizon)
purge_end = min(n, test_end + label_horizon)
embargo_end = min(n, test_end + embargo)
train_indices = np.array(
[i for i in range(n)
if i < purge_start or i >= embargo_end]
)
yield train_indices, test_indices
函数 签名 与 行为 跨 区域 字节 一致。label-horizon 参数 对应 前 向 收益 计算 窗口(5 日 标签 取 5、月 频 标签 取 21);embargo 取 一 个 标签 horizon 或 1% 的 数据集(取 较 大 者),是 阻 断 测试 块 与 相邻 训练 标签 之间 序列 相关 泄漏 的 关键。
泄漏 检测 清单
每 一 个 项目 在 写 报告 之前 执行 这 张 五 项 清单。任何 一 项 不 过 即 重新 跑 修复;清单 是 测试集 触碰 一 次 规则 的 工程 落地。
(i) every feature at time t uses only data with timestamp <= t (look-ahead check)
(ii) the universe is universe(date, symbol) from 4.1.1 L4, not today's index (survivorship check)
(iii) the test set has been touched zero times during exploration (snooping check)
(iv) the test window includes at least one crisis period (regime check)
(v) for overlapping labels, purging + embargoing is applied (label-overlap check)
规则:每 一 个 项目 在 写 报告 之前 执行;任何 一 项 不 过 即 重新 跑 修复。五 项 检查 沿 下游 串 起 —— (i) 接 L4 复现 性 规则 即 特征 流水 线 必须 可 从 种子 重 生;(ii) 接 4.1.1 L4 的 point-in-time universe;(iii) 接 L1 预登记 文档 把 测试 窗口 从 头 上锁;(iv) 接 L3 多重 检验 修正 由 regime 过 拟合 实际 收 窄 推断;(v) 接 walk-forward 与 purged k-fold 作 为 生产 模式。
纪律 口号
测试集 从 项目 开始 即 留 出,最 多 触碰 一 次;要 迭代 用 验证集;验证集 用 尽 了 就 加 数据,绝 不 重 用 测试集。L1 的 四 个 执行 层(工程、文化、代码 评审、统计)在 这 里 落 在 测试集 上锁 上;L1 实验 日志 里 的 试验 计数器 是 L3 多重 检验 修正 的 输入;L4 把 整 个 栈 与 git commit SHA 绑 定,全 链 可 审计。
Formula Explorer
\text{embargo} = 0.01 \cdot N - \text{label\_horizon} + \text{label\_horizon}样本外 评估 的 夏普比率 必须 扣 交易 成本 计算;针对 基准(中证 500、沪深 300 或 行业 中性化 因子 栈)的 信息比率 把 超额 收益 分解 出 来;策略 的 最大回撤 与 Sharpe 一 起 报告;测试 窗口 的 Alpha 衰减 —— 信号 预测 力 在 测试 区间 内 的 衰减 速度 —— 是 抓 "夏普 来 自 一 次 régime 对 齐 而 不是 持久 edge" 的 诊断;因子模型 的 归因(多 少 是 价值、质量、动量、低 波)告诉 你 这 个 "信号" 是不 是 已知 因子 的 伪装;动量 暴露 是 横截面 信号 的 经典 混 杂;测试 窗口 权重 进入 生产 簿 的 均值方差优化 在 下游 4.4 里;约束 化 组合 簿 的 组合优化 同 在 下游;中国 证监会 与 AMAC 中国 证券 投资 基金业 协会 信息 披露 要求 的 监管 级 压力测试 场景 闭 合 监管 端 的 链 路。
练习
Exercise
你 正在 评估 一 个 510300 沪深 300 ETF 上 的 5 日 动量 信号,10 年 区间 是 2014-01-01 to 2023-12-31。做 四 项 计算,按 表 报告 答案。
(i) 用 temporal_train_test_split 配 test_fraction = 0.2,报告 train 与 test 的 日期 区间。
(ii) 找 出 你 数据 区间 内 的 三 个 经典 危机 时段(2015 股灾 / 2018 trade-war drawdown / 2022 疫情 drawdown),针对 80 / 20 时序 留 出 给 出 每 一 个 危机 落 在 train、validation 还 是 test 分区,用 一 句话 论证 每 一 个 危机 为 什么 应 该 留 在 它 被 分 到 的 分区。
(iii) 对 5 日 前 向 收益 标签 实现 并 跑 purged_kfold_split(df, label_horizon=5, n_splits=5, embargo_fraction=0.01);报告 embargo 区域 的 交易 日 数 与 第一 折 被 purged 的 训练 标签 数。
(iv) 对 四 种 泄漏 模式(look-ahead、survivorship、snooping、regime)每 一 种 给 出 你 在 这 个 项目 上 会 应用 的 一 项 具体 工程 或 流程 修复(例如:look-ahead 用 assert all(feature_timestamps <= label_timestamps);survivorship 用 4.1.1 L4 的 universe(date, symbol);snooping 用 data/test/ 的 文件 系统 权限;regime 用 verify test window contains at least one crisis period)。
把 全部 四 个 答案 报告 在 一 张 表 上。
提示
提示
参考 卡
本 课 装 配 的 组件,按 序:
- Inline-code listing —— 三 个 数据 分区(training / validation / test)。
- Inline-code listing —— 四 种 经典 时序 数据 分割 策略。
- Inline-code listing —— 四 种 经典 泄漏 模式 与 典型 Sharpe 通胀 区间。
- Fenced ```text block —— purged + embargoed k-fold CV 规则 与 embargo-size 公式。
- Fenced ```python code block ——
temporal_train_test_split(df, test_fraction=0.2)。 - Fenced ```python code block ——
purged_kfold_split(df, label_horizon, n_splits=5, embargo_fraction=0.01)。 - Inline-code listing —— 五 项 泄漏 检测 清单。
- Exercise —— 5 日 动量 信号 四 项 分割 评估,配 两 条 渐进 Hint。
- FormulaExplorer —— embargo-size 规则。
下 一 课
下 一 课 「多重 检验 与 p 值 黑客 行为」把 L1 开 出 的、L2 保护 的 试验 计数器 转 成 量化 "报告 出 来 的 夏普 中 真正 真 的 那 部分" 的 统计 修正。你 将 看到 为 什么 N=50 候选 信号 筛 出 的 夏普 2.0 大约 等 于 零 假设 期望,不 是 真 信号 的 强 证据;学 到 Bailey-Lopez de Prado 分解、Bonferroni 与 Benjamini-Hochberg 修正、以及 通缩 夏普比 (DSR)。本 课 学 到 的 四 种 泄漏 模式 都 映射 到 N —— 每 一 次 测试集 重 触 +1 实际 试验 数,每 一 次 régime 过 拟合 缩 窄 推断 窗口,每 一 个 look-ahead 特征 抬高 估计量 的 零假设 方差。L3 把 统计 纪律 闭 合。