引言
「冰淇淋销量上升时溺水人数也上升」——这是相关关系,不是因果。两者共同的驱动因素是气温。如果据此关闭冰淇淋店,溺水不会减少。
数据驱动的决策最容易犯的错误,就是把相关当因果。而因果推断提供了一套严谨工具,回答「如果我做了这件事,结果会怎样」这类反事实问题——这正是产品迭代、营销投放、策略评估的核心。本文覆盖观察性数据的三大方法(倾向得分、双重差分、合成控制)与实验性数据(A/B 测试)的设计与分析。
前置:概率统计与假设检验基础见 https://plumephp.com/ml-model-evaluation/;上线后的效果监控与 https://plumephp.com/ml-model-monitoring-drift/ 密切相关;A/B 分流工程可参考 https://plumephp.com/ml-model-deployment/。
目录
1. 相关不等于因果
1.1 三个经典陷阱
| 陷阱 | 机制 | 例子 |
|---|---|---|
| 混杂 | 第三变量同时影响两者 | 气温导致冰淇淋与溺水同增 |
| 反向因果 | 因果方向搞反 | 是生病导致住院,而非住院导致生病 |
| 选择偏差 | 样本非随机 | 只调查 App 用户,忽略已流失用户 |
1.2 辛普森悖论
分组看结论一致,合并看结论反转:
import pandas as pd
df = pd.DataFrame({"group": ["A", "B", "A", "B"],
"severity": ["轻", "轻", "重", "重"],
"n": [100, 900, 900, 100],
"cured": [90, 810, 450, 40]})
df["rate"] = df["cured"] / df["n"]
print(df)
print("合并 A 治愈率:", 540 / 1000) # 0.54
print("合并 B 治愈率:", 850 / 1000) # 0.85
分组看 A 在轻症(0.90 对 0.90)和重症(0.50 对 0.40)都不差,合并后却落后——因为 A 组里重症占比高。混杂变量会颠倒结论。
1.3 因果问题的形式化
P(Y | do(X=1)) vs P(Y | X=1)
前者:干预 X 后的结果分布(因果)
后者:观察到 X=1 的样本的结果分布(相关)
do 算子代表「人为干预」,它切断了 X 与其原因的联系——这是因果与相关最本质的区别。
2. 潜在结果框架
2.1 两个潜在结果
对每个个体 i 定义两个潜在结果:Y_i(1) 为接受干预时的结果,Y_i(0) 为未接受时的结果,两者之差即个体处理效应 tau_i = Y_i(1) - Y_i(0)。
2.2 因果推断的根本问题
我们永远只能观察到其中一个:个体要么接受了干预,要么没有,另一个是反事实。观测数据里每个个体只有一行(T 与 Y_obs),Y(1) 与 Y(0) 不会同时出现。
既然个体效应不可得,就退而求其次估计群体平均:
| 指标 | 定义 | 含义 |
|---|---|---|
| ATE | E[Y(1) - Y(0)] | 全人群的平均效应 |
| ATT | E[Y(1)-Y(0) 给定 T=1] | 受干预者的平均效应 |
干预对「愿意接受的人」效果往往更大,因此 ATE 与 ATT 通常不同。
2.3 可识别性的三个假设
要让 ATE 可估计,必须满足:
1. 无干扰(SUTVA):一个人的结果不受他人干预状态影响
2. 可忽略性(无未观测混杂):给定协变量 X 后,T 与潜在结果独立
3. 重叠(正值性):每种协变量组合下都有一定概率被干预
第 2 条是最关键也最难验证的——它本质上是一个领域知识假设,不是数据能证明的。
3. 因果图与混杂因子
3.1 用 DAG 表达假设
因果图(DAG)把变量间的因果假设画出来,节点是变量,箭头是因果方向:
X 干预 → Y 结果
↑ ↑
└── Z ─────┘ Z 是混杂因子,必须控制
X → M → Y M 是中介,控制它会屏蔽部分真实效应
X → C ← Y C 是碰撞点,控制它会引入虚假关联
3.2 三种变量的处理原则
| 类型 | 结构 | 该不该控制 |
|---|---|---|
| 混杂因子 | Z → X 且 Z → Y | 必须控制 |
| 中介变量 | X → M → Y | 通常不控制(否则测的是直接效应) |
| 碰撞点 | X → C ← Y | 绝不控制(会制造偏差) |
碰撞点的直觉:才华与颜值本来独立,但都影响「是否成名」。只统计成名的人会看到两者负相关——控制碰撞点会人为制造关联,这就是「对撞偏差」(Berkson 悖论)。
3.3 后门准则
要估计 X 对 Y 的因果效应,需要找到一组变量 Z,使得阻断所有从 X 到 Y 的后门路径(指向 X 的箭头方向),且 Z 中不含 X 的后代。
from pgmpy.models import BayesianNetwork
from pgmpy.inference import CausalInference
dag = BayesianNetwork([("Z", "X"), ("Z", "Y"), ("X", "Y")])
print(CausalInference(dag).get_all_backdoor_adjustment_sets("X", "Y"))
# frozenset({frozenset({'Z'})}) —— 控制了 Z 就能识别因果效应
4. 倾向得分匹配
4.1 核心思想
混杂变量可能几十维,直接匹配协变量会维度灾难。Rosenbaum 与 Rubin 证明:只要按「接受干预的概率」匹配就够了。在 e(X) = P(T=1 | X) 相同的人群里,干预分配近似随机——这就把观察数据「伪装」成了实验数据。
4.2 估计倾向得分
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
def estimate_ps(X, T):
"""X: 协变量矩阵, T: 干预指示(0/1)"""
m = GradientBoostingClassifier(n_estimators=200, max_depth=3, random_state=42)
m.fit(X, T)
return m.predict_proba(X)[:, 1]
ps = estimate_ps(X, T)
print("倾向得分分布:", np.percentile(ps, [5, 50, 95]).round(3))
梯度提升比逻辑回归更能捕捉非线性,但容易过拟合——务必交叉验证并检查 AUC 是否过高(> 0.9 说明两组几乎可分,重叠性差)。
4.3 匹配与 ATT 估计
from sklearn.neighbors import NearestNeighbors
def match_1to1(ps, T, caliper=0.05):
treated = np.where(T == 1)[0]
control = np.where(T == 0)[0]
nn = NearestNeighbors(n_neighbors=1).fit(ps[control].reshape(-1, 1))
dist, idx = nn.kneighbors(ps[treated].reshape(-1, 1))
keep = dist.ravel() <= caliper # 卡钳:丢弃差距过大的对
return treated[keep], control[idx.ravel()[keep]]
t_idx, c_idx = match_1to1(ps, T)
print("ATT 估计:", round(Y[t_idx].mean() - Y[c_idx].mean(), 4))
卡钳(caliper) 用来丢弃质量差的匹配,经验值取倾向得分标准差的 0.2 倍。
4.4 逆概率加权与平衡性检验
不匹配,直接给样本加权,让两组在协变量分布上对齐:
def ipw_ate(Y, T, ps, clip=0.01):
ps = np.clip(ps, clip, 1 - clip) # 防极端权重
w1, w0 = T / ps, (1 - T) / (1 - ps)
return (w1 * Y).sum() / w1.sum() - (w0 * Y).sum() / w0.sum()
def smd(x, T): # 标准化均值差,< 0.1 视为平衡
m1, m0 = x[T == 1].mean(), x[T == 0].mean()
s1, s0 = x[T == 1].var(), x[T == 0].var()
return abs(m1 - m0) / np.sqrt((s1 + s0) / 2)
| 方法 | 优点 | 缺点 |
|---|---|---|
| 匹配 | 直观、可检查平衡性 | 丢弃样本、对卡钳敏感 |
| IPW | 用全部样本 | 权重方差大,需截断 |
| 双重稳健 | 两者之一正确即可 | 实现复杂 |
5. 双重差分与合成控制
5.1 双重差分
当有干预前和干预后两期数据、且有对照组时,DID 用「差分的差分」抵消掉不随时间变化的混杂:
DID = (Y_treat,post - Y_treat,pre) - (Y_ctrl,post - Y_ctrl,pre)
def did(y_t_post, y_t_pre, y_c_post, y_c_pre):
return (y_t_post - y_t_pre) - (y_c_post - y_c_pre)
print("DID 效应:", did(120, 100, 115, 105)) # (20) - (10) = 10
写成回归形式就是 y ~ treat + post + treat:post,交互项系数即 DID 估计,其 p 值即显著性:
import statsmodels.formula.api as smf
res = smf.ols("y ~ treat + post + treat:post", data=df).fit()
print(res.params["treat:post"], res.pvalues["treat:post"])
5.2 平行趋势假设
DID 的唯一命门是平行趋势:如果没有干预,两组的趋势应当平行。检验方法是用事件研究法,以干预前一期为基准逐期估计交互项——若干预前的系数都不显著异于 0,则平行趋势成立。若干预前就存在趋势差异,DID 的估计就不可信。
5.3 合成控制
当只有一个处理单位(如某个城市)时,用其他多个单位加权组合出一个「合成对照」,找到权重 w 使合成单位在干预前的轨迹尽可能贴合处理单位,干预后两者的差距即处理效应:
from scipy.optimize import minimize
def synth_weights(Y_pre_treat, Y_pre_donors):
"""Y_pre_treat: (T,) 处理单位干预前序列; Y_pre_donors: (T, J)"""
J = Y_pre_donors.shape[1]
def loss(w):
w = np.abs(w); w = w / w.sum()
return np.mean((Y_pre_donors @ w - Y_pre_treat) ** 2)
res = minimize(loss, np.ones(J) / J, method="Nelder-Mead")
w = np.abs(res.x)
return w / w.sum()
| 方法 | 数据要求 | 关键假设 | 适用 |
|---|---|---|---|
| 倾向得分 | 横截面 + 丰富协变量 | 无未观测混杂 | 观察性、一次性干预 |
| DID | 面板 + 前后两期 | 平行趋势 | 政策评估、区域试点 |
| 合成控制 | 面板 + 多个对照单位 | 干预前可完美拟合 | 单一大单位干预 |
6. A/B 实验设计
6.1 为什么 A/B 是黄金标准
随机化让干预分配与所有潜在混杂独立(无论是否被观测到),直接满足可忽略性假设。这是观察性方法永远达不到的强度。
6.2 实验设计四步
1. 明确假设与指标:主指标(唯一)、护栏指标、诊断指标
2. 算样本量:给定基线、最小可检测效应 MDE、alpha、power
3. 随机化与分流:按用户/会话/设备维度,保证一致性
4. 定运行时长:至少覆盖一个完整业务周期(通常 1~2 周)
6.3 样本量计算
from scipy.stats import norm
def sample_size_per_group(baseline, mde, alpha=0.05, power=0.8):
"""baseline: 基线转化率; mde: 相对提升(0.05 表示提升 5%)"""
p1, p2 = baseline, baseline * (1 + mde)
p_bar = (p1 + p2) / 2
z_a, z_b = norm.ppf(1 - alpha / 2), norm.ppf(power)
return int(np.ceil(2 * p_bar * (1 - p_bar) * (z_a + z_b) ** 2 / (p2 - p1) ** 2))
print(sample_size_per_group(0.10, 0.05)) # 每组约 6 万
print(sample_size_per_group(0.10, 0.20)) # 提升到 20%,只需约 4 千
MDE 越小,所需样本量平方级增长。这就是为什么「小改动」的实验往往跑不出显著结果。
6.4 分流实现与健康检查
import hashlib
def assign_bucket(user_id, salt, num_buckets=10000):
"""对用户做确定性哈希,保证同一用户始终进同一桶"""
key = f"{salt}:{user_id}".encode()
return int(hashlib.md5(key).hexdigest()[:8], 16) % num_buckets
def in_treatment(user_id, salt, ratio=0.5):
return assign_bucket(user_id, salt) < int(ratio * 10000)
确定性哈希是关键:同一个用户跨设备、跨请求必须稳定落在同一组,否则体验割裂且指标污染。实验开跑前还要做分流健康检查:样本比例接近 50:50、A/A 检验的 p 值均匀分布、实验前指标无显著差异、同一用户不跨组污染。
7. 实验结果分析
7.1 基础检验
from scipy import stats
def ab_test(conv_a, n_a, conv_b, n_b):
p_a, p_b = conv_a / n_a, conv_b / n_b
p_pool = (conv_a + conv_b) / (n_a + n_b)
se = np.sqrt(p_pool * (1 - p_pool) * (1 / n_a + 1 / n_b))
z = (p_b - p_a) / se
return {"lift": (p_b - p_a) / p_a, "z": z,
"p": 2 * (1 - stats.norm.cdf(abs(z))),
"ci": ((p_b - p_a) - 1.96 * se, (p_b - p_a) + 1.96 * se)}
print(ab_test(1000, 10000, 1100, 10000))
7.2 置信区间、多重比较与常见误读
p 值只回答「有没有差异」,置信区间还告诉你差异有多大,报告时优先给 CI:例如「提升 1.0 个百分点,95% CI [0.2, 1.8]」既有统计显著性,也有业务显著性(下限仍为正)。
同时看 20 个指标,按 alpha=0.05 会有 1 个假阳性,因此需要校正:
def bonferroni(p_values, alpha=0.05):
m = len(p_values)
return [min(p * m, 1.0) for p in p_values], alpha / m
更温和的做法是控制错误发现率(FDR),用 Benjamini-Hochberg 过程。此外还有四个常见误读:p > 0.05 不代表无差异(可能只是样本不足);中途多次看结果就停会让假阳性率暴涨;显著不等于可上线,还要看延迟、崩溃率等护栏指标;提升 3% 但 CI 很宽说明估计不稳定,需延长实验。
8. 常见坑与检验清单
| 现象 | 根因 | 处理 |
|---|---|---|
| 实验组指标全面变好 | 分流不均匀或指标泄漏 | 做 A/A 检验,检查埋点口径 |
| 干预前就有差异 | 随机化有 bug | 用哈希一致性校验,检查灰名单 |
| 效应随时间衰减 | 新奇效应 | 延长观察期,看趋势曲线 |
| 网络效应污染 | 用户互相影响(社交/双边市场) | 用集群随机化或切换实验 |
| 样本量算出来很小 | MDE 设得过大 | 结合业务价值重设 MDE |
| 观察性结论与实验相反 | 混杂未控制干净 | 回到 DAG 检查后门路径 |
上线前必做:A/A 检验、样本比例校验、护栏指标看板、预注册分析计划。
9. 总结
9.1 方法选择决策树
能随机化吗?
├── 能 → A/B 实验(黄金标准)
└── 不能
├── 有前后两期 + 对照组 → DID
├── 只有单一大单位 → 合成控制
└── 横截面 + 丰富协变量 → 倾向得分 / IPW
9.2 关键决策点
| 问题 | 选择 |
|---|---|
| 混杂变量多且连续 | 倾向得分而非直接匹配 |
| 有未观测混杂 | 只能靠 DID 或工具变量,PS 无能为力 |
| 协变量匹配后仍不平衡 | 检查是否控制了碰撞点或中介 |
| 实验结果不显著 | 先算功效,可能是样本不足 |
| 指标太多 | 定唯一主指标 + FDR 校正 |
9.3 一句话心法
因果推断不是算出来的,是设计出来的——再精巧的统计方法也救不了糟糕的识别策略,能随机化就随机化。
延伸阅读
- https://plumephp.com/ml-model-evaluation/ — 交叉验证、假设检验与评估方法
- https://plumephp.com/ml-model-monitoring-drift/ — 上线后效果监控与漂移检测
- https://plumephp.com/ml-model-deployment/ — 模型部署与灰度 A/B 分流工程
- https://plumephp.com/ml-feature-engineering/ — 协变量构造与数据清洗
- 因果推断导论(Causal Inference: The Mixtape)
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。