因果与模拟
判定内核回答「变化超出噪声带了吗」, 不回答「为什么」。归因给出的是确定性分解——那是描述,不是因果主张: 说「华东贡献了 −92%」,不等于说「是华东导致了变化」。这页讲两件事: 因果升级梯(默认停在描述层,条件逐条实测全部满足才升级到能给出反事实估计的设计), 以及 what-if(同一条判定内核在假想数字上的重判)。 全部纯函数,零 LLM;因果绝不改变触发判定——它是附录,不是前置。
描述不是因果主张
「本月放款额跌了 12%,华东贡献了其中 92%」——这句话是恒等式分解, 在任何意义上都成立,也完全没说「是华东引起了下跌」。 可能华东只是体量大(同样的跌幅自然贡献最多),也可能两地同跌而华东只是先跌。 把分解当原因用,是数据产品最常见的越界。
因果升级梯要做的,是把「从描述走到反事实估计」变成一条有条件的梯子: 默认停在 L1(只做确定性分解);只有当清单上的条件逐条可判定且全部满足, 才升级到 L2 差中差(DiD)或 L3 合成对照。三条铁律贯穿全篇: ① 条件满足才升级,不满足诚实降级回 L1; ② 算不出 ≠ 平行(检验做不出就不升级,与「算不出 ≠ 在带内」同一条纪律); ③ 每条结论都带非空的假设清单,「无法由数据检验」的条目写出来而不是省略。
flowchart TB
T["触发(已超出噪声带)"]:::entry --> C{"条件清单逐条实测"}:::gate
C -->|"任一未过"| L1["L1 确定性分解
描述,不主张因果
unmet 各带实测值 / 阈值"]:::exec
C -->|"C1–C5 全过"| L2["L2 差中差 DiD
ATT =(T_post−T_pre)−(C_post−C_pre)"]:::exec
L2 -->|"mode=auto 且 C6 供体拟合通过"| L3["L3 合成对照
等权供体 · 比值外推"]:::exec
L2 -->|"C6 未过"| S2["诚实停在 L2
unmet 写明卡在哪"]:::entry
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef exec fill:#14b8a614,stroke:#14b8a699,stroke-width:1.5px;
classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px;
linkStyle 2 stroke:#f59e0b,stroke-width:1.5px
条件清单:每一条都带实测值与阈值
升级判定是一个纯函数
causal.py:305 ladder_decision:
所有条件在调用前已被实测,函数只做「过/不过」的折叠。不过时返回 unmet 清单,
每条 {condition, reason, measured, threshold}——
例如 C1: insufficient_blocks(实测 5 / 阈值 8)。
| 条件 | 要求 | 不过时的 reason 举例 |
|---|---|---|
C4 块对齐 | 声明了 seasonal、有时间字段、块规格可规划、前窗有值(结构性,先于一切取数) | no_seasonal / no_pre_block |
C1 稳定基线 | 历史有值块数 ≥ MIN_BLOCKS(8) | insufficient_blocks(带实测与阈值) |
C2 对照可构造 | 对照组序列 pre/post 齐备 | control_unavailable / control_data_missing |
C5 预算 | 因果阶段两条取数的额度(让路时记账) | query_budget_exceeded |
C3 前窗平行 | placebo:max|did| ≤ tolerance × scale | placebo_unverifiable / placebo_not_parallel(带 max|did| 与阈值) |
C6 供体拟合 | 仅 mode=auto:合成对照前窗 index 偏差 ≤ tolerance——只挡 L3 | fit_too_poor / treated_unidentified(带拟合偏差与容差) |
条件之间存在取数顺序,顺序即记账顺序:C4/C1 不过(块都建不出来或太少)时 不花第二条 SQL;预算不够时两条都不花、让路记账。 代价上界因此是明确的:至多 2 条取数(处置组一条 + 对照帧一条),两条都成功才记账。
还有一条容易被当成装饰、实际是审计核心的要求:假设清单永远非空
causal.py:379 assumptions_for。
每条假设附 checked:True = 在本级被检验且通过;
None = 数据无法检验——写出来而不是省略。
比如 L2/L3 都有的「对照单元未受同一干预影响(数据无法检验)」,
它永远以 checked=None 出现在结论旁边。「无法检验」本身就是审计事实。
placebo 前窗:唯一不发明分布的噪声来源
DiD 的全部输入是四个数:ATT = (T_post − T_pre) − (C_post − C_pre)
effect.py:59 did_2x2。
四个数没有残差自由度——教科书的模型标准误在这里无从谈起。Trove 的做法是:
只承认一种噪声,即前窗上反复做 placebo(把历史相邻块对假装成
「干预前/后」做同样的 2×2)得到的漂移散布
causal.py:119 placebo_pairs。
四值齐备才算一对,缺值的块跳过而不是补零——补出来的对会稀释
散布,而散布正是唯一噪声来源;少一对只是少一个样本,补一个假的却会污染全部结论。
由此得到两件东西,措辞都经过刻意收紧:
did_secausal.py:188 did_se = placebo 漂移的标准差(≥2 对,ddof=1),源码明标 「非模型标准误」——它是漂移散布的粗噪声估计,不是一个假装有理论的量;crosses_zerocausal.py:202 crosses_zero 用 ±2·se 的朴素带判断效应是否跨零,不冒充置信区间; se 缺失 →None(判不了),绝不返回False装「不跨零」。
平行检验本身也是三态的
causal.py:151 parallel_trends:
scale(取 max(|T_pre|, |C_pre|))为 0 或缺失 → zero_scale;
一对都没有 → no_placebo_pairs;placebo=None(检验整体做不出)→
梯子直接停住,reason placebo_unverifiable。算不出 ≠ 平行——
这和统计侧「算不出 ≠ 在带内」是同一条纪律换了个场景。检验不过时,
返回体带着 max_abs_did 与 threshold,「差多少才算过」照例可见。
L3 合成对照:无优化器版
没有现成对照组时,用「未受干预的同维单元」合成一个
causal.py:213 donor_counterfactual。
四步全部手算:① 前窗 index 拟合——各块按前窗均值归一化,
fit = max_j |T_j/T̄ − D_j/D̄| ≤ tolerance 才准升级;
② 反事实——cf_post = T̄ × (D_post/D̄)(供体当期的整体变动率外推到处理组);
③ 效应——effect = T_post − cf_post;
④ 数据不齐的供体进 skipped 不参与,一个供体都不剩 → no_donors 不升级。
这是刻意的最简可解释版:等权供体平均 + 比值外推,没有权重求解。 合成对照的经典做法(权重优化器)需要数值优化,超出「零第三方依赖、可复算」的 边界——verdict 是不可编辑的审计记录,反事实估计必须能从证据里的数字复算出 同一结果,优化器的收敛细节做不到这一点。
供体池的构造在取数侧完成,有一处「过滤手术」值得单说
causal_source.py:80 donor_plan:
处理组的身份就写在 subject 的等值过滤里(region=华东)。
按维分组取对照帧时必须摘掉钉在对照维上的等值过滤——
不摘,分组结果只剩处理组自己一行,对照与供体池都不存在;
摘掉后对照组 = control.value 那一行,供体池 = 其余行,
并排除被 subject 钉住的处理组标签(供体受同一干预,合成对照就不成立)。
一颗钉子都没有(处理组 = 整个总体)→ treated_unidentified:
此时任何供体都是处理组的子集,L3 诚实不可用。
what-if:同一条内核,同一份数字
「如果华东再降 10%,这条规则会响吗」——模拟的第一纪律是零查询:
数据来源只有两条,重放最近一条 verdict 的 evidence.rows
(判定当时到底在哪些数字上判的),或调用方直接给的 maps。
绝不为了「如果…会怎样」去查业务库——再取一份数字,判的就不是同一个东西了;
模拟的价值在于同一条内核(judge)在同一份数字上重跑。
flowchart LR
V["最近一条 verdict
evidence.rows"]:::entry --> S["场景解析 parse_scenario
pct / abs / set · 未知键报错"]:::exec
S --> A["假想数字 apply_adjustments
unapplied 记账"]:::exec
V --> J1["judge(原数字)"]:::gate
A --> J2["judge(假想数字)"]:::gate
J1 --> F["flip:none / fired / cleared"]:::entry
J2 --> F
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef exec fill:#14b8a614,stroke:#14b8a699,stroke-width:1.5px;
classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px;
另外两条纪律与升级梯同源:
| 纪律 | 在哪体现 | 为什么 |
|---|---|---|
| 没模拟到的要写出来 | unapplied 带原因:unknown_dim(点名了重放数据里没有的组)/ missing_value(该侧本来没值,pct/abs 算不动;set 例外) |
少模拟了两项的结论与完整结论,在输出上必须可分辨,否则「模拟过了」与「模拟的是别的场景」长得一样 |
| 不装能算 | simulate_tree:非加性节点(比率/乘法/均值)标 not_modeled,父级一起不可算;total=None 是合法且有信息量的结果 |
子项变化量之和 ≠ 父变化量时,给一个数就是造恒等式——与分解侧 decomposable 纪律同源 |
| 带位只重放不重算 | verdict 行卡上带着当时的 confidence/gated 原样参与两次判定;响应以 degraded 明说 replayed_not_recomputed(有带位)或 not_simulated(没有) |
假想数字没有历史带可站;「模拟里触发」不许被读成「带也确认了」 |
还有一个安静的选择:没有带位数据时(调用方模式、或老 verdict 没记),
require_gate 不施加——假装带没确认会把所有组判成「不触发」,
读起来像「这条规则永远不响」,比不给模拟更糟。此时翻转是条件级的,
degraded 立刻说明。场景解析本身是闭集校验
whatif.py:54 parse_scenario:
未知键、未知 field/mode、非数值 value 全部在解析期抛 WhatIfError 并列出合法键——
静默忽略一条调整,会让「模拟过了」与「模拟的是别的场景」看起来一样。
怎么用
在规则里声明 causal(schema v4)。前置:规则必须有 seasonal
(块规格,因果复用同一套块语义),且 subject 用等值过滤把处理组钉到维值粒度:
seasonal: # 因果的块规格(复用噪声带声明)
grain: month
lookback: 12
causal:
mode: auto # auto = L2 过后再试 L3;did = 只到 L2
control: # 恰一种形态,解析期强制
dim: region # 形态一:对照 = 该维取 value 的那一行
value: 华北 # 供体池 = 该维其余取值
# filters: # 形态二:窄口径序列(供体池不存在,L3 不可用)
# - {field: product, op: "=", value: 车贷}
placebo_blocks: 4 # 前窗相邻块对(k 对需要 k+1 个块)
tolerance: 0.1 # 相对容差(相对前窗水平量级)
触发后的证据里随之多出 causal 节:rung(L1/L2/L3)、
unmet、assumptions、treated / control(含各自 SQL)、
placebo、synthetic、degraded——全部进 verdict 审计行。
通知缀由
causal.py:425 causal_line
生成,仅 L2/L3 且有效应时缀上(L1 拒绝主张的效应不上通知):
「净效应:+1,234.00(DiD·区间不跨零)」——crosses_zero 为 None 时后缀是
「噪声估计不足」,限定语
causal.py:80 CAUSAL_NOTE
(「条件式反事实估计,不是实验结论;不进入触发判定」)在证据节里。
what-if 走管理端 API POST /v1/admin/decisions/{rule_id}/simulate
decisions.py:429 simulate:
body 传 scenario([{dim, field, mode, value}, …],空数组 = 只重放),
响应含 before/after 两次判定、applied/unapplied、
flip 与一句话摘要。模拟不写进判定历史——推测性评估不污染审计线。
取舍
| 选择 | 放弃了什么 | 为什么值得 |
|---|---|---|
| 默认停在 L1,不主张因果 | 「自动给出原因」的产品话术 | 把分解当原因用是这一层最不能出的错。宁可大多数时候只给描述——描述至少是真的 |
| 2×2 手算 + placebo 漂移当唯一噪声 | 模型标准误、置信区间的完整话术 | 四个数没有残差自由度,标准误无从谈起。±2·se 朴素带 + 明标「非模型标准误」,不发明分布 |
| 等权供体平均(无优化器) | 更贴合的权重合成、更好的拟合优度 | 零依赖 + 可复算:verdict 必须能从证据数字复算出同一结果,数值优化做不到这一点 |
| 算不出 ≠ 平行 | 「检验没过但看起来还行」时照常升级 | 少升级优于假升级。检验做不出与检验不过,是两种诚实的停法 |
| 假设清单永远非空,含「无法检验」 | 更「干净」的结论呈现 | 「对照未受同一干预影响」永远无法由数据检验——但读者必须看到它,而不是被省略掉 |
| what-if 零查询 | 对任意假设重新取数、模拟更大胆的场景 | 判的必须是同一份数字;场景越不出格,结论越可信。unapplied 如实记账替代静默丢失 |
| 取数顺序即记账顺序 | 「先把两条都查了再判断」的简单写法 | 结构性条件不过就不花第二条 SQL——预算纪律让因果阶段(附录)永远拖不垮正文 |
优势在哪
- 条件式、可审计。每条 unmet 带实测值与阈值,「卡在哪、差多少才算过」一眼可见——不是一句「条件不足」。
- 同一把尺子。升级梯的 2×2 与效果验收(闭环与契约)的净效应估计是字面同一份实现(
effect.py)——「判定看到的净效应」与「验收算出的净效应」不可能对不上。 - 诚实降级链。编译 MISS、SQL 报错、数据不足、预算让路——全部只进
degraded,绝不上抛、绝不改triggered;附录永远是附录。 - 可复算、零依赖。反事实估计只用手算公式与 stdlib;模拟从证据重放——两件事都不需要再碰数据库,也不需要相信第二次计算结果。
- 假设显式化。每级结论都带假设清单,可检验的附实测细节,不可检验的以
checked=None出现——审计看得到「我们站在什么上面」。