2.3 随机对照试验(RCTs)
"Randomization is the only method known to us that can be relied on to control for all relevant factors.""随机化是我们所知的唯一可以控制所有相关因素的方法。"— Abhijit Banerjee & Esther Duflo, 2019 Nobel Laureates in Economics (2019年诺贝尔经济学奖得主)
因果推断的黄金标准
本节目标
- 理解随机化如何消除选择偏误
- 掌握 RCT 的设计原理和类型
- 学习平衡性检验的方法
- 了解 RCT 的优势与局限
随机化的魔力
核心思想
RCT(Randomized Controlled Trial) = 随机对照试验
关键操作:通过 抛硬币(随机数生成器)决定谁接受处理
通常 (处理组和对照组各占 50%)
为什么随机化有效?
大数定律 + 独立性 → 平衡性
当样本量足够大时,随机分配保证:
含义:
- 两组在所有特征上平均相同
- 包括 可观测特征(年龄、教育)和 不可观测特征(能力、动机)
- 两组唯一的差别就是 是否接受处理
简单对比在 RCT 中无偏
数学证明
在 RCT 下,简单对比:
关键步骤:
- 第 2 行:观测规则()
- 第 3 行:随机化保证
- 第 4 行:定义 ATE
对比:无随机化的情况
选择偏误重现:
RCT 的优势:第二项(选择偏误)= 0
RCT 设计类型
1. 完全随机化(Simple Randomization)
方法:每个个体独立随机分配
import numpy as np
n = 1000
treatment = np.random.binomial(1, 0.5, n)优点:
- 最简单
- 理论上完全消除偏误
缺点:
- 小样本时可能不平衡(如处理组 480 人,对照组 520 人)
- 可能在协变量上不平衡
2. 分层随机化(Stratified Randomization)
方法:先按协变量分层,再在每层内随机化
案例:教育实验
层 1(重点高中):500 人 → 250 处理,250 对照
层 2(普通高中):300 人 → 150 处理,150 对照
层 3(职业高中):200 人 → 100 处理,100 对照Python 实现:
import pandas as pd
import numpy as np
# 生成数据
np.random.seed(42)
data = pd.DataFrame({
'student_id': range(1000),
'school_type': np.random.choice(['重点', '普通', '职业'], 1000, p=[0.5, 0.3, 0.2])
})
# 分层随机化
def stratified_randomization(df, strata_col, prob=0.5):
df['treatment'] = 0
for stratum in df[strata_col].unique():
mask = df[strata_col] == stratum
n_stratum = mask.sum()
df.loc[mask, 'treatment'] = np.random.binomial(1, prob, n_stratum)
return df
data = stratified_randomization(data, 'school_type')
# 检查平衡性
print(data.groupby('school_type')['treatment'].value_counts())优点:
- 保证重要协变量平衡
- 提高统计功效(Power)
何时使用:
- 已知某些变量对结果影响很大(如性别、年龄、基线成绩)
- 想确保子组样本量足够
3. 配对随机化(Matched-Pair Randomization)
方法:先配对相似个体,再在每对内随机化
案例:学校实验
- 配对 50 所相似学校(按地区、规模、成绩)
- 每对中,一所随机分配为处理,另一所为对照
Python 实现:
# 假设有 100 所学校,两两配对
n_pairs = 50
pairs = np.repeat(range(n_pairs), 2) # [0,0,1,1,2,2,...]
# 每对内随机化
treatment = np.zeros(100, dtype=int)
for pair_id in range(n_pairs):
pair_indices = np.where(pairs == pair_id)[0]
treatment[pair_indices[0]] = np.random.binomial(1, 0.5)
treatment[pair_indices[1]] = 1 - treatment[pair_indices[0]]
print(f"处理组数量: {treatment.sum()}") # 应该正好是 50优点:
- 自动平衡协变量
- 样本量严格相等
缺点:
- 需要先找到合适的配对
- 分析时需要考虑配对结构(Cluster SE)
4. 聚类随机化(Cluster Randomization)
方法:以组为单位随机化(如班级、学校、村庄)
案例:教育政策评估
- 随机选择 50 所学校实施新政策
- 另外 50 所学校为对照组
- 单位:学校(而非学生)
为什么需要?
- 溢出效应(Spillover):同班学生会相互影响
- 实施可行性:无法在同一学校内区别对待
Python 实现:
# 100 所学校
n_schools = 100
school_treatment = np.random.binomial(1, 0.5, n_schools)
# 每所学校 30 名学生
students_per_school = 30
data = pd.DataFrame({
'school_id': np.repeat(range(n_schools), students_per_school),
'student_id': range(n_schools * students_per_school)
})
# 学生继承学校的处理状态
data['treatment'] = data['school_id'].map(
dict(zip(range(n_schools), school_treatment))
)
print(data.groupby('school_id')['treatment'].first().value_counts())注意事项:
- 标准误需要 聚类调整(Cluster-Robust SE)
- 有效样本量 = 聚类数(不是个体数)
平衡性检验(Balance Check)
为什么需要?
虽然随机化理论上保证平衡,但实际数据可能因为:
- 样本量有限
- 随机性波动
- 实施过程中的偏差
导致某些变量不平衡。
检验方法
1. 描述性统计对比
import pandas as pd
from scipy import stats
# 模拟数据
np.random.seed(42)
n = 200
data = pd.DataFrame({
'treatment': np.random.binomial(1, 0.5, n),
'age': np.random.normal(30, 5, n),
'income': np.random.normal(50000, 15000, n),
'education': np.random.randint(12, 22, n)
})
# 平衡性表格
balance_table = data.groupby('treatment').agg({
'age': ['mean', 'std'],
'income': ['mean', 'std'],
'education': ['mean', 'std']
}).T
balance_table.columns = ['对照组', '处理组']
print(balance_table.round(2))输出:
对照组 处理组
age mean 29.87 30.13
std 5.12 4.88
income mean 49234.56 50876.23
std 14987.45 15234.78
education mean 16.47 16.53
std 2.89 2.912. t 检验(连续变量)
def balance_test_continuous(data, var, treatment_col='treatment'):
"""
对连续变量进行平衡性 t 检验
"""
treated = data[data[treatment_col] == 1][var]
control = data[data[treatment_col] == 0][var]
t_stat, p_value = stats.ttest_ind(treated, control)
return {
'变量': var,
'处理组均值': treated.mean(),
'对照组均值': control.mean(),
'差异': treated.mean() - control.mean(),
't 统计量': t_stat,
'p 值': p_value,
'平衡': '' if p_value > 0.05 else ''
}
# 批量检验
balance_results = []
for var in ['age', 'income', 'education']:
balance_results.append(balance_test_continuous(data, var))
balance_df = pd.DataFrame(balance_results)
print(balance_df.round(3))3. 卡方检验(分类变量)
# 添加分类变量
data['gender'] = np.random.choice(['男', '女'], n)
data['region'] = np.random.choice(['东部', '中部', '西部'], n)
def balance_test_categorical(data, var, treatment_col='treatment'):
"""
对分类变量进行平衡性卡方检验
"""
contingency_table = pd.crosstab(data[var], data[treatment_col])
chi2, p_value, dof, expected = stats.chi2_contingency(contingency_table)
return {
'变量': var,
'卡方统计量': chi2,
'p 值': p_value,
'平衡': '' if p_value > 0.05 else ''
}
# 批量检验
cat_results = []
for var in ['gender', 'region']:
cat_results.append(balance_test_categorical(data, var))
cat_df = pd.DataFrame(cat_results)
print(cat_df.round(3))4. 联合 F 检验
import statsmodels.api as sm
# 回归:treatment ~ age + income + education + ...
X = data[['age', 'income', 'education']]
X = sm.add_constant(X)
y = data['treatment']
model = sm.OLS(y, X).fit()
print(model.summary())
# F 检验:H0: 所有系数 = 0
print(f"\nF-statistic: {model.fvalue:.3f}")
print(f"Prob (F-statistic): {model.f_pvalue:.4f}")
if model.f_pvalue > 0.05:
print(" 协变量联合平衡(F 检验不显著)")
else:
print(" 协变量可能不平衡(F 检验显著)")平衡性检验的解读
| p 值 | 结论 | 处理方式 |
|---|---|---|
| > 0.1 | 非常平衡 | 无需调整 |
| 0.05-0.1 | ️ 边缘情况 | 可以加控制变量 |
| < 0.05 | 不平衡 | 必须加控制变量或重新随机化 |
重要提醒:
- 平衡性检验 ≠ 假设检验
- 不应该拒绝原假设(我们希望 p > 0.05)
- 多重检验问题:测试 20 个变量,期望有 1 个 p < 0.05
RCT 的估计方法
方法 1:简单差分
# 最简单的 ATE 估计
ATE_simple = data[data['treatment'] == 1]['outcome'].mean() - \
data[data['treatment'] == 0]['outcome'].mean()
# 标准误(假设方差齐性)
n1 = (data['treatment'] == 1).sum()
n0 = (data['treatment'] == 0).sum()
s1 = data[data['treatment'] == 1]['outcome'].std()
s0 = data[data['treatment'] == 0]['outcome'].std()
se_simple = np.sqrt(s1**2 / n1 + s0**2 / n0)
# 95% 置信区间
ci_lower = ATE_simple - 1.96 * se_simple
ci_upper = ATE_simple + 1.96 * se_simple
print(f"ATE: {ATE_simple:.2f}")
print(f"95% CI: [{ci_lower:.2f}, {ci_upper:.2f}]")方法 2:回归估计
import statsmodels.api as sm
X = sm.add_constant(data['treatment'])
y = data['outcome']
model = sm.OLS(y, X).fit()
print(model.summary())
# ATE = 处理变量的系数
ATE_reg = model.params['treatment']
se_reg = model.bse['treatment']
print(f"\nATE (回归): {ATE_reg:.2f}")
print(f"标准误: {se_reg:.2f}")优势:
- 可以添加控制变量(提高精度)
- 异方差稳健标准误
方法 3:回归 + 控制变量
# 添加控制变量
X = data[['treatment', 'age', 'income', 'education']]
X = sm.add_constant(X)
y = data['outcome']
model_control = sm.OLS(y, X).fit(cov_type='HC3') # 异方差稳健 SE
print(model_control.summary())
# 对比精度
print(f"\n不加控制变量 SE: {se_reg:.3f}")
print(f"加控制变量 SE: {model_control.bse['treatment']:.3f}")
print(f"精度提升: {(1 - model_control.bse['treatment'] / se_reg) * 100:.1f}%")为什么在 RCT 中加控制变量?
- RCT 下 的估计无偏(无论是否加控制变量)
- 但加控制变量可以 降低标准误(提高统计功效)
️ RCT 的优势与局限
优势(为什么是黄金标准)
| 优势 | 说明 |
|---|---|
| 内部有效性最强 | 随机化消除所有混淆因素 |
| 无需假设 | 不依赖函数形式、不可观测假设 |
| 简单透明 | 分析方法直观,结果可信 |
| 异质性分析 | 容易研究子组效应 |
局限
| 局限 | 说明 | 应对措施 |
|---|---|---|
| 成本高 | 时间、资金、人力 | 考虑准实验方法 |
| 伦理问题 | 某些处理无法随机(如吸烟) | 观察性数据 + 因果推断方法 |
| 外部有效性 | 实验样本可能不代表总体 | 多地点重复实验 |
| Hawthorne 效应 | 参与者知道被观察而改变行为 | 双盲设计 |
| 样本流失 | 跟踪困难导致数据缺失 | 意向性分析(ITT) |
| 不依从 | 分配到处理但未接受 | 工具变量(IV) |
经典 RCT 案例
案例 1:PROGRESA(墨西哥条件现金转移)
背景:
- 1997 年墨西哥政府推出扶贫项目
- 向贫困家庭发放现金,条件是子女必须上学、定期体检
实验设计:
- 随机选择 320 个村庄立即实施
- 186 个村庄推迟 2 年实施(对照组)
- 单位:村庄(聚类随机化)
结果:
- 儿童入学率提高 3.4 个百分点
- 健康状况显著改善
- 后来推广到全国(更名为 Oportunidades)
关键教训:
- 政策推广可以利用 逐步推出(Phase-in)设计 RCT
- 对照组只是推迟而非永远剥夺
案例 2:STAR Project(田纳西州小班教学)
背景:
- 1985-1989 年研究班级规模对学业成绩的影响
实验设计:
- 11,600 名学生随机分配到:
- 小班(13-17 人)
- 普通班(22-25 人)
- 普通班 + 助教
- 单位:学生(学校内随机化)
结果:
- 小班学生成绩显著更高
- 效应持续到高中甚至成年
- 对弱势群体效应更大
数据:公开数据集,广泛用于教学
# 加载 STAR 数据(示例)
# data = pd.read_csv('STAR_data.csv')
# ATE = data[data['small_class'] == 1]['test_score'].mean() - \
# data[data['small_class'] == 0]['test_score'].mean()案例 3:Oregon Health Insurance Experiment
背景:
- 2008 年俄勒冈州扩大 Medicaid(医疗补助)
- 名额有限,通过抽签分配
实验设计:
- 自然形成的 RCT
- 中签者可以申请 Medicaid(处理组)
- 未中签者为对照组
结果:
- 医疗保险显著降低财务压力
- 改善心理健康
- 但对身体健康指标影响不显著(争议)
Python 完整实战:模拟 RCT
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
import statsmodels.api as sm
# 设置随机种子
np.random.seed(42)
# ==== 1. 数据生成 ====
n = 500
# 协变量
data = pd.DataFrame({
'age': np.random.normal(30, 10, n),
'income': np.random.lognormal(10.5, 0.5, n),
'education': np.random.randint(9, 23, n)
})
# 随机分配(RCT)
data['treatment'] = np.random.binomial(1, 0.5, n)
# 结果变量(模拟真实因果效应 = 1000)
data['Y0'] = (500 + 50 * data['age'] + 0.05 * data['income'] +
100 * data['education'] + np.random.normal(0, 500, n))
data['Y1'] = data['Y0'] + 1000 + np.random.normal(0, 200, n)
data['Y_obs'] = np.where(data['treatment'] == 1, data['Y1'], data['Y0'])
# ==== 2. 平衡性检验 ====
print("=" * 50)
print("平衡性检验")
print("=" * 50)
for var in ['age', 'income', 'education']:
treated = data[data['treatment'] == 1][var]
control = data[data['treatment'] == 0][var]
t_stat, p_val = stats.ttest_ind(treated, control)
print(f"\n{var}:")
print(f" 处理组均值: {treated.mean():.2f}")
print(f" 对照组均值: {control.mean():.2f}")
print(f" 差异: {treated.mean() - control.mean():.2f}")
print(f" p-value: {p_val:.4f} {'' if p_val > 0.05 else ''}")
# ==== 3. ATE 估计 ====
print("\n" + "=" * 50)
print("ATE 估计")
print("=" * 50)
# 方法 1:简单差分
ATE_simple = (data[data['treatment'] == 1]['Y_obs'].mean() -
data[data['treatment'] == 0]['Y_obs'].mean())
print(f"\n简单差分: {ATE_simple:.2f}")
# 方法 2:回归(无控制变量)
X = sm.add_constant(data['treatment'])
model1 = sm.OLS(data['Y_obs'], X).fit(cov_type='HC3')
print(f"\n回归(无控制变量):")
print(f" ATE: {model1.params['treatment']:.2f}")
print(f" SE: {model1.bse['treatment']:.2f}")
print(f" 95% CI: [{model1.conf_int().loc['treatment', 0]:.2f}, "
f"{model1.conf_int().loc['treatment', 1]:.2f}]")
# 方法 3:回归(有控制变量)
X_control = sm.add_constant(data[['treatment', 'age', 'income', 'education']])
model2 = sm.OLS(data['Y_obs'], X_control).fit(cov_type='HC3')
print(f"\n回归(有控制变量):")
print(f" ATE: {model2.params['treatment']:.2f}")
print(f" SE: {model2.bse['treatment']:.2f}")
print(f" 95% CI: [{model2.conf_int().loc['treatment', 0]:.2f}, "
f"{model2.conf_int().loc['treatment', 1]:.2f}]")
print(f"\n精度提升: {(1 - model2.bse['treatment'] / model1.bse['treatment']) * 100:.1f}%")
# ==== 4. 可视化 ====
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
# 子图 1:处理分配
axes[0, 0].bar(['对照组', '处理组'],
data['treatment'].value_counts().sort_index(),
color=['skyblue', 'salmon'])
axes[0, 0].set_ylabel('样本量')
axes[0, 0].set_title('随机分配结果')
# 子图 2:结果分布对比
axes[0, 1].hist(data[data['treatment'] == 0]['Y_obs'],
bins=30, alpha=0.5, label='对照组', color='skyblue')
axes[0, 1].hist(data[data['treatment'] == 1]['Y_obs'],
bins=30, alpha=0.5, label='处理组', color='salmon')
axes[0, 1].axvline(data[data['treatment'] == 0]['Y_obs'].mean(),
color='blue', linestyle='--', linewidth=2)
axes[0, 1].axvline(data[data['treatment'] == 1]['Y_obs'].mean(),
color='red', linestyle='--', linewidth=2)
axes[0, 1].set_xlabel('结果变量')
axes[0, 1].set_ylabel('频数')
axes[0, 1].set_title('结果分布对比')
axes[0, 1].legend()
# 子图 3:协变量平衡(年龄)
axes[1, 0].boxplot([data[data['treatment'] == 0]['age'],
data[data['treatment'] == 1]['age']],
labels=['对照组', '处理组'])
axes[1, 0].set_ylabel('年龄')
axes[1, 0].set_title('协变量平衡:年龄')
# 子图 4:ATE 估计对比
methods = ['简单差分', '回归\n(无控制)', '回归\n(有控制)']
estimates = [ATE_simple, model1.params['treatment'], model2.params['treatment']]
se = [0, model1.bse['treatment'], model2.bse['treatment']]
axes[1, 1].errorbar(methods, estimates, yerr=[1.96*s for s in se],
fmt='o', capsize=5, capthick=2, markersize=8)
axes[1, 1].axhline(1000, color='red', linestyle='--', label='真实 ATE')
axes[1, 1].set_ylabel('ATE 估计')
axes[1, 1].set_title('不同方法的 ATE 估计')
axes[1, 1].legend()
axes[1, 1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('rct_analysis.png', dpi=300, bbox_inches='tight')
plt.show()
print("\n RCT 分析完成!")小结
RCT 的核心要点
| 要点 | 内容 |
|---|---|
| 核心机制 | 随机化 → 独立性 → 平衡性 → 无偏估计 |
| 设计类型 | 简单/分层/配对/聚类随机化 |
| 平衡性检验 | t 检验、卡方检验、F 检验 |
| 估计方法 | 简单差分、回归、回归+控制 |
| 优势 | 内部有效性最强 |
| 局限 | 成本高、伦理问题、外部有效性 |
关键洞察
随机化是因果推断的终极武器
- 消除所有(可观测和不可观测的)混淆因素
- 让简单对比就能得到无偏估计
平衡性检验是质量保证
- 不是假设检验(希望不显著)
- 不平衡时必须加控制变量
RCT 不是万能的
- 很多重要问题无法做 RCT(伦理、可行性)
- 需要结合准自然实验方法
思考题
设计题:你想研究"远程办公对员工生产力的因果效应"。
- (a) 设计一个 RCT
- (b) 应该使用哪种随机化类型?为什么?
- (c) 可能遇到哪些实施挑战?
分析题:某 RCT 有 1000 人,随机分配后发现:
- 处理组平均年龄 35 岁,对照组 30 岁(p = 0.02)
- 其他变量都平衡(p > 0.1)
问题:
- (a) 这个 RCT 有效吗?
- (b) 应该如何分析数据?
理解题:为什么 RCT 中添加控制变量不改变 ATE 估计的无偏性,但能提高精度?
点击查看答案提示
问题 1:
- (b) 应该使用聚类随机化(以部门为单位),因为同部门员工之间存在溢出效应
- (c) 挑战:Hawthorne 效应、测量生产力困难、样本流失(员工离职)
问题 2:
- (a) RCT 仍然有效,但年龄变量不平衡
- (b) 必须在回归中控制年龄
问题 3:
- 控制变量吸收了结果变量的部分方差
- 降低残差标准差 → 降低标准误 → 提高统计功效
下一步
下一节我们将深入学习 平均处理效应(ATE, ATT, LATE),以及如何处理 RCT 中的不依从问题。
核心问题预告:
- ATE、ATT、LATE 有什么区别?
- 什么是意向性分析(ITT)?
- 如何使用工具变量处理不依从?
继续!
参考文献:
- Duflo, E., Glennerster, R., & Kremer, M. (2007). "Using randomization in development economics research: A toolkit". Handbook of Development Economics.
- Angrist, J. D., & Pischke, J. S. (2008). Mostly Harmless Econometrics. Chapter 2.
- Athey, S., & Imbens, G. W. (2017). "The econometrics of randomized experiments". Handbook of Field Experiments.