Skip to content

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)

方法:每个个体独立随机分配

python
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 实现

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 实现

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 实现

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. 描述性统计对比

python
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.91

2. t 检验(连续变量)

python
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. 卡方检验(分类变量)

python
# 添加分类变量
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 检验

python
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:简单差分

python
# 最简单的 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:回归估计

python
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:回归 + 控制变量

python
# 添加控制变量
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 人)
    • 普通班 + 助教
  • 单位:学生(学校内随机化)

结果

  • 小班学生成绩显著更高
  • 效应持续到高中甚至成年
  • 对弱势群体效应更大

数据:公开数据集,广泛用于教学

python
# 加载 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

python
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 检验
估计方法简单差分、回归、回归+控制
优势内部有效性最强
局限成本高、伦理问题、外部有效性

关键洞察

  1. 随机化是因果推断的终极武器

    • 消除所有(可观测和不可观测的)混淆因素
    • 让简单对比就能得到无偏估计
  2. 平衡性检验是质量保证

    • 不是假设检验(希望不显著)
    • 不平衡时必须加控制变量
  3. RCT 不是万能的

    • 很多重要问题无法做 RCT(伦理、可行性)
    • 需要结合准自然实验方法

思考题

  1. 设计题:你想研究"远程办公对员工生产力的因果效应"。

    • (a) 设计一个 RCT
    • (b) 应该使用哪种随机化类型?为什么?
    • (c) 可能遇到哪些实施挑战?
  2. 分析题:某 RCT 有 1000 人,随机分配后发现:

    • 处理组平均年龄 35 岁,对照组 30 岁(p = 0.02)
    • 其他变量都平衡(p > 0.1)

    问题:

    • (a) 这个 RCT 有效吗?
    • (b) 应该如何分析数据?
  3. 理解题:为什么 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.

基于 MIT 许可证发布。内容版权归作者所有。