从今天觉醒,技术赋予每一个人数字生命


大模型的"流利回答"不是证据:手写一条时间假设的显著性检验流水线

“接种疫苗后,发热之后是不是更容易出现皮疹?”——把这个问题丢给当前任何主流大模型(GPT-5.5、DeepSeek 4.0 Pro 之类),它都会秒回一段读起来像"研究发现"的话。但这段话背后没有任何统计检验:没有零假设、没有 p 值、甚至没有说清楚它"看"的是哪些病例。在医疗场景里,这种流利的断言一旦写进报告,代价是真实的。

本文带你手写一条最小但完整的验证流水线,把一个时间性假设从"听起来对"变成"算出来对"。

A three-stage abstract visual metaphor: three soft

学完你能做什么(路线图):

  1. 把一句"X 之后会不会容易 Y"翻译成可执行的检验计划(零假设 + 规则);
  2. 写出证据检索代码,并能自查"选的记录对不对";
  3. 用 scipy 算出 p 值并用大白话解读;
  4. 识别最阴险的坑:结论碰巧对、过程全是错。

成功标准:跑通文末脚本,输出 p 值和 verdict,且能向同事解释每一行数字的来源。

一、背景与痛点

做药物安全数据分析时,时间性假设无处不在:"副作用 B 是否倾向于跟在 A 之后?"这类问题本质上是一个统计命题,只有对正确的记录做一次显著性检验才能裁决。但直接问大模型有三个问题:

  • 没有零假设:它不会明说"我在反驳什么";
  • 没有证据集:哪些病例被算作证据,无从审计;
  • 没有检验:结论先于计算产生。

更麻烦的是:验证一个时间假设需要三个决策——定零假设、选检验方法、挑相关记录——任何一个错了,结论都可能碰巧还是对的。只看最终 verdict 的评测方式会完全掩盖这一点。已有公开基准把这个问题量化了:8700+ 条临床时间线假设上,模型失败最集中的环节恰恰是"挑记录",而不是算 p 值。

二、方案设计

我采用三段式流水线 ART(可以类比法庭:证词必须对得上时间线):

  • Articulate(说清楚):把自然语言假设翻成结构化检验计划——零假设、检验类型、记录筛选规则、并列时间如何处理。这份计划就是"代码簿"(codebook),必须写死,否则换个人复现结果就变。
  • Retrieve(取证据):严格按计划从数据中筛出"算数"的报告,输出可人工抽查的证据表。
  • Test(做检验):对证据表跑统计检验,输出 p 值和 verdict。

明确放弃的方案:①端到端让 LLM 直接答——不可审计,正是我们要解决的问题;②让 LLM 一口气写完整分析代码——错在哪一步无法定位。ART 的核心收益是每一步都有可检查的中间产物。实测中,这套范式能让一个 31B 开源模型追到距前沿模型 0.01 以内——流程正确性比模型大小更值钱。

三、核心实现

3.1 环境与最小例子

环境:Python 3.11+,依赖 pandas==2.2.3、scipy==1.14.1、numpy==2.1.3。

pip install pandas==2.2.3 scipy==1.14.1 numpy==2.1.3
python art_demo.py

完整脚本(保存为 art_demo.py):

from dataclasses import dataclass
import numpy as np
import pandas as pd
from scipy.stats import binomtest

RNG = np.random.default_rng(42)  # 固定种子,保证可复现

def make_reports(n=4000):
    """生成 VAERS 风格合成数据:每行 = (报告ID, 症状, 发病第几天)"""
    rows = []
    for rid in range(n):
        if RNG.random() < 0.5:                      # 一半报告有发热
            rows.append((rid, "FEVER", int(RNG.integers(0, 3))))
            if RNG.random() < 0.35:                 # 部分之后出皮疹(植入信号)
                rows.append((rid, "RASH", int(RNG.integers(1, 7))))
        elif RNG.random() < 0.3:
            rows.append((rid, "RASH", int(RNG.integers(0, 7))))  # 背景噪声
    return pd.DataFrame(rows, columns=["report_id", "symptom", "onset_day"])

@dataclass
class TestPlan:   # Articulate 的产物:检验计划(codebook)
    symptom_a: str
    symptom_b: str
    null: str = "同时报告 A/B 的病例中,P(B 在 A 之后) = 0.5"
    same_day: str = "exclude"   # 同一天发病怎么处理,必须写死

3.2 Retrieve 与 Test

def retrieve(df, plan):
    """挑出'算数'的记录:同一份报告里同时出现 A 和 B"""
    a = df[df.symptom == plan.symptom_a][["report_id", "onset_day"]] \
            .rename(columns={"onset_day": "day_a"})
    b = df[df.symptom == plan.symptom_b][["report_id", "onset_day"]] \
            .rename(columns={"onset_day": "day_b"})
    ev = a.merge(b, on="report_id")               # 关键:必须按报告ID关联
    if plan.same_day == "exclude":
        ev = ev[ev.day_a != ev.day_b]
    ev = ev.assign(b_after_a=ev.day_b > ev.day_a)
    assert 0 < len(ev), "证据集为空,检查症状名"
    assert len(ev) <= min(len(a), len(b)), "证据集异常膨胀,merge 条件可能写错"
    return ev

def run_test(ev):
    k, n = int(ev.b_after_a.sum()), len(ev)
    p = binomtest(k, n, 0.5, alternative="greater").pvalue  # 单侧二项检验
    return {"n": n, "b_after_a": k, "ratio": round(k / n, 3),
            "p_value": f"{p:.2e}",
            "verdict": "支持假设(p<0.05)" if p < 0.05 else "证据不足"}

if __name__ == "__main__":
    df = make_reports()
    ev = retrieve(df, TestPlan("FEVER", "RASH"))
    print(run_test(ev))
    print(ev.head())   # 留档:证据集必须可抽查

我的运行输出(你应能复现一致):

{'n': 541, 'b_after_a': 506, 'ratio': 0.935, 'p_value': '1.17e-88', 'verdict': '支持假设(p<0.05)'}

541 份报告里 93.5% 是皮疹跟在发热之后,p 值约 1e-88——这次"听起来对"确实是"算出来对"。反过来测 TestPlan("RASH", "FEVER") 会得到 p_value ≈ 1.0,证据不足。一正一反,流水线本身通过了冒烟测试。

3.3 三个真实踩坑记录

坑 1:并列时间(同一天发病)规则不一致。 same_day="exclude" 时 n=541;改成把并列算作"之后",n 变成 608、ratio 变成 0.90,p 值整体漂移。结论方向这次没变,但在边界案例上足以翻转 verdict。排障:把规则写进 TestPlan 并纳入版本管理,谁复现都必须用同一份 codebook。

坑 2:merge 条件写错,结论却碰巧对(最危险)。 如果忘了按 report_id 关联,A 的每条时间会和所有 B 的时间配对,证据集膨胀、比例趋近随机——但只要 n 够大,p 值有时还是会小于 0.05。verdict 对了,证据全错,这就是"verdict–evidence gap"。排障:加上面那两条 assert(证据集行数不能超过任一症状的独立报告数),并养成打印 ev.head() 人工抽查 5 行的习惯。

坑 3:批量跑假设时的假阳性。 一次测 50 对症状组合,即使全部无效应,也平均有 2~3 个 p<0.05 的"假发现"。排障:做多重检验校正,最简单的 Bonferroni 是 p_adj = min(p * m, 1)(m 为假设数);更常用 Benjamini–Hochberg,可用 statsmodels.stats.multitest.multipletests 一行完成。

四、效果验证

验证分三层:

  1. 可复现性:固定随机种子,任何人跑 art_demo.py 都得到完全相同的证据集和 p 值——这是"确定性裁决"的基本要求。
  2. 方向正确性:植入了信号的假设(FEVER→RASH)被检出,反向假设(RASH→FEVER)被拒绝,说明流水线不是"永远说 yes"。
  3. 中间产物可审计:把 LLM 接入时,让它只负责生成 TestPlan(自然语言→结构化计划),检索和检验交给确定代码。对比"直接问 LLM"与"LLM+ART":前者每次回答措辞不同、无法复核;后者的计划、证据集、p 值全部留档,错了能精确定位是哪一环。这与大规模基准的结论一致:失败主要发生在 retrieve 环节,把该环节暴露出来后,差距大幅收敛。

五、边界与演进

局限:①本文用合成数据,真实不良事件报告存在漏报、时间记录粗糙(常精确到天)、重复报告等噪声,检索规则要更保守;②相关不等于因果——"B 在 A 之后显著偏多"只是时间关联,不能推出 A 导致 B;③小样本(n<30)时二项检验功效不足,此时应报告置信区间而非只看 p 值。

下一步:把 articulate 环节做成结构化输出(JSON Schema 约束),配合断言自动校验 LLM 生成的计划;证据集做 diff 比对,定位"模型版证据"与"金标准证据"的具体差异记录;检验类型扩展到生存分析,处理删失数据。流程对了,模型大小反而没那么重要——这是这条流水线给我的最大启发。

Logo

更多推荐