Causal Identification / Matching

PSM 倾向得分匹配

PSM 用一个一维得分压缩高维协变量,再在共同支持上比较处理组和相似对照组,目标是让可比性从“看起来像”变成可诊断的平衡条件。

Mechanism Lab

动画:PSM 如何在共同支持上构造匹配对

动画把处理组和对照组投影到同一条倾向得分轴上,逐步显示共同支持、最近邻、卡尺筛选和匹配后的平衡诊断。

Step 1 / 5

Raw scores

先估计每个样本接受处理的概率,把高维协变量压到一条得分轴。

e(X)=P(D=1|X)

Animation Control

Reduced-motion users receive the same step states without continuous motion.

01 / 直觉

核心直觉

倾向得分 e(X) 是个体在协变量 X 下接受处理的概率。它不是处理效应,而是进入处理组的选择机制摘要。

匹配的目标不是让结果 Y 相近,而是让处理组和对照组在处理前协变量上平衡。

PSM 的识别仍依赖“可观测变量条件独立”:所有同时影响处理选择和结果的混杂因素必须已经在 X 中。

02 / 数学

从可忽略性到一维匹配估计量

01 / 潜在结果与可忽略性

令 D 表示是否接受处理,Y(1), Y(0) 表示潜在结果。PSM 不能解决未观测混杂;它从给定 X 后处理近似随机这个假设出发。

(Y(1), Y(0)) independent of D | X
0 < P(D=1|X) < 1

02 / 倾向得分

倾向得分是条件处理概率。它把多维 X 压成一维,但保留了处理分配所需的概率信息。

e(X) = P(D=1 | X)

03 / 平衡性质证明

在离散 X 的情形下,对任意满足 e(x)=p 的 x,用贝叶斯公式可得处理组在给定 e(X)=p 后的 X 分布等于总体在该得分层里的 X 分布。

P(X=x | D=1, e(X)=p)
= P(D=1 | X=x, e(X)=p) P(X=x | e(X)=p) / P(D=1 | e(X)=p)
= p P(X=x | e(X)=p) / p
= P(X=x | e(X)=p)

04 / 可忽略性转移

若给定 X 后处理分配与潜在结果独立,并且 e(X) 是平衡得分,则给定 e(X) 后处理也与潜在结果独立。这让一维匹配可以替代高维精确匹配。

(Y(1), Y(0)) independent of D | e(X)

05 / ATT 匹配估计量

对每个处理个体 i,找得分接近的对照个体集合 J(i),用权重 w_ij 构造其未处理反事实,再对处理组平均。

tau_ATT_hat = (1/N_T) sum_{i:D_i=1} [Y_i - sum_{j:D_j=0} w_ij Y_j]
w_ij >= 0, sum_j w_ij = 1

06 / 平衡诊断

匹配后应检查协变量标准化均差,而不只报告匹配算法。经验上 |SMD| 小于 0.1 常作为平衡改善的参考阈值。

SMD_k = (mean(X_k|D=1) - mean(X_k|D=0, matched)) / sqrt((s_Tk^2 + s_Ck^2)/2)

03 / 代码

Python 代码:估计倾向得分、最近邻匹配与平衡表

下面是可复现研究中常见的 PSM skeleton:先估计处理概率,再限制共同支持,做最近邻匹配,最后报告 ATT 和匹配前后平衡。

import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import NearestNeighbors

# df columns:
# outcome, treated, age, baseline_score, income, school_size
covariates = ["age", "baseline_score", "income", "school_size"]
X = df[covariates]
D = df["treated"].astype(int)

ps_model = LogisticRegression(max_iter=2000)
ps_model.fit(X, D)
df = df.copy()
df["pscore"] = ps_model.predict_proba(X)[:, 1]

# Common support: keep treated and control observations whose scores overlap.
treat = df[df["treated"] == 1].copy()
control = df[df["treated"] == 0].copy()
lower = max(treat["pscore"].min(), control["pscore"].min())
upper = min(treat["pscore"].max(), control["pscore"].max())
support = df[df["pscore"].between(lower, upper)].copy()

treat = support[support["treated"] == 1].copy()
control = support[support["treated"] == 0].copy()

matcher = NearestNeighbors(n_neighbors=1, metric="euclidean")
matcher.fit(control[["pscore"]])
distance, index = matcher.kneighbors(treat[["pscore"]])

matched_control = control.iloc[index[:, 0]].copy()
matched_control.index = treat.index

att = (treat["outcome"] - matched_control["outcome"]).mean()
print({"ATT": att, "matched_pairs": len(treat), "max_distance": float(distance.max())})

def standardized_mean_difference(left, right, columns):
    rows = []
    for col in columns:
        pooled_sd = np.sqrt((left[col].var() + right[col].var()) / 2)
        rows.append({
            "covariate": col,
            "smd": (left[col].mean() - right[col].mean()) / pooled_sd,
        })
    return pd.DataFrame(rows)

before = standardized_mean_difference(
    df[df["treated"] == 1],
    df[df["treated"] == 0],
    covariates,
)
after = standardized_mean_difference(treat, matched_control, covariates)
print(before.assign(stage="before"))
print(after.assign(stage="after"))

04 / 案例

案例:助学项目参与学生的可比对照组

  • 研究问题:参加某助学项目是否提高后续成绩?处理选择明显依赖年龄、基线成绩、家庭收入和学校规模。
  • 直接比较参与者和未参与者会混入选择偏差,因为高动机或资源更好的学生更可能参与。
  • PSM 的工作流是先估计参与概率,再剔除没有共同支持的样本,最后把每个参与者和倾向得分相近的未参与者配对。
  • 报告时不应只给一个 ATT;还要给匹配前后协变量平衡图、共同支持图、卡尺敏感性和未观测混杂的讨论。

05 / 风险

常见误区

把倾向得分当成结果模型,忽略匹配后的协变量平衡诊断。
在没有共同支持的区域强行外推,导致处理组反事实来自不可比样本。
以为 PSM 能消除未观测混杂;它只能处理已进入 X 的可观测混杂。

参考资料