首页/设计深潜/因果与模拟

因果与模拟

判定内核回答「变化超出噪声带了吗」, 不回答「为什么」。归因给出的是确定性分解——那是描述,不是因果主张: 说「华东贡献了 −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
图 1 · 升级梯:不满足条件时不是「条件不足」一句话,而是逐条给出实测值与阈值——「差多少才算过」是审计的一部分。 上面这段以源码显示:图渲染库没加载起来(离线、CDN 被拦或浏览器禁用了 JavaScript 时会这样)。这段 Mermaid 文本本身就说明了上面那些框与连线。

条件清单:每一条都带实测值与阈值

升级判定是一个纯函数 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 × scaleplacebo_unverifiable / placebo_not_parallel(带 max|did| 与阈值)
C6 供体拟合仅 mode=auto:合成对照前窗 index 偏差 ≤ tolerance——只挡 L3fit_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。 四值齐备才算一对,缺值的块跳过而不是补零——补出来的对会稀释 散布,而散布正是唯一噪声来源;少一对只是少一个样本,补一个假的却会污染全部结论。

由此得到两件东西,措辞都经过刻意收紧:

平行检验本身也是三态的 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;
图 2 · 模拟 = 同一个 judge 跑两遍;差异只有输入数字,以及每一条没模拟到的调整的记账。 上面这段以源码显示:图渲染库没加载起来(离线、CDN 被拦或浏览器禁用了 JavaScript 时会这样)。这段 Mermaid 文本本身就说明了上面那些框与连线。

另外两条纪律与升级梯同源:

纪律在哪体现为什么
没模拟到的要写出来 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——预算纪律让因果阶段(附录)永远拖不垮正文

优势在哪

继续读

想了解去哪
条件 DSL、三值逻辑、预算与审计行判定内核
噪声带本身:块、稳健统计、位置分数统计与噪声带
行动之后数字动了没有——验收侧怎样复用同一份 2×2闭环与契约
管理端模拟与判定历史操作判定规则
本页源码锚点的基准提交:anchor-base: 9689fd9 GitHub 仓库 文档首页