监控面板上 CPU 和延迟两条曲线总是一起涨——但你无法从曲线本身判断是 CPU 导致延迟、延迟导致 CPU,还是某个隐藏的共同上游(一波流量激增)同时推高了两者。这个隐藏上游就是混杂变量(confounder),相当于系统里一个没被画进依赖图的共享依赖。看到相关就下因果结论,等于看到两个服务一起抖动就断定 A 调用了 B。
统计相关回答的是观察问题:"已经看到 X 高,Y 大概率也高"——数学上是条件概率 P(Y | X)。因果回答的是干预问题:"我主动把 X 调高,Y 会变吗"——Judea Pearl 用 do 算子把它写成 P(Y | do(X))。两者的差距全在混杂:当 Z 同时影响 X 和 Y,X 与 Y 即使毫无因果关系也会高度相关。
机制上,混杂打开了一条后门路径(backdoor path):X ← Z → Y。相关性把"X→Y 的真实效应"和"经由 Z 的伪关联"混在一起。识别因果的核心动作,就是堵住后门——通过分层、回归、匹配等手段"控制住 Z",只留下 X→Y 这条直接通路。这也是著名的 Simpson 悖论的根源:不控制 Z 时整体趋势,可能和每个分组内的趋势完全相反。
import pandas as pd, numpy as np import statsmodels.formula.api as smf np.random.seed(0) n = 2000 Z = np.random.normal(size=n) # 混杂:比如"用户本身的活跃度" X = Z + np.random.normal(size=n) # Z 推高 X(活跃用户更常用新功能) Y = Z + np.random.normal(size=n) # Z 也推高 Y,X 对 Y 真实效应=0 df = pd.DataFrame({"X": X, "Y": Y, "Z": Z}) # 不控制 Z:看似 X 强烈影响 Y(伪关联) print(smf.ols("Y ~ X", df).fit().params["X"]) # ≈ 0.5 # 控制 Z(堵后门):X 的系数塌回真实值 0 print(smf.ols("Y ~ X + Z", df).fit().params["X"]) # ≈ 0
反事实(counterfactual)就是"如果我当时没上线这个 deploy,会怎样"。问题在于:你无法对同一台服务器、在同一时刻,既部署又不部署。这正是 A/B test 的心智模型,但它有个无法回避的硬伤——每个个体你只能看到一条平行宇宙,另一条永远是缺失数据。
Rubin 的潜在结果框架给每个个体 i 定义两个值:Y_i(1)(接受处理时的结果)和 Y_i(0)(不接受时的结果)。个体因果效应 = Y_i(1) − Y_i(0)。麻烦在于:i 要么被处理、要么没被处理,你永远只能观测到其中一个,另一个是反事实。这就是 因果推断的根本难题(Fundamental Problem of Causal Inference)——它本质是个缺失数据问题。
既然个体效应不可观测,我们退而求其次估平均:ATE = E[Y(1) − Y(0)]。但天真地拿"被处理组均值 − 未处理组均值"会有选择偏差:选择接受处理的人本就和别人不同。随机化是破局钥匙——随机分配让"是否处理"独立于潜在结果,两组在处理前统计上无差异,于是组间差就是无偏的 ATE。这也是为什么 RCT(随机对照试验)是因果推断的黄金标准。
| 个体 | Y(0) 不处理 | Y(1) 处理 | 个体效应 |
|---|---|---|---|
| A(被处理) | ? 反事实 | 8 | 不可知 |
| B(未处理) | 5 | ? 反事实 | 不可知 |
| … | … | … | … |
import numpy as np np.random.seed(1) n = 5000 T = np.random.binomial(1, 0.5, n) # 随机分配处理:关键!独立于个体 Y0 = np.random.normal(50, 10, n) # 不处理时的潜在结果 tau = 4.0 # 真实因果效应 +4 Y1 = Y0 + tau Y = np.where(T == 1, Y1, Y0) # 只能观测到被分配的那一支 # 因为随机化,组间差就是无偏 ATE 估计 ate = Y[T == 1].mean() - Y[T == 0].mean() print(round(ate, 2)) # ≈ 4.0,逼近真实 tau
当你无法做随机实验、又有甩不掉的混杂时,去找一个天然的随机器——一个外部推力,它只改变"是否处理",本身和结果、和混杂都无关。类比:你的灰度系统用随机 seed 分配 feature flag,谁拿到新功能是随机的、与用户画像无关。于是你可以把"被随机分到 flag"当成杠杆,撬出功能本身的因果效应——即使用户用不用功能是自选的。
有未观测混杂时,"控制 Z"这招失效——你根本测不到 Z。工具变量(instrument)Z 绕开它,靠三个条件:(1) 相关性——Z 确实影响处理 X(Z→X 够强);(2) 排他性(exclusion)——Z 只能经由 X 影响 Y,没有别的通路;(3) 独立性——Z 与未观测混杂无关,相当于"随机分配"。
机制是两阶段最小二乘(2SLS):第一阶段用 Z 预测 X,得到 X 中仅由 Z 驱动的那部分变动(这部分"干净"、不含混杂);第二阶段用这个干净的预测值去解释 Y。直觉上,你只用工具带来的那一点外生扰动来估效应,把被混杂污染的其余变动统统丢掉。代价:估计量方差更大,且条件 (2)(3) 无法从数据检验,只能靠领域论证——这是 IV 最脆弱处。
import numpy as np, pandas as pd from linearmodels.iv import IV2SLS # pip install linearmodels np.random.seed(2) n = 4000 Z = np.random.normal(size=n) # 工具:外生随机推力 U = np.random.normal(size=n) # 未观测混杂,测不到 X = 0.8*Z + U + np.random.normal(size=n) # X 受 Z 和混杂 U 共同影响 Y = 2.0*X + 3*U + np.random.normal(size=n)# 真实效应=2,但 U 污染了 OLS df = pd.DataFrame({"Y": Y, "X": X, "Z": Z}) # OLS 被混杂带偏(远离 2);IV 用 Z 撬出干净效应 iv = IV2SLS.from_formula("Y ~ 1 + [X ~ Z]", df).fit() print(round(iv.params["X"], 2)) # ≈ 2.0,逼近真实因果效应
你给某个 shard 改了配置(处理组),另一个 shard 没动(对照组)。直接看处理组改前/改后的差不行——因为整个集群这期间可能因为流量季节性一起在涨。DiD 的做法:(处理组前后差) 减去 (对照组前后差),把"两组共同经历的时间趋势"减掉,剩下的才是配置改动的净效应。
当处理不是随机分配、但你有处理前后两个时间点和一个没被处理的对照组时,DiD 能识别效应。它同时消掉两类偏差:第一次差分(前后)消掉了每个组不随时间变的固定差异(比如处理组本来基线就高);第二次差分(处理组 vs 对照组)消掉了两组共享的时间趋势(大盘的季节性波动)。两次相减后,留下的就是处理的因果效应。
核心假设是平行趋势(parallel trends):若没有处理,处理组和对照组本会沿着平行的轨迹变化。这是 DiD 全部可信度的来源,也无法直接证明——只能用处理前多期数据看两条线是否一直平行来旁证。经典案例是 Card & Krueger (1994) 最低工资研究:新泽西涨了最低工资、隔壁宾州没涨,用快餐店就业的双重差分,发现就业并未如传统理论预测的下降。
import pandas as pd import statsmodels.formula.api as smf # treat: 是否处理组 post: 是否处理后时期 df = pd.DataFrame({ "y": [20, 22, 18, 25], # 对照前后 + 处理前后 "treat": [0, 0, 1, 1], "post": [0, 1, 0, 1], }) # 交互项 treat:post 的系数 = DiD 因果效应 m = smf.ols("y ~ treat + post + treat:post", df).fit() print(m.params["treat:post"]) # = (25-18) - (22-20) = 7 - 2 = 5 ← 减掉了大盘+2 的趋势