本页是 PSM 实务深入篇 本教程聚焦 propensity score matching(PSM)的设计、执行和诊断。潜在结局、目标试验、DAG、标准化、IPW 与 AIPW 的完整框架见 因果推断详解。这里不会把各种方法重复一遍,而是把一个 PSM 分析从问题定义完整走到报告。
关于模拟数据 全部个体、治疗、结局与反事实真值均由固定随机种子生成,不含真实个人健康信息。实际研究中无法同时观察同一个人的两个潜在结局;本页展示“真值”只是为了检验分析流程,不能视为现实证据。
推荐按以下顺序学习:
定义因果问题与 estimand → 锁定治疗前变量 → 估计倾向评分 → 匹配 → 检查重叠、样本流与平衡 → 冻结设计 → 分析结局 → 做敏感性分析与报告
这是一个“先设计、后看结局”的工作流。所有主要代码只依赖 R
自带功能、MatchIt 与渲染页面所需的
knitr。页面中的结局变量是连续型 6
个月临床指标,数值越低越好。
完成本教程后,你应能够:
glm() 理解 PS 模型,并用
MatchIt::matchit() 完成 1:1 最近邻匹配;link = "linear.logit"、0.2 SD
caliper、replacement 与 ratio;令 表示接受干预, 与 表示同一个体在两种策略下的潜在结局。常见目标包括:
ATE 针对整个目标人群;ATT 针对实际接受治疗者;ATC 则针对实际对照者。三者在效应异质或支持区域不同的时候可以不同。PSM 不是一个脱离 estimand 的按钮:匹配方向、是否丢弃样本、是否允许重复使用对照,都应服务于预先定义的目标。
本教程的主要问题是:
在观察到且可找到合适对照的干预接受者中,如果这些人接受干预而不是对照管理,6 个月结局平均相差多少?
初始意图是 ATT;但 caliper 使一部分干预者无法匹配,因此最终可识别的目标更准确地称为保留干预者中的 ATT(matched-sample ATT)。必须报告这次目标人群收窄,而不能只写“估计了 ATT”。
对治疗前协变量 ,倾向评分是:
若给定 后治疗分配与潜在结局可交换,并存在正值性,那么在正确使用倾向评分形成可比组后,可以比较结局。其核心角色是压缩用于治疗分配的一组协变量,以帮助构造平衡设计,不是预测谁“应该”治疗,也不是个体因果效应。
PSM 通常按相近的 或 logit 把治疗者与对照者配对。成功标准不是 PS 模型的 AUC、似然比检验或系数 p 值,而是匹配后治疗前协变量是否充分平衡、目标样本是否清楚、重叠是否可信。
| 假设 | 在本问题中的含义 | 可做的工作 |
|---|---|---|
| 一致性 | “干预”和“对照”有足够明确的版本,观察结局等于实际接受策略的潜在结局 | 明确剂量、开始时间、依从性与版本 |
| 条件可交换性 | 给定所选治疗前变量后,不再有共同影响治疗与结局的原因 | 时间顺序、DAG、领域知识、敏感性分析 |
| 正值性 | 对目标协变量组合,两种策略都有可能出现 | 画重叠图、检查极端 PS、限制目标人群 |
| 无干扰 | 一人的治疗不改变另一人的结局 | 说明传播、机构或网络效应是否可能存在 |
| 正确测量与模型 | 治疗、结局和关键协变量测量充分,设计模型可形成平衡 | 数据审计、灵活项、平衡诊断 |
匹配可以处理已测量且被正确使用的基线混杂,不能创造随机化,也不能检验“所有混杂都已测量”。
PS 模型应在明确时间零点后,仅使用治疗开始前已知的变量。优先纳入:
谨慎或避免纳入:
本例使用年龄、BMI、吸烟、基线严重度和治疗前生物标志物。结局没有进入
psm_design_data,也不会用于挑选 caliper、ratio 或 PS
公式。
stopifnot(
nrow(psm_design_data) == 1200,
all(psm_design_data$treatment_num %in% c(0, 1)),
!anyNA(psm_design_data),
!"outcome" %in% names(psm_design_data)
)
data_preview <- transform(
head(psm_design_data, 6),
treatment = ifelse(treatment == "Intervention", "干预", "对照"),
smoker = ifelse(smoker == "Yes", "是", "否"),
severity = c(
Mild = "轻度", Moderate = "中度", Severe = "重度"
)[as.character(severity)]
)
knitr::kable(
data_preview[, c(
"id", "treatment", "age", "bmi", "smoker", "severity", "biomarker"
)],
col.names = c(
"编号", "实际策略", "年龄", "BMI", "当前吸烟",
"基线严重度", "标准化生物标志物"
),
caption = "设计阶段数据的前 6 行(不含结局)"
)| 编号 | 实际策略 | 年龄 | BMI | 当前吸烟 | 基线严重度 | 标准化生物标志物 |
|---|---|---|---|---|---|---|
| 1 | 对照 | 55.9 | 18.8 | 是 | 轻度 | 0.91 |
| 2 | 干预 | 66.5 | 23.4 | 是 | 轻度 | 0.15 |
| 3 | 对照 | 51.8 | 40.4 | 是 | 中度 | 0.62 |
| 4 | 对照 | 59.4 | 23.9 | 是 | 轻度 | -0.19 |
| 5 | 干预 | 75.5 | 20.4 | 否 | 轻度 | 0.21 |
| 6 | 对照 | 65.4 | 30.9 | 否 | 重度 | -2.38 |
cohort_audit <- data.frame(
总样本 = nrow(psm_design_data),
对照组 = sum(psm_design_data$treatment_num == 0),
干预组 = sum(psm_design_data$treatment_num == 1),
干预比例 = pct(mean(psm_design_data$treatment_num == 1)),
check.names = FALSE
)
knitr::kable(cohort_audit, caption = "匹配前队列审计")| 总样本 | 对照组 | 干预组 | 干预比例 |
|---|---|---|---|
| 1200 | 710 | 490 | 40.8% |
队列包含 490 名干预者和 710 名对照者。只有在变量含义、单位、编码、时间顺序、重复记录和缺失均确认后,才应开始匹配。
最常见的工作模型为:
先用普通 glm() 可以清楚看到所估计的量;随后
matchit() 会用相同公式建立距离。
ps_model <- glm(
treatment_num ~ age + bmi + smoker + severity + biomarker,
data = psm_design_data,
family = binomial()
)
psm_design_data$ps_probability <- predict(ps_model, type = "response")
psm_design_data$ps_logit <- predict(ps_model, type = "link")
ps_coefficient_table <- data.frame(
项 = names(coef(ps_model)),
logit系数 = unname(coef(ps_model)),
check.names = FALSE
)
knitr::kable(
ps_coefficient_table,
digits = 3,
caption = "治疗分配 logistic 工作模型的系数"
)| 项 | logit系数 |
|---|---|
| (Intercept) | -7.602 |
| age | 0.066 |
| bmi | 0.093 |
| smokerYes | 0.806 |
| severityModerate | 0.413 |
| severitySevere | 1.171 |
| biomarker | 0.270 |
连续变量的线性
logit、交互和缺失处理都属于模型设定。若基于领域知识或匹配后的协变量诊断发现平衡不足,可在不查看结局的前提下加入预先合理的非线性项,例如
I(age^2)
或治疗前变量之间的交互。不要根据治疗模型系数是否显著来删变量。
不要用结局调匹配参数 如果反复尝试公式、caliper 或匹配算法,最后选择结局差异最大或 p 值最小的方案,设计阶段就被结果污染。应使用协变量平衡、重叠、样本保留和预先说明的临床可比性来冻结设计,然后只打开结局一次。
主设计采用:
estimand = "ATT":从每名干预者出发寻找对照;method = "nearest":最近邻匹配;ratio = 1:每名保留干预者匹配一名对照;replace = FALSE:同一对照不能重复使用;link = "linear.logit":在 PS 的 logit
线性预测值尺度上匹配;caliper = 0.20, std.caliper = TRUE:两人的 logit
距离不超过该距离标准差的 0.2 倍;m.order = "closest":在贪心最近邻算法中优先处理当前更近的候选配对;它不等同于最小化所有配对总距离的
optimal matching。m.out <- MatchIt::matchit(
treatment_num ~ age + bmi + smoker + severity + biomarker,
data = psm_design_data,
method = "nearest",
distance = "glm",
link = "linear.logit",
estimand = "ATT",
ratio = 1,
replace = FALSE,
caliper = 0.20,
std.caliper = TRUE,
m.order = "closest"
)
stopifnot(
isTRUE(all.equal(unname(m.out$distance), psm_design_data$ps_logit))
)
match_summary <- summary(m.out, un = TRUE, standardize = TRUE)
matched_design <- MatchIt::match_data(m.out, data = psm_design_data)m.out$distance 在这里是 logit 线性预测值,不是 0 到 1
的概率;需要概率时可用 plogis(m.out$distance)。caliper
也因此作用于 logit 尺度。改变 link
会改变距离的尺度,不能只复制数值 0.2 而忽略定义。
ps_probability <- plogis(m.out$distance)
control_density <- density(
ps_probability[psm_design_data$treatment_num == 0],
from = 0,
to = 1
)
treated_density <- density(
ps_probability[psm_design_data$treatment_num == 1],
from = 0,
to = 1
)
plot(
control_density,
col = palette_psm["orange"],
lwd = 2.4,
xlim = c(0, 1),
ylim = c(0, max(control_density$y, treated_density$y)),
xlab = "估计倾向评分",
ylab = "密度",
main = "匹配前的共同支持",
las = 1
)
lines(treated_density, col = palette_psm["teal"], lwd = 2.4)
legend(
"topright",
legend = c("对照", "干预"),
col = c(palette_psm["orange"], palette_psm["teal"]),
lwd = 2.4,
bty = "n"
)匹配前干预组与对照组的估计倾向评分分布。两组有共同支持,但干预组整体向较高概率移动,尾部个体较难找到可比对象。
“两组 PS 范围有交集”只是最低限度,不等于处处有可靠正值性。应结合密度、局部样本量、具体协变量组合和被排除者特征。尾部少数对照支撑大量干预者时,即使范围重叠,也可能依赖脆弱外推。
sample_flow_raw <- match_summary$nn[
c("All", "Matched", "Unmatched", "Discarded"),
,
drop = FALSE
]
sample_flow <- data.frame(
阶段 = c("原始样本", "成功匹配", "未能匹配", "匹配前主动丢弃"),
对照 = sample_flow_raw[, "Control"],
干预 = sample_flow_raw[, "Treated"],
check.names = FALSE
)
knitr::kable(sample_flow, caption = "1:1 最近邻匹配的样本流")| 阶段 | 对照 | 干预 | |
|---|---|---|---|
| All | 原始样本 | 710 | 490 |
| Matched | 成功匹配 | 375 | 375 |
| Unmatched | 未能匹配 | 335 | 115 |
| Discarded | 匹配前主动丢弃 | 0 | 0 |
pair_groups_design <- split(matched_design, matched_design$subclass)
pair_logit_differences <- vapply(
pair_groups_design,
function(x) {
abs(
x$distance[x$treatment_num == 1] -
x$distance[x$treatment_num == 0]
)
},
numeric(1)
)
caliper_width <- 0.20 * sd(m.out$distance)
distance_audit <- data.frame(
配对数 = length(pair_logit_differences),
caliper宽度 = caliper_width,
最大实际配对距离 = max(pair_logit_differences),
中位实际配对距离 = median(pair_logit_differences),
check.names = FALSE
)
knitr::kable(
distance_audit,
digits = 3,
caption = "logit PS 尺度上的 caliper 与实际配对距离"
)| 配对数 | caliper宽度 | 最大实际配对距离 | 中位实际配对距离 |
|---|---|---|---|
| 375 | 0.203 | 0.202 | 0.002 |
主规格形成 375 对,共保留 375 名干预者与同数对照。115 名干预者没有 caliper 内的可用对照。最大实际距离为 0.202,低于 0.203 的 caliper。
样本损失不仅是“精度变低” 未匹配的 115 名干预者不再由主要比较代表。因此主要结果针对 375 名保留干预者,而不是自动推广到全部 490 名干预者。应比较保留者与未匹配者的基线特征,并在标题、表格和讨论中明确目标人群。
retained_treated_ids <- matched_design$id[matched_design$treatment_num == 1]
treated_population_table <- rbind(
全部干预者 = c(
n = sum(psm_design_data$treatment_num == 1),
mean_age = mean(psm_design_data$age[psm_design_data$treatment_num == 1]),
mean_bmi = mean(psm_design_data$bmi[psm_design_data$treatment_num == 1]),
severe = mean(
psm_design_data$severity[psm_design_data$treatment_num == 1] == "Severe"
)
),
保留干预者 = c(
n = length(retained_treated_ids),
mean_age = mean(matched_design$age[matched_design$treatment_num == 1]),
mean_bmi = mean(matched_design$bmi[matched_design$treatment_num == 1]),
severe = mean(
matched_design$severity[matched_design$treatment_num == 1] == "Severe"
)
),
未匹配干预者 = {
u <- psm_design_data$treatment_num == 1 &
!psm_design_data$id %in% retained_treated_ids
c(
n = sum(u),
mean_age = mean(psm_design_data$age[u]),
mean_bmi = mean(psm_design_data$bmi[u]),
severe = mean(psm_design_data$severity[u] == "Severe")
)
}
)
treated_population_display <- data.frame(
人群 = rownames(treated_population_table),
人数 = treated_population_table[, "n"],
平均年龄 = treated_population_table[, "mean_age"],
平均BMI = treated_population_table[, "mean_bmi"],
重度比例 = pct(treated_population_table[, "severe"]),
check.names = FALSE
)
knitr::kable(
treated_population_display,
digits = 1,
caption = "全部、保留与未匹配干预者的基线特征"
)| 人群 | 人数 | 平均年龄 | 平均BMI | 重度比例 | |
|---|---|---|---|---|---|
| 全部干预者 | 全部干预者 | 490 | 62.9 | 29.2 | 23.7% |
| 保留干预者 | 保留干预者 | 375 | 61.0 | 28.7 | 20.3% |
| 未匹配干预者 | 未匹配干预者 | 115 | 69.0 | 30.8 | 34.8% |
对连续变量,标准化均差可写为:
其中
是预先定义的标准化尺度。在本页的 ATT 设计中,MatchIt
使用原始干预组的标准差作为 SMD 分母,并在匹配前后保持同一分母;ATE 或
ATC 的参考尺度可能不同。分类变量通常拆成指示变量逐一检查。SMD
不随样本量机械变小,适合描述设计平衡;基线显著性检验回答“总体均值是否可能相同”,不是“差异是否足以造成混杂”。
绝对 SMD 小于 0.1 是常用经验线,不是自动合格证。重要变量可能需要更严格标准;均值平衡也不能保证尾部、非线性和交互平衡。
balance_terms <- setdiff(rownames(match_summary$sum.all), "distance")
balance_labels <- c(
age = "年龄",
bmi = "BMI",
smokerNo = "不吸烟",
smokerYes = "吸烟",
severityMild = "轻度",
severityModerate = "中度",
severitySevere = "重度",
biomarker = "生物标志物"
)
balance_table <- data.frame(
协变量 = unname(balance_labels[balance_terms]),
匹配前SMD = match_summary$sum.all[balance_terms, "Std. Mean Diff."],
匹配后SMD = match_summary$sum.matched[
balance_terms, "Std. Mean Diff."
],
匹配前方差比 = match_summary$sum.all[balance_terms, "Var. Ratio"],
匹配后方差比 = match_summary$sum.matched[
balance_terms, "Var. Ratio"
],
匹配前最大eCDF差 = match_summary$sum.all[balance_terms, "eCDF Max"],
匹配后最大eCDF差 = match_summary$sum.matched[
balance_terms, "eCDF Max"
],
check.names = FALSE
)
max_smd_before <- max(abs(balance_table$匹配前SMD), na.rm = TRUE)
max_smd_after <- max(abs(balance_table$匹配后SMD), na.rm = TRUE)
knitr::kable(
balance_table,
digits = 3,
caption = "治疗前协变量的匹配前后平衡诊断"
)| 协变量 | 匹配前SMD | 匹配后SMD | 匹配前方差比 | 匹配后方差比 | 匹配前最大eCDF差 | 匹配后最大eCDF差 | |
|---|---|---|---|---|---|---|---|
| age | 年龄 | 0.566 | -0.017 | 0.992 | 0.958 | 0.237 | 0.035 |
| bmi | BMI | 0.371 | -0.009 | 1.049 | 1.052 | 0.148 | 0.040 |
| smokerNo | 不吸烟 | -0.312 | 0.000 | NA | NA | 0.151 | 0.000 |
| smokerYes | 吸烟 | 0.312 | 0.000 | NA | NA | 0.151 | 0.000 |
| severityMild | 轻度 | -0.296 | -0.066 | NA | NA | 0.144 | 0.032 |
| severityModerate | 中度 | 0.076 | 0.033 | NA | NA | 0.037 | 0.016 |
| severitySevere | 重度 | 0.252 | 0.038 | NA | NA | 0.107 | 0.016 |
| biomarker | 生物标志物 | 0.250 | -0.031 | 0.991 | 1.052 | 0.131 | 0.040 |
治疗前协变量的最大绝对 SMD 从 0.566 降到 0.066。这里特意不把
distance 行算作“协变量最大值”:PS
本身接近平衡并不能替代逐个协变量的诊断。
love_before <- abs(balance_table$匹配前SMD)
love_after <- abs(balance_table$匹配后SMD)
love_order <- order(love_before)
love_y <- seq_along(love_order)
plot(
love_before[love_order],
love_y,
pch = 16,
col = palette_psm["orange"],
xlim = c(0, max(love_before) * 1.08),
yaxt = "n",
xlab = "绝对标准化均差",
ylab = "",
main = "匹配前后协变量平衡",
las = 1
)
points(
love_after[love_order],
love_y,
pch = 17,
col = palette_psm["teal"]
)
axis(2, at = love_y, labels = balance_table$协变量[love_order], las = 1)
abline(v = 0.10, lty = 2, col = palette_psm["vermillion"])
legend(
"bottomright",
legend = c("匹配前", "匹配后", "|SMD|=0.10"),
col = c(
palette_psm["orange"],
palette_psm["teal"],
palette_psm["vermillion"]
),
pch = c(16, 17, NA),
lty = c(NA, NA, 2),
bty = "n"
)治疗前协变量的匹配前后绝对标准化均差(Love plot)。虚线 0.10 是常用经验参考,而不是充分因果条件。
方差比(VR)比较两组离散程度;MatchIt
在这里报告干预组方差除以对照组方差,匹配后使用相应匹配权重。连续变量的理想值接近
1。0.5–2 或更严格的 0.8–1.25
可作问题筛查线,但不能机械决定因果有效性。二分类指示变量的方差由均值决定,表中
VR 可能为空,这不是计算失败。
经验累积分布函数(eCDF)在整个取值范围比较分布。eCDF Max
是两条 eCDF 的最大垂直距离,越接近 0
越好;它能发现“均值相同但分布不同”的情况。
old_par <- par(mfrow = c(1, 2), mar = c(4.2, 4.2, 3, 1))
plot(
ecdf(psm_design_data$age[psm_design_data$treatment_num == 0]),
col = palette_psm["orange"],
lwd = 2.2,
verticals = TRUE,
do.points = FALSE,
main = "匹配前",
xlab = "年龄",
ylab = "经验累积概率",
las = 1
)
lines(
ecdf(psm_design_data$age[psm_design_data$treatment_num == 1]),
col = palette_psm["teal"],
lwd = 2.2,
verticals = TRUE,
do.points = FALSE
)
plot(
ecdf(matched_design$age[matched_design$treatment_num == 0]),
col = palette_psm["orange"],
lwd = 2.2,
verticals = TRUE,
do.points = FALSE,
main = "匹配后",
xlab = "年龄",
ylab = "经验累积概率",
las = 1
)
lines(
ecdf(matched_design$age[matched_design$treatment_num == 1]),
col = palette_psm["teal"],
lwd = 2.2,
verticals = TRUE,
do.points = FALSE
)
legend(
"bottomright",
legend = c("对照", "干预"),
col = c(palette_psm["orange"], palette_psm["teal"]),
lwd = 2.2,
bty = "n"
)年龄在匹配前后的经验累积分布。匹配后两组阶梯曲线明显靠近,补充了均值层面的 SMD 诊断。
continuous_vr_before <- balance_table$匹配前方差比[
is.finite(balance_table$匹配前方差比)
]
continuous_vr_after <- balance_table$匹配后方差比[
is.finite(balance_table$匹配后方差比)
]
balance_summary <- data.frame(
诊断 = c(
"最大协变量 |SMD|",
"连续变量方差比范围",
"最大协变量 eCDF Max"
),
匹配前 = c(
sprintf("%.3f", max_smd_before),
sprintf(
"%.3f–%.3f",
min(continuous_vr_before),
max(continuous_vr_before)
),
sprintf("%.3f", max(balance_table$匹配前最大eCDF差))
),
匹配后 = c(
sprintf("%.3f", max_smd_after),
sprintf(
"%.3f–%.3f",
min(continuous_vr_after),
max(continuous_vr_after)
),
sprintf("%.3f", max(balance_table$匹配后最大eCDF差))
),
check.names = FALSE
)
knitr::kable(balance_summary, caption = "主规格的总体平衡摘要")| 诊断 | 匹配前 | 匹配后 |
|---|---|---|
| 最大协变量 |SMD| | 0.566 | 0.066 |
| 连续变量方差比范围 | 0.991–1.049 | 0.958–1.052 |
| 最大协变量 eCDF Max | 0.237 | 0.040 |
matched_probability <- plogis(matched_design$distance)
matched_control_density <- density(
matched_probability[matched_design$treatment_num == 0],
from = 0,
to = 1
)
matched_treated_density <- density(
matched_probability[matched_design$treatment_num == 1],
from = 0,
to = 1
)
plot(
matched_control_density,
col = palette_psm["orange"],
lwd = 2.4,
xlim = c(0, 1),
ylim = c(0, max(matched_control_density$y, matched_treated_density$y)),
xlab = "估计倾向评分",
ylab = "密度",
main = "匹配后的支持区域",
las = 1
)
lines(
matched_treated_density,
col = palette_psm["teal"],
lwd = 2.4
)
legend(
"topright",
legend = c("匹配对照", "保留干预者"),
col = c(palette_psm["orange"], palette_psm["teal"]),
lwd = 2.4,
bty = "n"
)匹配样本中干预组与对照组的估计倾向评分分布。两组分布在保留支持区域内高度接近。
MatchIt::match_data() 返回原变量以及:
distance:匹配距离;weights:由匹配方案产生的分析权重;subclass:匹配组编号。主设计是 1:1、无放回,因此每个保留个体权重均为 1,每个 subclass 恰有一名干预者和一名对照。ratio、replacement 或其他匹配方式下,不能假定权重都是 1,也不能丢掉 subclass 后按普通独立样本分析。
# 设计已通过平衡诊断并冻结;现在才把结局合并进匹配样本。
matched_outcome <- MatchIt::match_data(m.out, data = psm_data)
subclass_audit <- aggregate(
treatment_num ~ subclass,
data = matched_outcome,
FUN = function(z) c(n = length(z), treated = sum(z))
)
stopifnot(
nrow(matched_outcome) == 750,
all(matched_outcome$weights == 1),
all(vapply(
split(matched_outcome$treatment_num, matched_outcome$subclass),
function(z) length(z) == 2 && sum(z) == 1,
logical(1)
))
)
matched_preview <- transform(
head(matched_outcome[order(matched_outcome$subclass), ], 8),
treatment = ifelse(treatment == "Intervention", "干预", "对照")
)
knitr::kable(
matched_preview[, c(
"id", "treatment", "outcome", "distance", "weights", "subclass"
)],
col.names = c(
"编号", "策略", "6 个月结局", "logit PS", "权重", "配对编号"
),
digits = 3,
caption = "冻结设计后打开的匹配结局数据"
)| 编号 | 策略 | 6 个月结局 | logit PS | 权重 | 配对编号 | |
|---|---|---|---|---|---|---|
| 2 | 2 | 干预 | 128 | -0.170 | 1 | 1 |
| 32 | 32 | 对照 | 134 | -0.199 | 1 | 1 |
| 5 | 5 | 干预 | 124 | -0.640 | 1 | 2 |
| 810 | 810 | 对照 | 128 | -0.601 | 1 | 2 |
| 11 | 11 | 干预 | 132 | -0.845 | 1 | 3 |
| 725 | 725 | 对照 | 140 | -0.845 | 1 | 3 |
| 14 | 14 | 干预 | 134 | 0.032 | 1 | 4 |
| 791 | 791 | 对照 | 158 | -0.031 | 1 | 4 |
对第 个匹配对,计算:
以配对为独立单位,均值标准误为 。这个简洁推断适用于当前 1:1 无放回设计;不能原样复制到有放回、多对一或加权匹配。
matched_pairs <- split(matched_outcome, matched_outcome$subclass)
pair_differences <- vapply(
matched_pairs,
function(x) {
x$outcome[x$treatment_num == 1] -
x$outcome[x$treatment_num == 0]
},
numeric(1)
)
matched_att <- mean(pair_differences)
matched_att_se <- sd(pair_differences) / sqrt(length(pair_differences))
matched_att_ci <- matched_att +
qt(c(0.025, 0.975), df = length(pair_differences) - 1) * matched_att_se
matched_att_p <- 2 * pt(
-abs(matched_att / matched_att_se),
df = length(pair_differences) - 1
)
naive_difference <- with(
psm_data,
mean(outcome[treatment_num == 1]) - mean(outcome[treatment_num == 0])
)
true_full_att <- mean(
psm_data$individual_effect[psm_data$treatment_num == 1]
)
true_matched_att <- mean(
matched_outcome$individual_effect[matched_outcome$treatment_num == 1]
)
effect_table <- data.frame(
分析 = c(
"未调整原始均值差",
"1:1 配对后的 matched-sample ATT",
"模拟真值:全部干预者 ATT",
"模拟真值:保留干预者 ATT"
),
估计或真值 = c(
naive_difference,
matched_att,
true_full_att,
true_matched_att
),
`95% CI 下限` = c(NA, matched_att_ci[1], NA, NA),
`95% CI 上限` = c(NA, matched_att_ci[2], NA, NA),
check.names = FALSE
)
knitr::kable(
effect_table,
digits = 2,
caption = "未调整比较、匹配估计与仅供教学的模拟真值"
)| 分析 | 估计或真值 | 95% CI 下限 | 95% CI 上限 |
|---|---|---|---|
| 未调整原始均值差 | -0.54 | NA | NA |
| 1:1 配对后的 matched-sample ATT | -6.27 | -7.26 | -5.28 |
| 模拟真值:全部干预者 ATT | -6.12 | NA | NA |
| 模拟真值:保留干预者 ATT | -6.18 | NA | NA |
未调整均值差只有 -0.54,因高风险者更可能接受干预而严重低估其降低结局的作用。匹配后的估计为 -6.27(95% CI:-7.26 至 -5.28;p <0.001),接近保留干预者中的模拟真值 -6.18。
在已测量变量足以实现条件可交换性、匹配后保留人群具有正值性、治疗与结局定义正确、且无干扰等假设下,接受干预使保留干预者的 6 个月结局平均降低约 6.27 个单位。因为结局越低越好,负差值有利于干预。该结论针对成功匹配者,不自动代表未匹配干预者或整个原始队列。
| 选择 | 主要收益 | 主要代价与推断提醒 |
|---|---|---|
| 更窄 caliper | 配对距离更近,常改善局部可比性 | 更多干预者无法匹配,目标人群变窄、精度下降 |
| 更宽 caliper | 保留更多人 | 可能接受更差配对、残余不平衡增大 |
| ratio > 1 | 每名干预者利用更多对照,可能提高精度 | 后续对照通常更远;组大小不一时权重可能不同,必须按软件输出使用 |
| replacement | 稀少的优质对照可重复匹配,常改善可比性 | 有效样本量下降;需处理对照重复与聚类/权重 |
| exact matching | 强制关键分类变量完全一致 | 可能大量损失样本;其他变量仍需诊断 |
不要根据结局选择这些选项。下面只比较预先列出的设计诊断,不读取任何结局。
fit_design <- function(caliper = 0.20, ratio = 1, replace = FALSE) {
MatchIt::matchit(
treatment_num ~ age + bmi + smoker + severity + biomarker,
data = psm_design_data,
method = "nearest",
distance = "glm",
link = "linear.logit",
estimand = "ATT",
ratio = ratio,
replace = replace,
caliper = caliper,
std.caliper = TRUE,
m.order = "closest"
)
}
design_fits <- list(
`1:1,无放回,0.10 SD` = fit_design(0.10, 1, FALSE),
`1:1,无放回,0.20 SD(主规格)` = m.out,
`1:1,无放回,0.30 SD` = fit_design(0.30, 1, FALSE),
`最多 1:2,无放回,0.20 SD` = suppressWarnings(
fit_design(0.20, 2, FALSE)
),
`1:1,有放回,0.20 SD` = fit_design(0.20, 1, TRUE)
)
extract_design_diagnostics <- function(fit) {
s <- summary(fit, un = TRUE, standardize = TRUE)
covariate_rows <- setdiff(rownames(s$sum.matched), "distance")
c(
retained_treated = s$nn["Matched", "Treated"],
used_controls = sum(
fit$weights[psm_design_data$treatment_num == 0] > 0
),
max_smd = max(
abs(s$sum.matched[covariate_rows, "Std. Mean Diff."]),
na.rm = TRUE
),
max_weight = max(fit$weights)
)
}
design_sensitivity_matrix <- do.call(
rbind,
lapply(design_fits, extract_design_diagnostics)
)
design_sensitivity_table <- data.frame(
设计规格 = rownames(design_sensitivity_matrix),
保留干预者 = design_sensitivity_matrix[, "retained_treated"],
使用的不同对照 = design_sensitivity_matrix[, "used_controls"],
最大协变量绝对SMD = design_sensitivity_matrix[, "max_smd"],
最大匹配权重 = design_sensitivity_matrix[, "max_weight"],
check.names = FALSE
)
knitr::kable(
design_sensitivity_table,
digits = 3,
caption = "不查看结局时比较预先列出的匹配规格"
)| 设计规格 | 保留干预者 | 使用的不同对照 | 最大协变量绝对SMD | 最大匹配权重 | |
|---|---|---|---|---|---|
| 1:1,无放回,0.10 SD | 1:1,无放回,0.10 SD | 372 | 372 | 0.066 | 1.00 |
| 1:1,无放回,0.20 SD(主规格) | 1:1,无放回,0.20 SD(主规格) | 375 | 375 | 0.066 | 1.00 |
| 1:1,无放回,0.30 SD | 1:1,无放回,0.30 SD | 379 | 379 | 0.060 | 1.00 |
| 最多 1:2,无放回,0.20 SD | 最多 1:2,无放回,0.20 SD | 375 | 509 | 0.046 | 1.36 |
| 1:1,有放回,0.20 SD | 1:1,有放回,0.20 SD | 489 | 270 | 0.055 | 6.07 |
这些规格都可能是合理设计,选择取决于目标估计量、关键协变量、样本保留与精度。“最多 1:2”规格中,若某名干预者在 caliper 内只有一名对照,软件会保留 1:1 匹配;因此它不保证每个 subclass 都恰有两名对照。主规格在预先指定的 0.10 SMD 参考线内实现平衡,同时保留 375 名干预者;结局分析只在设计冻结后执行。
match_data() 中的权重编码 MatchIt 所构造的目标样本。1:k
匹配时,同一 subclass
内的多个对照通常分担对照权重;有放回时,同一原始对照可能服务于多个干预者。常见错误是对提取的数据做一次无权重
lm(),从而丢掉原设计。
# 例:最多 1:2 匹配后保留 MatchIt 权重与 subclass。
m_ratio2 <- design_fits[["最多 1:2,无放回,0.20 SD"]]
d_ratio2 <- MatchIt::match_data(m_ratio2, data = psm_data)
# 点估计可用匹配权重;标准误必须与 subclass/重复使用结构相容。
fit_ratio2 <- lm(outcome ~ treatment_num,
data = d_ratio2,
weights = weights)
# 有放回匹配时,get_matches() 可展开每次匹配使用;同一 id 可能重复。
m_replace <- design_fits[["1:1,有放回,0.20 SD"]]
d_replace <- MatchIt::get_matches(
m_replace,
data = psm_data,
id = "source_row"
)上面的 lm()
只说明如何保留点估计权重,并不自动提供有效且与设计相容的标准误。实际分析需根据匹配方法采用
subclass/个体聚类稳健方差或与匹配设计相容的推断;重复使用的对照绝不能当成彼此独立的新个体。
匹配负责设计可比组,不决定结局模型。结局尺度必须回到研究问题。
对每对计算 ,其均值就是保留干预者中的风险差。McNemar 检验关注不一致配对,但 p 值不能替代风险差和置信区间。
# 假设 binary_outcome 已在设计冻结后并入 matched_outcome。
binary_pairs <- split(matched_outcome, matched_outcome$subclass)
pair_risk_differences <- vapply(
binary_pairs,
function(x) {
x$binary_outcome[x$treatment_num == 1] -
x$binary_outcome[x$treatment_num == 0]
},
numeric(1)
)
mean(pair_risk_differences) # matched-sample ATT 风险差
# 形成每对的 2×2 表后可做配对二分类检验。
paired_binary <- do.call(
rbind,
lapply(binary_pairs, function(x) c(
treated = x$binary_outcome[x$treatment_num == 1],
control = x$binary_outcome[x$treatment_num == 0]
))
)
mcnemar.test(table(paired_binary[, "treated"], paired_binary[, "control"]))计数结局需要明确观察时长与过度离散;可用与匹配权重和聚类结构相容的 Poisson/负二项模型。时间结局还涉及删失、时间零点、竞争风险与比例风险;匹配之后仍要用适当的生存模型,并处理配对或个体重复。详见 生存分析详解。
# 需要 survival 包;示意 1:1 配对中的分层 Cox 模型。
survival::coxph(
survival::Surv(time, event) ~ treatment_num + strata(subclass),
data = matched_outcome,
ties = "efron"
)
# 若使用匹配权重、replacement 或一个人多行,应采用相容的稳健方差。匹配不修复信息性删失,也不让 HR 自动成为风险差。应报告与问题相符的固定时点风险、RMST、HR 或其他效应量,并检查生存分析自身假设。
完美的 Love plot 只证明已纳入变量的分布接近。未测量疾病严重度、医生偏好、就医可及性或治疗禁忌仍可能同时影响治疗和结局。应:
敏感性分析不能证明没有偏倚,但能说明多强的未测量关系才足以改变结论。
完整案例 PSM 可能因缺失同时与治疗和结局相关而产生选择偏倚,并改变目标人群。处理步骤应包括:
简单把 NA
编成“未知”类别不一定消除偏倚;单次均值填补会扭曲方差和关系。
普通基线 PSM 假设治疗策略在共同时间零点定义。若治疗可在随访中开始、停止或切换,而当前健康状态既影响下一次治疗又受既往治疗影响,基线 PS 无法解决时间变化混杂。
此时应先模拟目标试验,建立 person-period 数据,并考虑 g-formula、边际结构模型或其他纵向因果方法。把“随访中曾治疗”倒填为基线变量会使用未来信息并产生不死时间偏倚。更多识别与纵向方法边界见 因果推断详解。
case_summary <- data.frame(
项目 = c(
"原始样本",
"主设计",
"匹配样本",
"最大协变量 |SMD|",
"未调整结局均值差",
"匹配后 matched-sample ATT",
"模拟真值(仅供教学)"
),
结果 = c(
sprintf(
"%d 名干预者;%d 名对照",
sum(psm_design_data$treatment_num == 1),
sum(psm_design_data$treatment_num == 0)
),
"ATT;1:1 最近邻;无放回;logit PS;0.20 SD caliper",
sprintf(
"%d 对;未匹配 %d 名干预者",
nlevels(matched_outcome$subclass),
sum(psm_design_data$treatment_num == 1) -
sum(matched_outcome$treatment_num == 1)
),
sprintf("%.3f → %.3f", max_smd_before, max_smd_after),
sprintf("%.2f", naive_difference),
sprintf(
"%.2f(95%% CI %.2f 至 %.2f)",
matched_att,
matched_att_ci[1],
matched_att_ci[2]
),
sprintf("保留干预者 ATT = %.2f", true_matched_att)
),
check.names = FALSE
)
knitr::kable(case_summary, caption = "PSM 主分析的可审核摘要")| 项目 | 结果 |
|---|---|
| 原始样本 | 490 名干预者;710 名对照 |
| 主设计 | ATT;1:1 最近邻;无放回;logit PS;0.20 SD caliper |
| 匹配样本 | 375 对;未匹配 115 名干预者 |
| 最大协变量 |SMD| | 0.566 → 0.066 |
| 未调整结局均值差 | -0.54 |
| 匹配后 matched-sample ATT | -6.27(95% CI -7.26 至 -5.28) |
| 模拟真值(仅供教学) | 保留干预者 ATT = -6.18 |
模拟队列共 1,200 人,其中 490 人接受干预、710 人接受对照管理。采用预先指定的 logistic 倾向评分,以 logit PS 的 0.20 个标准差为 caliper,进行 ATT 方向的 1:1 最近邻无放回匹配。375 名干预者获得对照,115 名未匹配。治疗前协变量最大绝对 SMD 从 0.566 降至 0.066。设计冻结后,375 对的平均配对结局差为 -6.27(95% CI -7.26 至 -5.28);这针对保留干预者,并依赖无未测量混杂、正值性、一致性和无干扰等假设。模拟保留人群真值为 -6.18,仅用于教学验证。
我们在[目标人群、地点与时间]中估计[ATE/ATT/ATC 或匹配后目标],治疗定义为[策略与时间零点],结局为[时点与尺度]。倾向评分使用治疗前的[变量列表与函数形式]估计;变量由[DAG/领域知识/方案]选择,设计阶段未使用结局。采用[匹配方法、方向、ratio、replacement、caliper 与 exact 条件]。原始样本包括[两组人数],匹配后包括[人数/匹配组];[人数]未匹配,因此目标人群[是否变化]。最大绝对 SMD 从[值]变为[值],并检查了[VR、eCDF、重叠、关键交互]。使用[权重、subclass 与方差方法]估计[效应量]为[估计值与 95% CI]。结果依赖[识别假设],主要局限为[未测量混杂、缺失、重叠、测量或外推]。
| 错误 | 为什么不对 | 更好的做法 |
|---|---|---|
| 把 PSM 当成自动去混杂按钮 | 未测量混杂与设计缺陷仍存在 | 先定义目标试验和识别假设 |
| 不写 estimand 就开始匹配 | 匹配方向和样本损失可能改变问题 | 预先定义 ATE/ATT/ATC 与目标人群 |
| 用结局或 p 值挑 PS 公式 | 污染设计并产生选择性结果 | 设计期隐藏结局,只按平衡与支持选择 |
| 按治疗模型 p 值删协变量 | 预测显著性不是混杂判断 | 用时间顺序、DAG 与领域知识 |
| 纳入治疗后变量 | 可能阻断效应或打开碰撞路径 | 只用时间零点前变量 |
| 只报告 PS 模型 AUC | 预测治疗不等于形成平衡 | 报每个协变量的匹配后诊断 |
| 用基线 t 检验评价平衡 | p 值强烈依赖样本量 | 以 SMD、VR、eCDF 和分布为主 |
| 只看平均 SMD | 可掩盖某个严重失衡变量 | 报全部变量和最大绝对值 |
| PS 平衡就不看协变量 | 相似 PS 可对应不同协变量组合 | 逐变量、非线性和交互诊断 |
| caliper 尺度说不清 | 0.2 在概率与 logit 尺度含义不同 | 报 link、标准化方式和实际宽度 |
| 忽略未匹配治疗者 | estimand 与外推人群已改变 | 报样本流并描述被排除者 |
| 丢掉 weights/subclass | 点估计或标准误不再对应设计 | 用 match_data() 输出完整分析 |
| replacement 后把重复对照当独立 | 低估不确定性 | 保留 id、权重与聚类结构 |
| 匹配后再做普通独立 t 检验 | 忽略配对依赖 | 1:1 无放回时分析配对差 |
| 完整案例后不报缺失 | 可能产生选择偏倚并改变人群 | 预先处理缺失并做机制敏感性分析 |
| Love plot 好看就作确定因果结论 | 只覆盖已测量协变量 | 使用有条件的因果语言并讨论未测混杂 |
| 目标 | 公式或代码 | 解释 |
|---|---|---|
| 倾向评分 | 给定治疗前变量的治疗概率 | |
| ATT | 实际治疗者中的平均因果效应 | |
| logit PS | 常用匹配距离尺度 | |
| SMD | 与样本量关系较小的均值平衡指标 | |
| 最近邻匹配 | matchit(..., method="nearest") |
从目标组寻找距离最近比较对象 |
| 0.2 SD caliper | caliper=.2, std.caliper=TRUE |
需同时报告 distance/link 尺度 |
| 提取设计样本 | match_data(m.out, data=d) |
保留 distance、weights、subclass |
| 有放回展开 | get_matches(m.out, data=d) |
原对照可能重复,保留原始 id |
| 平衡摘要 | summary(m.out, un=TRUE, standardize=TRUE) |
匹配前后 SMD、VR、eCDF 与人数 |
| 1:1 配对差 | mean(Y_treated - Y_control) |
当前主设计中的 matched-sample ATT |
# 1. 结局不可见的设计数据
design_data <- d[, c("A", "age", "bmi", "smoking", "severity")]
# 2. 预先指定 ATT 方向的 1:1 最近邻匹配
m <- MatchIt::matchit(
A ~ age + bmi + smoking + severity,
data = design_data,
method = "nearest",
distance = "glm",
link = "linear.logit",
estimand = "ATT",
ratio = 1,
replace = FALSE,
caliper = 0.20,
std.caliper = TRUE,
m.order = "closest"
)
# 3. 在结局仍不可见时检查样本流、重叠和所有协变量平衡
summary(m, un = TRUE, standardize = TRUE)
# 4. 冻结设计,才合并结局;保留 weights 与 subclass
matched <- MatchIt::match_data(m, data = d)
# 5. 采用与匹配结构和结局类型相容的效应与方差方法weights、subclass
和原始 id?建议继续学习 full matching、optimal matching、coarsened exact matching、Mahalanobis within PS calipers、generalized propensity scores、overlap weighting、双重稳健结局模型、多重插补后的匹配、匹配设计的稳健方差、未测量混杂定量偏倚分析,以及纵向治疗的 g-methods。完整因果框架和其他估计器见 因果推断详解。
## R version 4.6.1 (2026-06-24)
## Platform: aarch64-apple-darwin23
## Running under: macOS Tahoe 26.5.1
##
## Matrix products: default
## BLAS: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib
## LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
##
## locale:
## [1] C.UTF-8/C.UTF-8/C.UTF-8/C/C.UTF-8/C.UTF-8
##
## time zone: America/Edmonton
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## loaded via a namespace (and not attached):
## [1] backports_1.5.1 digest_0.6.39 R6_2.6.1 fastmap_1.2.0
## [5] xfun_0.60 cachem_1.1.0 knitr_1.51 htmltools_0.5.9
## [9] rmarkdown_2.31 lifecycle_1.0.5 cli_3.6.6 sass_0.4.10
## [13] jquerylib_0.1.4 compiler_4.6.1 chk_0.10.0 tools_4.6.1
## [17] evaluate_1.0.5 bslib_0.12.0 Rcpp_1.1.2 yaml_2.3.12
## [21] MatchIt_4.7.2 rlang_1.3.0 jsonlite_2.0.0