量化专用 pandas:未来函数的机械防线
两类未来函数,两把尺子——以及为什么第一版探测器漏掉了 bfill
p0.3 那道守卫能抓"大涨当日已持仓"。但它抓不到下面这行代码:
features = (returns - returns.mean()) / returns.std()
看起来只是个标准化,和未来毫无关系。实际上,这一行让 5661 个历史点都带上了它们当时不可能知道的信息——而且它不会让回测明显变好,只会让你的模型学到一些实盘不存在的规律。
这一节做一把更通用的尺子:扰动检测法。原理只有一句话,却能机械地抓出任何形态的特征泄漏。
顺便,我会把第一版探测器当场漏掉 bfill 的过程原样留给你看——因为那个失败,比成功更值钱。
查一个人有没有作弊,不必看懂他的解题过程。只需要:把标准答案改掉,看他的答卷会不会跟着变。
如果改了答案他的卷子就变了,那他一定看过答案——不管他嘴上怎么解释。
扰动检测法就是这个思路:改掉未来,看历史动不动。
一、先分清两类未来函数
这是本节最重要的认知,搞混了会让你在错误的地方找错误:
| 特征层泄漏 | 对齐层泄漏 | |
|---|---|---|
| 错在哪 | 特征值本身用了未来数据 | 特征干净,但用错了时点 |
| 典型 | bfill、center=True、全样本标准化 | 当日信号吃当日收益 |
| 谁能抓 | 本节的扰动探测器 | p0.3 的大涨探测器 |
| 能互相替代吗 | ❌ 不能 | ❌ 不能 |
实测可以证明这一点。把"当日信号未 shift"这个经典错误丢给扰动探测器:
align_1 当日信号未 shift(对齐层) 干净 干净 0/40 ✅
探测器说它"干净"——而且判得对。因为 rolling(5).mean() 的每一个值确实只用了当天及之前的数据。它的错不在特征,在于这条信号被拿去吃了当天的收益。
两把尺子各管一段。你的项目需要同时装两个。
二、扰动检测法
def _leak_at_cut(build_fn, close, base, cut, shock, tol):
perturbed = close.copy()
perturbed.iloc[cut:] = perturbed.iloc[cut:] * shock # 未来被彻底改写
after = build_fn(perturbed)
# 比较 cut 之前的部分有没有变
diff = ~np.isclose(base.iloc[:cut], after.iloc[:cut], atol=tol, equal_nan=True)
return int(diff.sum()), base.index[int(np.argmax(diff))] if diff.any() else None
判据:把 t 之后的数据全部改掉,如果 t 之前的特征值变了,这个特征一定用了 t 之后的信息。
三个好处:
- 黑盒可测:不需要读懂特征怎么算的,别人写的、库里来的,一样能测
- 无需真实标签:不用等实盘,不用做样本外,当场出结论
- 可进 CI:包成
assert_no_future_leak(),每次提交自动跑
第一版探测器只扫一个切点(70% 处),当场漏掉了 bfill。
原因:bfill 只往前泄漏一天。只有当切点恰好落在缺口旁边,那一天的泄漏才会跨过切点被看到。我造的缺口是每 17 行一个,70% 那个位置附近没有缺口——于是探测器判它"干净"。
修法:扫多个切点。改成在 20%~95% 之间均匀取 40 个切点,每个都测一次:
bad_2 bfill 后向填充 泄漏 3/40 个切点命中
bad_3 rolling(center=True) 泄漏 40/40
bad_4 全样本 z-score 泄漏 40/40
bad_5 月频 resample + bfill 泄漏 35/40
3/40 这个数字本身就是教学材料:短程泄漏极难被抓到。它每次只越界一天,却足以让你的模型偷看到明天。
这也再次印证 p0.3 的那条纪律:写完探测器,一定要用已知的坏例子验证它真的会响——否则你只是拥有了一个让你安心的摆设。
🔧 动手做:亲手种一个未来函数,再用探测器抓出来(6 分钟)
跑 python leak_detector.py。在你自己的特征函数里种一个经典未来函数——比如全样本标准化 (r - r.mean()) / r.std(),或者 bfill 后向填充——然后用本节的扰动检测法扫它。
你会看到(实测):全样本 z-score 在 40 个切点上 40/40 全部命中泄漏(它污染了 5661 个历史点);而 bfill 只 3/40 命中——短程泄漏极难抓,必须扫多个切点。
想明白:扰动检测法的原理只有一句——把未来的数据全改掉,如果过去的特征值跟着变了,它一定用了未来。它是黑盒可测的:不用读懂特征怎么算,别人写的、库里来的,一样能测。每写一个新特征,配一行 assert_no_future_leak,十秒钟省掉"三个月后发现整套研究白做"。
三、五个反面教材(全部实测)
| 写法 | 检出 | 命中切点 | 最大影响历史点 |
|---|---|---|---|
s.bfill() 后向填充 | 泄漏 | 3/40 | 1 |
rolling(21, center=True) | 泄漏 | 40/40 | 10 |
(r - r.mean()) / r.std() 全样本标准化 | 泄漏 | 40/40 | 5661 |
resample("ME").mean().reindex(..., method="bfill") | 泄漏 | 35/40 | 20 |
| 当日信号未 shift | (对齐层,本尺子不管) | 0/40 | 0 |
对应的正确写法,实测全部干净:
# ✅ 信号次日生效
raw.shift(1)
# ✅ 用扩展窗口标准化:t 时刻只用 t 及之前
(r - r.expanding(min_periods=60).mean()) / r.expanding(min_periods=60).std()
# ✅ 月度统计先 ffill 再 shift:月末的结论下个交易日才生效
monthly.reindex(close.index, method="ffill").shift(1)
为什么全样本标准化最危险
它是这五个里最隐蔽的,因为它在 ML 特征工程里极其常见——大家把它当"归一化",完全不觉得和未来有关。实测:
全样本 std = 0.015537 ← 含 2026 年才发生的波动
只用前 70% 的 std = 0.016847
两者差 7.78%
差 7.78% 看起来不多。但它的危害不是"让回测明显变好"——那样反而容易被发现。它的危害是:
它让特征的分布比现实更稳定,于是模型在样本内学到一些实盘根本不存在的规律。
你的回测不会好得离谱,只会好那么一点点——刚好到让你相信它的程度。这类泄漏在 P7 章做 ML 的时候会再次出现,那时你会庆幸手里有这把尺子。
四、装进你的项目
def assert_no_future_leak(build_fn, close, name="feature"):
leaked, where, n, hits = find_future_leak(build_fn, close)
assert not leaked, (f"{name} 使用了未来信息:{hits} 个切点上检出泄漏,"
f"最严重的一次影响 {n} 个历史点,最早出现在 {where}")
纪律:每写一个新特征,配一行断言。十秒钟的成本,换掉的是"三个月后发现整套研究白做"的风险。
📌 免责:本课为技术教学,本节所有计算基于历史行情数据用于方法演示,不构成任何投资建议。
这一节你拿到了第二把尺子,以及一个更重要的分类:
- 特征层泄漏(bfill / center=True / 全样本标准化 / resample+bfill)→ 扰动检测法
- 对齐层泄漏(当日信号吃当日收益)→ p0.3 的大涨探测器
- 两者不能互相替代,项目里要同时装
还有两个记忆点:3/40——短程泄漏极难抓到,必须扫多个切点;5661——一行全样本标准化能污染多少个历史点。
下一节我们碰价格本身的坑:复权、除权、停复牌、退市。这四个坑有个共同特点——它们不在你的代码里,在数据里。代码写得再干净,拿错口径的价格,结论一样是错的。
🔎 来源与核验· 3 条,点开核对
