因果推断与 A/B 实验:从潜在结果到双重差分

把相关关系升级为因果关系:潜在结果框架与 ATE/ATT 的定义、DAG 与混杂因子识别、倾向得分匹配与逆概率加权的实现、双重差分与合成控制的适用条件,以及 A/B 实验的设计要点(样本量、随机化、多重比较)与结果分析方法。

引言

「冰淇淋销量上升时溺水人数也上升」——这是相关关系,不是因果。两者共同的驱动因素是气温。如果据此关闭冰淇淋店,溺水不会减少。

数据驱动的决策最容易犯的错误,就是把相关当因果。而因果推断提供了一套严谨工具,回答「如果我做了这件事,结果会怎样」这类反事实问题——这正是产品迭代、营销投放、策略评估的核心。本文覆盖观察性数据的三大方法(倾向得分、双重差分、合成控制)与实验性数据(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) 不会同时出现。

既然个体效应不可得,就退而求其次估计群体平均:

指标定义含义
ATEE[Y(1) - Y(0)]全人群的平均效应
ATTE[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)

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「ml」更多文章

  1. 特征平台与训练服务一致性:时间点正确性与特征回填
  2. 检索增强生成与向量检索实战:Embedding、HNSW 与重排
  3. 实验管理与可复现:MLflow、版本化与模型注册表