研究脚本最让人兴奋的时刻,是它"跑出了一个显著信号"。最让人心凉的时刻,是几天后发现这个信号完全是你脚本 bug 凭空造出来的。这篇文章记录的就是这样一个"差点被写进规则库"的假发现。
现象
一个研究脚本跑出了一个统计上显著的信号,各项指标都像模像样,团队一度准备把它写进规则库。脚本本身没有任何报错,数据来源也是可信的——看起来无懈可击。
根因
问题出在一个非常隐蔽的假设上:脚本假定数据源返回的记录是按时间正序排列的(最早的在前)。于是"取最近一条"的逻辑写成了:
recent = records[0] # 假设索引 0 是"最新"
但这个数据源实际是倒序返回的——索引 0 其实是窗口内最老的一条。于是脚本嘴上说着"取最近的记录",手上拿到的却是"很久以前的记录"。用它算出来的信号,自然是一个凭空造出来的假信号。
更麻烦的是,这个 bug 不会报错。它取到了一条合法、有效、字段齐全的记录,只是时间点完全错了。你从最终统计量上完全看不出来——只有当你去检查中间变量的时间戳时,才会发现异常。
解决
第一,永远不要假设外部数据源的排序。要么显式排序,要么显式取极值:
# 危险:隐式依赖数据源的返回顺序
recent = records[0]
# 安全:显式按时间取
records_sorted = sorted(records, key=lambda r: r["date"])
recent = records_sorted[-1] # 明确取最新
oldest = records_sorted[0] # 明确取最老
如果数据量大、不想排序,也可以用 max():
recent = max(records, key=lambda r: r["date"])
第二,也是更重要的:研究脚本必须做关键中间变量的打印抽查。不能只看最终那个统计量——要打印那些"如果错了会让结论崩掉"的中间量,比如:
dates = [r["date"] for r in records]
print(f"窗口最早: {min(dates)} 最晚: {max(dates)}")
print(f"取到的最新记录时间: {recent['date']}")
print(f"事件日占比: {event_ratio:.2%}")
# 断言:取到的"最近一条"应该真的接近窗口末端
assert (max(dates) - recent["date"]).days < 5, "取到的不是最新记录!"
对"取最老 / 取最新"这类操作,加一条断言几乎零成本,却能立刻拦下这类 bug。
延伸与预防
这个教训的通用价值在于:可疑的"发现",先抽查中间变量,再谈结论。 一个信号越是"显著得漂亮",越应该怀疑它是不是 bug 造出来的——真正稳健的信号往往是朴素的,而"天上掉下来的显著发现"多半有问题。
可以养成几个习惯:
- 任何跨窗口、跨源取数的逻辑,都显式排序,不依赖返回顺序;
- 打印时间范围(最早/最晚/事件日占比)作为每次研究的例行体检;
- 对关键假设写断言("取到的应该是最新"、"窗口应该对齐交易日历");
- 先在小样本上人工核对几行原始数据,确认字段和时间都对得上。
数据源的排序习惯是一个"隐式契约",而隐式契约最容易在换源、换接口、换版本时被打破。把它变成显式代码,是让研究结论站得住的低成本办法。