倾向性评分匹配 (Propensity Score Matchi

2022-01-28  本文已影响0人  数科每日

本文参考自 Propensity Score Matching,Zolzaya Luvsandorj


Propensity Score Matching 是观察性因果研究中最常用的一种 Matching Sample 的手段, 本文通过一个例子来介绍这种方法。

术语

在开始之前, 我们先介绍三个术语

image.png

观察数据与随机分组

为了研究因果关系, 我们一般都会通过随机实验来控制干预变量, 随机实验中的随机分组可以排除一切混淆变量, 从而保证实验的有效性。 但是有些时候,我们无法进行实验, 可供研究的只有已经生成的数据。 由于没有经过随机分组, 没有排除混淆变量, 这时 Treatment variable 是不可用的。

随机分组的数据 观察性数据, 分组不随机

1 倾向性评分匹配 Propensity score matching

倾向得分匹配是一种非实验性的因果推理技术。它试图在混淆变量上平衡干预组合对照组,使它们具有可比性,以便我们可以使用观察数据得出干预变量的因果效用的结论, 它一般分为5个步骤

1.1 收集数据

在进行 Propensity score matching, 最重要的一步就是收集数据。 在收集数据的时候, 一定要把所有可能的Confounding variable 都囊括进来。 如果有重要的Confounding variable 没有被纳入到数据集中, 那么最后的匹配就很可能是无效的。

本例采用泰坦尼克号数据, 为了简洁, 我们只使用少量的变量作为 混淆变量。

混淆变量

这里, 我们试图调查:购买三等舱, 是否会影响到最后的生存率。 而我们认为年纪,性别可能会同时影响到是否购买三等舱(年纪,性别也会影响经济水平) 和是否获救(老人,妇女,儿童被优待)。

import numpy as np
import pandas as pd
pd.options.display.float_format = "{:.2f}".format
import matplotlib.pyplot as plt
import seaborn as sns
sns.set(style='darkgrid', context='talk')
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.metrics import roc_auc_score, f1_score
from causalinference import CausalModel
df = sns.load_dataset('titanic')
df['is_pclass3'] = df['pclass']==3
df['is_female'] = df['sex']=='female'
df = df.filter(['survived', 'is_pclass3', 'is_female', 'age'])\
       .dropna().reset_index(drop=True)
df
image.png
TREATMENT = 'is_pclass3'
OUTCOME = 'survived'
df.groupby(TREATMENT)[OUTCOME].describe()

首先,我们先直接看一下三等舱与生存率的关系:

image.png

三等舱生存率是 24%, 非三等舱生存率为 57%。

接下来,我们看一下,在不同的混淆变量下, 干预组(三等舱) 与对照组(其他舱)获救率的分布。

C_COLOUR = 'grey'
T_COLOUR = 'green'
C_LABEL = 'Control'
T_LABEL = 'Treatment'
sns.kdeplot(data=df[~df[TREATMENT]], x='age', shade=True, 
            color=C_COLOUR, label=C_LABEL)
sns.kdeplot(data=df[df[TREATMENT]], x='age', shade=True, 
            color=T_COLOUR, label=T_LABEL)
plt.legend();

可以看到,买三等舱票的人中, 年轻人居多。

image.png

接下来, 我们看看性别:

F_COLOUR = 'magenta'
M_COLOUR = 'blue'
F_LABEL = 'Female'
M_LABEL = 'Male'
gender = 100 * pd.crosstab(df[TREATMENT].replace({True: T_LABEL, 
                                                  False: C_LABEL}), 
                           df['is_female'].replace({True: 'Female',
                                                    False: 'Male'}), 
                           normalize='index')
gender['All'] = 100
plt.figure(figsize=(5, 4))
sns.barplot(data=gender, x=gender.index.astype(str),  y="All", 
            color=M_COLOUR, label=M_LABEL)
sns.barplot(data=gender, x=gender.index.astype(str),  y='Female', 
            color=F_COLOUR, label=F_LABEL)
plt.legend(loc='center', bbox_to_anchor=(1.3, 0.8))
plt.xlabel('')
plt.ylabel('Percentage');
image.png

很明显, 两组中性别组成成分不一致。

从上面的分析可以看出, 干预组与控制组的 年龄,性别组成成分差异比较大, 因此我们不能不考虑这些因素,直接得出三等舱影响生存率的结论。

1.2 计算 Propensity Scores

Propensity Scores 其实就是用混淆变量(age, is_female)来回归是否进入干预组(is_pclass3), 因为 “是否进入干预组” 是一个二分类变量(0或者1表示), 我们可以用 Logistic Regression。

# Build a descriptive model
t = df[TREATMENT]
X = pd.get_dummies(df.drop(columns=[OUTCOME, TREATMENT]))
pipe = Pipeline([
    ('scaler', StandardScaler()),
    ('logistic_classifier', LogisticRegression())
])
pipe.fit(X, t)
# Predict
threshold = 0.5
df['proba'] = pipe.predict_proba(X)[:,1]
df['logit'] = df['proba'].apply(lambda p: np.log(p/(1-p)))
df['pred'] = np.where(df['proba']>=threshold, 1, 0)
df.head()
image.png

proba 就是 Propensity Scores, 他代表该条记录被分配到 干预组中的概率。

接着我们看一下得到的模型的性能:

print(f"Accuracy: {np.mean(df[TREATMENT]==df['pred']):.4f},\
 ROC AUC: {roc_auc_score(df[TREATMENT], df['proba']):.4f},\
 F1-score: {f1_score(df[TREATMENT], df['pred']):.4f}")
# Visualise confusion matrix
pd.crosstab(df[TREATMENT], df['pred']).rename(columns={0: False, 
                                                       1:True})
模型性能

我们看一下 prob 和对应的 logit 在 control 和 treatment 组上的分布:

fig, ax = plt.subplots(1,2, figsize=(10,4))
# Visualise propensity
sns.kdeplot(data=df[~df[TREATMENT]], x='proba', shade=True, 
            color=C_COLOUR, label=C_LABEL, ax=ax[0])
sns.kdeplot(data=df[df[TREATMENT]], x='proba', shade=True, 
            color=T_COLOUR, label=T_LABEL, ax=ax[0])
ax[0].set_title('Propensity')
ax[0].legend(loc='center', bbox_to_anchor=(1.1, -0.3))
# Visualise logit propensity
sns.kdeplot(data=df[~df[TREATMENT]], x='logit', shade=True, 
            color=C_COLOUR, label=C_LABEL, ax=ax[1])
sns.kdeplot(data=df[df[TREATMENT]], x='logit', shade=True, 
            color=T_COLOUR, label=T_LABEL, ax=ax[1])
ax[1].set_title('Logit Propensity')
ax[1].set_ylabel("");
image.png

我们可以看到, 干预组和控制组有重叠部分, 这对接下来的匹配来说, 是个好消息。

1.3 匹配

我们依据 Propensity Score , 把干预组和对照组中的得分最接近样本进行一对一匹配, 注意, 在匹配中会重复使用样本, 因此有些样本会在结果中出现多次。

# Sort by 'logit' so it's quicker to find match
df.sort_values('logit', inplace=True)
n = len(df)-1
for i, (ind, row) in enumerate(df.iterrows()): 
    # Match the most similar untreated record to each treated record
    if row[TREATMENT]:
        # Find the closest untreated match among records sorted 
        # higher. 'equal_or_above would' be more accurate but 
        # used 'above' for brevity        
        if i<n:
            above = df.iloc[i:]
            control_above = above[~above[TREATMENT]]
            match_above = control_above.iloc[0]
            distance_above = match_above['logit'] - row['logit']
            df.loc[ind, 'match'] = match_above.name
            df.loc[ind, 'distance'] = distance_above
        
        # Find the closest untreated match among records sorted 
        # lower. 'equal_or_below' would be more accurate but 
        # used 'below' for brevity  
        if i>0:
            below = df.iloc[:i-1]
            control_below = below[~below[TREATMENT]]
            match_below = control_below.iloc[-1]
            distance_below = match_below['logit'] - row['logit']
            if i==n:
                df.loc[ind, 'match'] = match_below.name
                df.loc[ind, 'distance'] = distance_below
            
            # Only overwrite if match_below is closer than match_above
            elif distance_below<distance_above:
                df.loc[ind, 'match'] = match_below.name
                df.loc[ind, 'distance'] = distance_below
df[df[TREATMENT]]
image.png

我们重新创建一个 data frame

indices = df[df['match'].notna()].index.\
          append(pd.Index(df.loc[df['match'].notna(), 'match']))
matched_df = df.loc[indices].reset_index(drop=True)
matched_df
image.png
1.4 评估匹配结果
COLUMNS = ['age', 'is_female', OUTCOME]
matches = pd.merge(df.loc[df[TREATMENT], COLUMNS+['match']], 
                   df[COLUMNS], left_on='match', 
                   right_index=True, 
                   how='left', suffixes=('_t', '_c'))
matches
image.png
for var in ['logit', 'age']:
    print(f"{var} | Before matching")
    display(df.groupby(TREATMENT)[var].describe())
    print(f"{var} | After matching")
    display(matched_df.groupby(TREATMENT)[var].describe())
image.png

可以看到, 匹配后的干预组和对照组非常接近了。 我们在看一下在年龄上的分布

for var in ['logit', 'age']:
    fig, ax = plt.subplots(1,2,figsize=(10,4))
    # Visualise original distribution
    sns.kdeplot(data=df[~df[TREATMENT]], x=var, shade=True, 
                color=C_COLOUR, label=C_LABEL, ax=ax[0])
    sns.kdeplot(data=df[df[TREATMENT]], x=var, shade=True, 
                color=T_COLOUR, label=T_LABEL, ax=ax[0])
    ax[0].set_title('Before matching')
    
    # Visualise new distribution
    sns.kdeplot(data=matched_df[~matched_df[TREATMENT]], x=var, 
                shade=True, color=C_COLOUR, label=C_LABEL, ax=ax[1])
    sns.kdeplot(data=matched_df[matched_df[TREATMENT]], x=var, 
                shade=True, color=T_COLOUR, label=T_LABEL, ax=ax[1])
    ax[1].set_title('After matching')
    ax[1].set_ylabel("")
    plt.tight_layout()
ax[0].legend(loc='center', bbox_to_anchor=(1.1, -0.3));
image.png

可以看大, matching 以后的数据集平衡性好了很多。 我们在看一下性别:

fig, ax = plt.subplots(1, 2, figsize=(10, 4))
# Visualise original distribution
sns.barplot(data=gender, x=gender.index.astype(str), y="All", 
            color=M_COLOUR, label=M_LABEL, ax=ax[0])
sns.barplot(data=gender, x=gender.index.astype(str), y='Female', 
            color=F_COLOUR, label=F_LABEL, ax=ax[0])
ax[0].legend(loc='center', bbox_to_anchor=(1.1, -0.3))
ax[0].set_xlabel('')
ax[0].set_ylabel('Percentage')
ax[0].set_title('Before matching')
# Visualise new distribution
gender_after = 100 * pd.crosstab(
    matched_df[TREATMENT].replace({True: T_LABEL, False: C_LABEL}), 
    matched_df['is_female'].replace({True: 'Female', False: 'Male'}), 
    normalize='index'
)
gender_after['All'] = 100
sns.barplot(data=gender_after, x=gender_after.index.astype(str), 
            y="All", color=M_COLOUR, label=M_LABEL, ax=ax[1])
sns.barplot(data=gender_after, x=gender_after.index.astype(str), 
            y='Female', color=F_COLOUR, label=F_LABEL, ax=ax[1])
ax[1].set_xlabel('')
ax[1].set_title('After matching')
ax[1].set_ylabel('');
image.png
1.5 在匹配后的数据上进行评估
summary = matched_df.groupby(TREATMENT)[OUTCOME]\
                    .aggregate(['mean', 'std', 'count'])
summary
image.png

接着,我们评估一下 ATT 和 ATE。 关于这些指标的意义,请参考[因果推断常用评估指标]。(https://www.jianshu.com/p/5ebb3b2a8f55)

y = df[OUTCOME].values
t = df[TREATMENT].values
X = df[['is_female', 'age']]
X = pd.DataFrame(StandardScaler().fit_transform(X), 
                 columns=X.columns).values
model = CausalModel(y, t, X)
model.est_via_matching()
print(model.estimates)
image.png

通过ATT, 我们可以得出三等舱确实会影响生存率的结论(降低)。 虽然和我们一开始得出的结论相同, 但是由于排除了混淆因素,现在的结论更加有效。

上一篇 下一篇

猜你喜欢

热点阅读