← 返回目录
p1.2动手⏱ 约 17 分钟

量化专用 pandas:未来函数的机械防线

两类未来函数,两把尺子——以及为什么第一版探测器漏掉了 bfill

🔎 最后验证 2026-07📚 来源:8 个写法的扰动检测于 2026-07-31 在 ECS 实测(沪深300 5960 个交易日,40 个切点),输出见 code/outputs/stdout.txt🧰 pandas 3.0.5、numpy 2.5.1、Python 3.12.13
为什么学这个

p0.3 那道守卫能抓"大涨当日已持仓"。但它抓不到下面这行代码:

features = (returns - returns.mean()) / returns.std()

看起来只是个标准化,和未来毫无关系。实际上,这一行让 5661 个历史点都带上了它们当时不可能知道的信息——而且它不会让回测明显变好,只会让你的模型学到一些实盘不存在的规律。

这一节做一把更通用的尺子:扰动检测法。原理只有一句话,却能机械地抓出任何形态的特征泄漏。

顺便,我会把第一版探测器当场漏掉 bfill 的过程原样留给你看——因为那个失败,比成功更值钱。

💡 打个比方

查一个人有没有作弊,不必看懂他的解题过程。只需要:把标准答案改掉,看他的答卷会不会跟着变

如果改了答案他的卷子就变了,那他一定看过答案——不管他嘴上怎么解释。

扰动检测法就是这个思路:改掉未来,看历史动不动。

一、先分清两类未来函数

这是本节最重要的认知,搞混了会让你在错误的地方找错误:

特征层泄漏对齐层泄漏
错在哪特征值本身用了未来数据特征干净,但用错了时点
典型bfillcenter=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 之后的信息。

三个好处:

  1. 黑盒可测:不需要读懂特征怎么算的,别人写的、库里来的,一样能测
  2. 无需真实标签:不用等实盘,不用做样本外,当场出结论
  3. 可进 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 个历史点);而 bfill3/40 命中——短程泄漏极难抓,必须扫多个切点。

想明白:扰动检测法的原理只有一句——把未来的数据全改掉,如果过去的特征值跟着变了,它一定用了未来。它是黑盒可测的:不用读懂特征怎么算,别人写的、库里来的,一样能测。每写一个新特征,配一行 assert_no_future_leak,十秒钟省掉"三个月后发现整套研究白做"。

三、五个反面教材(全部实测)

写法检出命中切点最大影响历史点
s.bfill() 后向填充泄漏3/401
rolling(21, center=True)泄漏40/4010
(r - r.mean()) / r.std() 全样本标准化泄漏40/405661
resample("ME").mean().reindex(..., method="bfill")泄漏35/4020
当日信号未 shift(对齐层,本尺子不管)0/400

对应的正确写法,实测全部干净:

# ✅ 信号次日生效
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}")

纪律:每写一个新特征,配一行断言。十秒钟的成本,换掉的是"三个月后发现整套研究白做"的风险。

❓ 测验
你用扰动检测法测一个自己写的因子,40 个切点全部显示「干净」。可以下哪个结论?
✏️ 填空
用扩展窗口(expanding)而不是全样本做标准化,是为了保证 t 时刻只用到 t 及 ___ 的数据。

📌 免责:本课为技术教学,本节所有计算基于历史行情数据用于方法演示,不构成任何投资建议。

✅ 小结

这一节你拿到了第二把尺子,以及一个更重要的分类:

  • 特征层泄漏(bfill / center=True / 全样本标准化 / resample+bfill)→ 扰动检测法
  • 对齐层泄漏(当日信号吃当日收益)→ p0.3 的大涨探测器
  • 两者不能互相替代,项目里要同时装

还有两个记忆点:3/40——短程泄漏极难抓到,必须扫多个切点;5661——一行全样本标准化能污染多少个历史点。

下一节我们碰价格本身的坑:复权、除权、停复牌、退市。这四个坑有个共同特点——它们不在你的代码里,在数据里。代码写得再干净,拿错口径的价格,结论一样是错的。

下一节 → 价格序列的四个陷阱:复权、除权、停复牌、退市
🔎 来源与核验· 3 条,点开核对
本节每个关键论断都对应一个可追溯的来源 —— 这是本课程"靠谱、不过时"的底线。
「扫 40 个切点后 8 个写法全部分类正确:bfill 3/40 命中、center=True 40/40、全样本 z-score 40/40(最大影响 5661 个历史点)、resample+bfill 35/40;三个正确写法与「对齐层」案例均 0/40」
📚 本节 code/leak_detector.py,2026-07-31 于 ECS 实测(沪深300 2002-01-04~2026-07-30,5960 个交易日;2002 起为数据商回填,早于 2005-04-08 官方基日,故交易日数多于 p0.1 的 5177),输出见 code/outputs/stdout.txt✓ 已核验 2026-07
「第一版探测器只扫单一切点(70%)时漏检 bfill——因 bfill 仅向前泄漏一天,需切点恰好落在缺口旁」
📚 本节 code/leak_detector.py(第一版单切点漏检 bfill 的失败原样保留在代码注释 L47 与 L167 的 print 中)✓ 已核验 2026-07
「沪深300 日收益全样本 std=0.015537,前 70% 样本 std=0.016847,相差 7.78%」
📚 本节 code/leak_detector.py 实测✓ 已核验 2026-07
智图软件的赞赏码
都看到这了,打个赏呗!
接下来 · p1.3
价格序列的四个陷阱
继续读下一节 →