因果推断《Causal Inference in Python》中文笔记第4章 线性回归的不合理有效性

《Causal Inference in Python: Applying Causal Inference in the Tech Industry》因果推断啃书系列

  第1章 因果推断导论
  第2章 随机实验与统计学回顾
  第3章 图形化因果模型
  第4章 线性回归的不合理有效性
  第5章 倾向分
  第6章 效果异质性
  第7章 元学习器
  第8章 双重差分

  持续更新中:
  第9章 综合控制
  第10章 Geo实验与Switchback实验
  第11章 不依从性与工具
  第12章 后续行动

第4章 线性回归的不合理有效性

在本章中,你将在因果推断中加入第一个主要的去偏技术:线性回归,或者说是最小二乘法(OLS)和正交化。您将看到在估算干预和结果之间的关系时,线性回归如何调整混杂因子。但更重要的是,我希望你了解干预正交化这个强大的概念。这是在线性回归中诞生的想法,在你开始使用机器学习模型进行因果推断时将派上用场。

4.1 你所需要的就是线性回归

你看到这章的标题时可能会说:“哦,回归是如此简单,是我作为数据科学家学到的第一个模型”,然后直接跳到下一章。等等,让我向你保证,你实际上不知道线性回归。事实上,回归是因果推断中最迷人、最强大、最危险的模型之一。当然,它有一百多年的历史了,但是直到今天,它仍然经常让最优秀的因果推断研究者措手不及。

OLS的研究

不相信?只要看看最近发表的一些关于这个主题的论文,你就会明白。安德鲁·古德曼-培根(Andrew Goodman-Bacon)的文章《干预时间变量的双重差分》(Difference-in-Differences with Variation in Treatment Timing),或者Tymon Słoczyński的文章《干预效果异质时OLS估算的解释》(Interpreting OLS Estimands When Treatment Effects Are Heterogeneous),甚至是Goldsmith-Pinkham等人的文章《线性回归中的污染偏差》(Contamination Bias in Linear regression),你可以阅读这些论文开始了解不简单的线性回归。

我向你保证:回归不仅是因果推断的主力,而且将是你使用最多的工具。回归也是更先进技术的主要组成部分,如大多数的面板数据方法(双重差分和双向固定效应)、机器学习方法(双重/去偏机器学习)和替代识别技术(工具变量或断点设计)。

4.1.1 为什么需要模型

既然我已经说服你留下了,我们可以开始谈正事了。为了激励大家使用回归,让我们考虑一个在银行和一般贷款行业中相当具有挑战性的问题:理解贷款金额或信用卡限额对违约率的影响。当然,增加某人的信用卡限额会增加(或至少不会减少)他们拖欠信用卡账单的可能性。但是,如果你看任何银行数据,你会发现信用额度和违约率之间是负相关的。显然,这并不意味着更高的线导致客户违约减少。相反,它只是反映了干预分配机制:银行和贷款公司向违约几率较低的客户提供更多信贷,正如它们的承销模型所认为的那样。你看到的负相关是混杂偏差的影响:

import warnings
warnings.filterwarnings('ignore')

import pandas as pd
import numpy as np
import graphviz as gr
from matplotlib import style
import seaborn as sns
from matplotlib import pyplot as plt
style.use("ggplot")
import statsmodels.formula.api as smf
import matplotlib
from cycler import cycler

default_cycler = (cycler(color=['0.3', '0.5', '0.7', '0.5']))

color=['0.3', '0.5', '0.7', '0.9']
linestyle=['-', '--', ':', '-.']
marker=['o', 'v', 'd', 'p']

plt.rc('axes', prop_cycle=default_cycler)

gr.set_default_format("png");
g_risk = gr.Digraph(graph_attr={"rankdir":"LR"})

g_risk.edge("Risk", "X")
g_risk.edge("X", "Credit Limit")
g_risk.edge("X", "Default")
g_risk.edge("Credit Limit", "Default")

g_risk

在这里插入图片描述

道违约的固有风险,但它可以使用收入或信用评分等代理变量来估计违约风险。在前几章中,你看到了如何调整变量,使干预看起来像是随机分配的。具体来说,你看到了调整公式:
A T E = E x { E ( Y ∣ T = 1 , X = x ) − E ( Y ∣ T = 0 , X = x ) } ATE=E_x\{E(Y|T=1,X=x)-E(Y|T=0,X=x)\} ATE=Ex{E(YT=1,X=x)E(YT=0,X=x)}
和条件独立性假设 ( Y 0 , Y 1 ) ⊥ T ∣ X (Y_0,Y_1) \perp T|X (Y0,Y1)TX 是如何让你识别因果效应的。

然而,如果你真的应用调整公式,事情可能很快就会失控。首先,你需要将数据划分为由特征X定义的片段。如果你只有很少的离散特征,这将是很好的选择。但如果有很多特征,其中一些还是拥有连续的值呢?例如,假设你知道银行使用了10个变量,每个变量有3个组,来承保客户并分配信贷额度。这看起来并不多,对吧?但是已经有 3 10 = 59049 3^{10}=59049 310=59049 个组合了。只有在有大量数据的情况下,才有可能估计每组中的ATE并计算平均结果。这是维度的诅咒,是大多数数据科学家非常熟悉的问题。在因果推断的背景下,这个诅咒的一个含义是,如果你有很多协变量,简单地应用调整公式将受到数据匮乏的影响。

因果推断术语 vs 机器学习术语

机器学习是大多数数据科学家所熟悉的,关于机器学习的文献使用了与因果推断文献不同的术语,因果推断文献通常来自计量经济学或流行病学。所以,当你需要从一种语言转换到另一种语言时,以下是你会遇到的一些主要的等价词:

特征(Feature)

  变量(variables),包括协变量(Covariates)和自变量(Independent Variables)

权重(Weights)

  参数(Parameters)或系数(Coefficients)

目标(Target)

  结果(Outcome)或因变量(Dependent Variable)

解决维度问题的一种方法是假设潜在结果可以用类似线性回归的东西来建模,它可以内插和外推X单独定义的众多小组。在这里你可以把线性回归看作是一种降维算法。它将X的所以变量投射到结果维度并在那个投射上对干预和对照进行比较,非常优雅。但这样描述其实有些过于简单了,要真正理解回归,你必须从小处着手:在A/B测试的背景下进行回归。

4.1.2 A/B测试中的回归

假设你在一家在线流媒体公司工作,完善它的推荐系统。你的团队刚刚完成了这个系统的新版本,使用了尖端技术和来自机器学习社区的最新想法。虽然这一切都令人印象深刻,但你的经理真正关心的是,这个新系统是否会增加流媒体服务的观看时间。为了测试这一点,你决定进行A/B测试。首先,你从你的客户群中抽取一个具有代表性但比例很小的样本。然后,将新的推荐系统部署到该样本的1/3中,而其余的则继续使用旧版本的推荐系统。一个月后,你收集了每天平均观看时间的结果:

import pandas as pd
import numpy as np

data = pd.read_csv("./data/rec_ab_test.csv")
data.head()
recommenderagetenurewatch_time
0challenger1512.39
1challenger2712.32
2benchmark1702.74
3benchmark3411.92
4benchmark1412.47

由于推荐的版本是随机的,简单比较不同版本之间的平均观看时间就可以得出ATE。但是,你必须解决计算标准误差带来的所有麻烦,得到置信区间,以检查统计显著性。那么,如果我告诉你,可以使用回归来解释A/B测试的结果,这将免费为你提供所需的所有推断统计数据,你会怎样应答?回归背后的思想是,你需要估算以下公式模型:

W a t c h T i m e i = β 0 + β 1 c h a l l e n g e r i + e i WatchTime_i=\beta_0+\beta_1 challenger_i+e_i WatchTimei=β0+β1challengeri+ei

其中 c h a l l e n g e r challenger challenger 为1时表示该组用户对应的是新的推荐系统,否则为0。如果你估算这个模型,修改版本的影响可以使用 β 1 \beta_1 β1 的估算值 β 1 ^ \hat{\beta_1} β1^ 来表示。

要在Python中运行该回归模型,您可以使用statmodels.formula的API,它允许你使用R语言风格的公式简洁地表达线性模型。例如,您可以使用公式‘watch_time ~ C(recommender)’来表示前面的模型。要估算该模型,只需调用方法.fit(),然后对先前拟合的模型调用.summary()读取结果:

import statsmodels.formula.api as smf

result = smf.ols('watch_time ~ C(recommender)', data=data).fit()

result.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept2.04910.05835.3670.0001.9352.163
C(recommender)[T.challenger]0.14270.0951.5010.134-0.0440.330

在R语言类型公式中,结果变量排在前面,然后是符号“~”,最后是解释变量。在本例中,您将只使用recommender变量,该变量具有两个分类(challenger表示干预,benchmark 表示对照)。你可以将该变量包装在C(…)中,以显式地声明该列是用来分类的。

稻草人程序库

公式语法糖是一种非常方便的特征工程方法。你可以在稻草人程序库了解更多。

我们来看看上面的结果。第一行是截距(Intercept),是公式模型 β 0 \beta_0 β0 参数的估算值,表示当公式模型中其他参数为零时,结果的期望。由于公式模型唯一的变量是challenger符号,所以对于收到旧版本推荐系统的用户,可以将截距解释为预期的观看时间。所以上面的数据表示,当使用旧版本的推荐系统时,客户平均每天花费2.04小时观看你的流媒体内容。最后,看看新版本推荐系统相关的参数 β 1 ^ \hat{\beta_1} β1^ 的估算值,你可以看到这个新版本使得观看时间增加了。如果 β 0 ^ \hat{\beta_0} β0^ 表示旧推荐系统下观看时长的估算值, β 0 ^ + β 1 ^ \hat{\beta_0}+\hat{\beta_1} β0^+β1^ 则是新推荐系统下的预期观看时长。也就是说, β 1 ^ \hat{\beta_1} β1^ 是ATE的估算。由于新系统的发放是随机化的,你可以给这一估赋予因果意义:你可以说,新的推荐系统平均每天增加了0.14小时的观看时间。然而,这一结果在统计上并不显著。

暂时忘掉这个不重要的结果,因为你刚刚做的事情非常惊人。你不仅估算了ATE,还免费得到了置信区间和p值!不仅如此,你可以看到回归正在做我们希望它完成事情——估算每种干预下的E(Y|T):

(data
 .groupby("recommender")
 ["watch_time"]
 .mean())
recommender
benchmark     2.049064
challenger    2.191750
Name: watch_time, dtype: float64

就像前面说的,截距在数学上等价于那些对照组(旧版本推荐系统)的人的平均观看时长。

可以看到两次计算的结果是相同的,因为在这种情况下,回归在数学上等同于在平均值之间进行简单的比较。这也意味着 β 1 ^ \hat{\beta_1} β1^ 是两组之间的平均差异: 2.191 − 2.049 = 0.1427 2.191-2.049=0.1427 2.1912.049=0.1427。你成功地用回归法再现了各组结果的平均值。但这样做真正的好处是什么呢?

4.1.3 使用回归进行调整

为了理解回归的力量,让我带你回到最初的例子:估计信用额度对违约的影响。银行数据通常看起来像这样,有一些可能表征信用可靠性的客户特征,比如月薪、信用机构提供的大量信用评分、在当前公司的任期等。还有给该客户的信用额度(在本例中是干预)、客户是否违约的数据列(结果变量):

risk_data = pd.read_csv("./data/risk_data.csv")

risk_data.head()
wageeducexpermarriedcredit_score1credit_score2credit_limitdefault
0950.011161500.0518.03200.00
1780.01171414.0429.01700.00
21230.01491586.0571.04200.00
31040.01581379.0411.01500.00
41000.01611379.0518.01800.00

模拟数据

再一次,我从真实世界的数据中构建并修改,以适应本章的需要。这一次,我使用的是由Jeffrey M. Wooldridge教授策划的wage1数据,它可以在R语言“Wooldridge”包中找到。

在这里,干预credit_limit有太多的类别,最好将其视为一个连续变量,而不是一个分类变量。与其将ATE表示为多个层次干预之间的差异,不如将其表示为关于干预的预期结果的导数:

A T E = ∂ ∂ t E ( y ∣ t ) ATE=\frac{\partial}{\partial t}E(y|t) ATE=tE(yt)

公式看起来很花哨,但是含义很简单,它是说在干预发生单位增量的情况下,结果预期会变化的数量。在这个例子中,它表示在信用额度增加1美元的情况下,你预计违约率会发生多大变化。

估算这个量的一种方法就是使用回归。具体来说,可以对上面的模型进行估算:

D e f a u l t i = β 0 + β 1 l i m i t i + e i Default_i=\beta_0+\beta_1 limit_i+e_i Defaulti=β0+β1limiti+ei

其中 β 1 ^ \hat{\beta_1} β1^ 可以解释为,在限额增加1美元的情况下,风险预期会发生的变化金额。如果限额是随机的,则该参数具有因果解释。但你很清楚,限额不可能是随机的,因为银行倾向于给风险较低的客户更高的额度。事实上,如果你运行前面的模型, β 1 \beta_1 β1 会得到一个负值:

model = smf.ols('default ~ credit_limit', data=risk_data).fit()
model.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept0.21920.00459.7150.0000.2120.226
credit_limit-2.402e-051.16e-06-20.6890.000-2.63e-05-2.17e-05

这一点也不奇怪,由于混杂,风险和信用限额之间是负相关关系。如果你将拟合的回归线与信用限额的平均违约率绘制在一起,你可以清楚地看到负相关趋势:

plt_df = (risk_data
          .assign(size=1)
          .groupby("credit_limit")
          .agg({"default": "mean", "size":sum})
          .reset_index()
          .assign(prediction = lambda d: model.predict(d)))

plt.figure(figsize=(10,4))

sns.scatterplot(data=plt_df,
                x="credit_limit",
                y="default",
                size="size",
                sizes=(1,100))

plt.plot(plt_df["credit_limit"], plt_df["prediction"], color="C1")
plt.title("Default Rate by Credit Limit")

Text(0.5, 1.0, 'Default Rate by Credit Limit')

在这里插入图片描述
为了调整这种偏差,理论上,您可以通过所有混杂因子分割数据,在每个部分内对信用额度的违约情况进行回归,提取斜率参数,并对所有结果进行平均。然而,由于维度的诅咒,即使你尝试对中等数量的混杂因子(都是信用评分)这样做,你也会看到只有一个样本的分组,这使得你无法运行回归。更不用说许多分组是空的:

risk_data.groupby(["credit_score1", "credit_score2"]).size().head()
credit_score1  credit_score2
34.0           339.0            1
               500.0            1
52.0           518.0            1
69.0           214.0            1
               357.0            1
dtype: int64

谢天谢地,回归在这里再次帮助了你。你可以简单地将它们添加到OLS估算的模型中,而不是手动调整混杂因子:
D e f a u l t i = β 0 + β 1 l i m i t i + θ X i + e i Default_i=\beta_0+\beta_1 limit_i+\theta X_i+e_i Defaulti=β0+β1limiti+θXi+ei

其中 X X X 表示混杂因子变量组成的向量, θ \theta θ 是与这些混杂因子相关的参数向量。参数向量 θ \theta θ 没有什么特别的地方,它们起到的作用就像是和 β 1 \beta_1 β1 一样。我用不同的方式表示它们,因为它们只是为了帮助你得到一个 β 1 \beta_1 β1 的无偏估计。也就是说,你并不真正关心它们的因果解释,它们在技术上被称为多余参数(Nuisance Parameters)。

在信用额度示例中,您可以将信用评分和工资混杂因子添加到模型中。它看起来是这样的:
D e f a u l t i = β 0 + β 1 l i m i t i + θ 1 w a g e i + θ 2 c r e d i t S c o r e 1 i + θ 3 c r e d i t S c o r e 2 i + e i Default_i=\beta_0+\beta_1 limit_i+\theta_1 wage_i+\theta_2 creditScore1_i+\theta_3 creditScore2_i+e_i Defaulti=β0+β1limiti+θ1wagei+θ2creditScore1i+θ3creditScore2i+ei

我将更详细地介绍在模型中包含变量是如何调整混杂因子的,但现在有一个非常简单的方法来了解它。前面的模型是为了计算 E ( y ∣ t , X ) E(y|t,X) E(yt,X),回想一下,你需要 ∂ ∂ t E ( y ∣ t , X ) \frac{\partial}{\partial t}E(y|t,X) tE(yt,X),所以如果你对上面的模型求关于干预(信用额度)的微分会发生什么?你会很容易地得到 β 1 \beta_1 β1!在某种意义上,可以看作是违约的期望值对信用额度的偏导数。或者,更直观地说,它可以被视为,在保持模型中所有其他变量不变的情况下,在信贷限额小幅增加的情况下,违约率预期会发生多少变化。这种解释已经告诉你回归是如何对混杂因子进行调整的:它在估算干预和结果之间的关系时使混杂因子保持固定。

要查看实际操作,可以对前面的模型进行估算。只要添加一些混淆因子,就像某种魔法一样,信用额度和违约率之间变成了正相关关系!

formula = 'default ~ credit_limit + wage+credit_score1+credit_score2'
model = smf.ols(formula, data=risk_data).fit()
model.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept0.40370.00946.9390.0000.3870.421
credit_limit3.063e-061.54e-061.9870.0474.16e-086.08e-06
wage-8.822e-056.07e-06-14.5410.000-0.000-7.63e-05
credit_score1-4.175e-051.83e-05-2.2780.023-7.77e-05-5.82e-06
credit_score2-0.00031.52e-05-20.0550.000-0.000-0.000

不要让小小的估算值欺骗了你。回想一下,当违约default为0或1时,额度limit的范围是1000s。因此,增加1美元的额度将使预期违约增加一个很小的数字,这并不奇怪。不过,这个数字在统计上是显著的,它告诉你,随着你提高信用额度,风险会增加,这更符合你对世界运行的直觉。

保持这种想法,因为你要更正式地探索它。最后是时候学习最伟大的因果推断工具之一:弗里希-瓦夫-洛维尔(FWL)定理。这是一种难以置信的消除偏差的方法,不幸的是,数据科学家很少知道这一点。FWL是理解更高级去偏方法的先决条件,但我发现它最有用的原因是它可以用作去偏预处理。还是使用银行的例子,想象一下这家银行的许多数据科学家和分析师正在试图理解信贷限额是如何影响(导致)许多不同的业务指标,而不仅仅是风险。然而,只有你知道信用额度是如何分配的,这意味着你是唯一知道什么样的偏差困扰着信用额度干预的专家。有了FWL,你可以使用该知识来消除信用限额数据的偏差,其他人也可以使用这个方法,不管他们对什么结果变量感兴趣。弗里希-瓦夫-洛维尔(Frisch-Waugh-Lovell)定理允许你将去偏步骤与影响估算步骤分开。但是为了学习它,你必须首先快速复习一些回归理论。

4.2 回归理论

我不打算深入探讨线性回归是如何构建和估算的。然而,一点理论就能很好地解释它在因果推断中的作用。首先,回归解决了最佳线性预测问题。将 β ∗ \beta^* β 设为参数向量:
β ∗ = a r g m i n β E [ ( Y i − X i ′ β ) 2 ] \beta^*=\underset{\beta}{argmin} E[(Y_i-X^\prime_i \beta)^2] β=βargminE[(YiXiβ)2]

线性回归可以找到使均方误差(MSE)最小化的参数。如果你对它求导,并使其为0,你会发现这个问题的线性解为:
β ∗ = E ( X ′ X ) − 1 E ( X ′ Y ) \beta^*=E(X^\prime X)^{-1}E(X^\prime Y) β=E(XX)1E(XY)

你可以使用样本数据来估算这个 β \beta β
β ^ = ( X ′ X ) − 1 X ′ Y \hat\beta=(X^\prime X)^{-1}X^\prime Y β^=(XX)1XY

但不要相信我的话。如果相比理解公式,你更懂代码,你可以自己试试,在下面的代码中,我将使用OLS的代数解来估算你刚才看到的模型的参数(我将添加截距作为最终变量,所以第一个参数估算值是 β 1 ^ \hat{\beta_1} β1^):

X_cols = ["credit_limit", "wage", "credit_score1", "credit_score2"]
X = risk_data[X_cols].assign(intercep=1)
y = risk_data["default"]

def regress(y, X): 
    return np.linalg.inv(X.T.dot(X)).dot(X.T.dot(y))

beta = regress(y, X)
beta
array([ 3.06252773e-06, -8.82159125e-05, -4.17472814e-05, -3.03928359e-04,
        4.03661277e-01])

如果你回头看一下,你会发现这些数字与你之前用statmodels的ols函数估算模型时得到的数字完全相同。

赋值

我倾向于经常使用pandas中的.assign()方法。如果你不熟悉它,它只是返回一个新的数据框,其中包含你传给这个方法而新创建的列。

4.2.1 一元线性回归

上一节的 β ^ \hat\beta β^ 公式非常一般化。然而,研究只有一个回归因子的特殊情况也是值得的。在因果推断中,你经常想要估算一个变量 T T T 对一个结果 y y y 的因果影响。因此,你可以使用这个单一变量的回归来估算这种影响。

对于单一回归变量 T T T,与之相关的参数为:
τ ^ = C o v ( Y i , T i ) V a r ( T i ) = E [ ( T i − T ‾ ) ( Y i − Y ‾ ) ] E [ ( T i − T ‾ ) 2 ] \hat\tau=\frac{Cov(Y_i,T_i)}{Var(T_i)}=\frac{E[(T_i-\overline{T})(Y_i-\overline{Y})]}{E[(T_i-\overline{T})^2]} τ^=Var(Ti)Cov(Yi,Ti)=E[(TiT)2]E[(TiT)(YiY)]

如果 T T T 是随机分配的, β 1 \beta_1 β1 则为ATE。重要的是,通过这个简单的公式,你可以看到回归在做什么。它发现干预和结果是如何一起变化的(通过分子中的协方差表示),并将变化缩放为干预每单位的变化对应的量,这是通过除以干预的方差来实现的。

你也可以把它和一般公式联系起来。协方差和点积密切相关,所以可以说 X ′ X X^\prime X XX 在协方差/方差公式中是分母,而 X ′ Y X^\prime Y XY 是分子。

4.2.2 多元线性回归

其实还有另一种理解多元线性回归的方法,超越了你之前看到的一般公式。这种方式揭示了回归正在做什么。

如果你有多个回归量,你可以扩展一元回归公式以适应。假设这些其他变量只是辅助变量,你真正感兴趣的是估算与 T T T 相关的参数 t a u tau tau
y i = β 0 + τ T i + β 1 X 1 i + . . . + β k i + u i y_i=\beta_0+\tau T_i+\beta_1 X_{1i}+...+\beta_{ki}+u_i yi=β0+τTi+β1X1i+...+βki+ui

τ \tau τ 可以使用下面的公式来估算:
τ ^ = C o v ( Y i , T i ~ ) V a r ( T i ~ ) \hat\tau=\frac{Cov(Y_i,\tilde{T_i})}{Var(\tilde{T_i})} τ^=Var(Ti~)Cov(Yi,Ti~)

其中 T i ~ \tilde{T_i} Ti~ T i T_i Ti 对所有其他协变量 X 1 i + . . . + X k i X_{1i}+...+X_{ki} X1i+...+Xki 的回归残差(Residual)。

现在,让我们欣赏一下这有多酷。这意味着多元回归的系数是在考虑了模型中其他变量的影响后,同一回归量的双变量系数。用因果推理术语来说,使用所有其他变量预测 T T T 后, τ \tau τ T T T 的双变量系数。

这背后的直觉非常美妙。如果你能够使用别的变量预测 T T T,意味着 T T T 不是随机的。不过,一旦你控制了所有的混杂变量,就可以使 T T T 看起来像是随机的。这样,你可以使用线性回归从混杂因子中预测 T T T,然后取该回归的残差 T ~ \tilde{T} T~。根据定义, T ~ \tilde{T} T~ 不能被你已经用来预测 T T T 的其他变量 X X X 预测。 T ~ \tilde{T} T~ 是干预的与其他变量无关的一个版本。

这有点拗口,但实在太美妙了。这就是FWL定理的作用。如果你还没理解多元回归这部分也不用担心,因为你会用一种更直观的方式来复习它。

4.3 FWL定理与正交化(Orthogonalization)

FWL式正交化是你可以使用的第一个主要去偏技术。这是一种简单而有力的方法,可以让非实验数据看起来像是随机的干预。FWL主要是关于线性回归;FWL式正交化已经扩展到更一般的环境中,正如你将在第三部分中看到的那样。Frisch-Waugh-Lovell定理指出,一个多元线性回归模型可以一次性或分三个步骤进行估算。例如,你可以回归credit_limit、wage、credit_score1、credit_score2的风险值,就像你已经做的那样:

formula = 'default ~ credit_limit + wage+credit_score1+credit_score2'
model = smf.ols(formula, data=risk_data).fit()
model.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept0.40370.00946.9390.0000.3870.421
credit_limit3.063e-061.54e-061.9870.0474.16e-086.08e-06
wage-8.822e-056.07e-06-14.5410.000-0.000-7.63e-05
credit_score1-4.175e-051.83e-05-2.2780.023-7.77e-05-5.82e-06
credit_score2-0.00031.52e-05-20.0550.000-0.000-0.000

但是根据FWL定理,你也可以将计算过程分成三步:

  1. 去偏步骤,在此步骤中,你对混杂因子 X X X 下的干预 T T T 进行回归,获得干预残差 T ~ = T − T ^ \tilde{T}=T-\hat{T} T~=TT^
  2. 去噪步骤,在此步骤中,你对混杂因子 X X X 下的结果 Y Y Y 进行回归,获得结果残差 Y ~ = Y − Y ^ \tilde{Y}=Y-\hat{Y} Y~=YY^
  3. 结果模型,在该模型中,你对干预残差 T ~ \tilde{T} T~ 下的结果残差 Y ~ \tilde{Y} Y~ 进行回归,获得 T T T Y Y Y 的因果影响的估算值

并不出乎意料,上面只是“4.2.2 多元线性回归”中的公式的重述。FWL定理在用回归模型估算过程中陈述了一个等价性。它还说,你可以分离线性回归的去偏成分,这是前面列表中概述的第一步。

为了更好地理解发生了什么,让我们一步一步地分解它。

4.3.1 步骤一:去偏

回想一下本章开头,由于混杂的偏差,你的数据看起来像这样,信用额度的违约趋势是向下的:

plt_df = (risk_data
          .assign(size=1)
          .groupby("credit_limit")
          .agg({"default": "mean", "size":sum})
          .reset_index())

plt.figure(figsize=(10,4))
sns.scatterplot(data=plt_df,
                x="credit_limit",
                y="default",
                size="size",
                sizes=(1,100))

plt.title("Default Rate by Credit Limit")
Text(0.5, 1.0, 'Default Rate by Credit Limit')

根据FWL定理,你可以通过拟合一个回归模型,从混杂因子中预测干预(信用限额)来消除这些数据的偏差。然后,可以从该模型中取残差: l i n e i ~ = l i n e i − l i n e i ^ \widetilde{line_i}=line_i-\widehat{line_i} linei =lineilinei 。这个残差可以被视为与去偏模型中使用的变量不相关的干预的一个版本。这是因为,根据定义,残差与产生预测的变量正交。

这个过程会使得 l i n e ~ \widetilde{line} line 在0周围分布。你可以选择将平均干预 l i n e ‾ \overline{line} line 加到上式中:
l i n e i ~ = l i n e i − l i n e i ^ + l i n e ‾ \widetilde{line_i}=line_i-\widehat{line_i}+\overline{line} linei =lineilinei +line

这对于去偏不是必须的,但是使得 l i n e ~ \widetilde{line} line 与原始 l i n e line line 处于相同的区间,更利于可视化:

debiasing_model = smf.ols(
    'credit_limit ~ wage + credit_score1  + credit_score2',
    data=risk_data
).fit()

risk_data_deb = risk_data.assign(
    # for visualization, avg(T) is added to the residuals
    credit_limit_res=(debiasing_model.resid 
                      + risk_data["credit_limit"].mean())
)

如果你现在运行一个简单的用于结果(风险)的线性回归,在干预的去偏或残差版本( l i n e ~ \widetilde{line} line )上,控制去偏模型中使用的混杂因子,你将得到信用限额对风险的影响。你在这里得到 β 1 \beta_1 β1 的参数估算与之前运行包括了干预和混杂因子的完整模型得到的参数估算完全相同:

model_w_deb_data = smf.ols('default ~ credit_limit_res',
                           data=risk_data_deb).fit()

model_w_deb_data.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept0.14210.00530.0010.0000.1330.151
credit_limit_res3.063e-061.56e-061.9570.050-4.29e-096.13e-06

备选系数公式

只需要重新计算干预这一事实展示了重写回归系数公式的一种更简单的方法。在单变量情况下,可以不使用对应 T T T 方差的 Y Y Y T T T 的协方差,你可以使用:

β 1 = E [ ( T i − T ‾ ) y i ] E [ ( T i − T ‾ ) 2 ] \beta_1=\frac{E[(T_i-\overline{T})y_i]}{E[(T_i-\overline{T})^2]} β1=E[(TiT)2]E[(TiT)yi]

在多变量情况下,可以使用:
β 1 = E [ ( T i − E ( T ∣ X ) ) y i ] E [ V a r ( T ∣ X ) ] \beta_1=\frac{E[(T_i-E(T|X))y_i]}{E[Var(T|X)]} β1=E[Var(TX)]E[(TiE(TX))yi]

不过,这是有区别的。看p值,比你之前得到的要高一些。这是因为你没有应用去噪步骤,去噪负责减少方差。尽管如此,仅通过去偏步骤,就可以得到信用限额对风险的因果影响的无偏估计,因为所有的混杂因子都包含在去偏模型中了。

你还可以通过绘制信用限额与违约率的去偏版本可视化正在发生的事情。你会看到,这种关系不再像数据有偏差时那样向下倾斜:

plt_df = (risk_data_deb
          .assign(size=1)
          .assign(credit_limit_res = lambda d: d["credit_limit_res"].round(-2))
          .groupby("credit_limit_res")
          .agg({"default": "mean", "size":sum})
          .query("size>30")
          .reset_index())

plt.figure(figsize=(10,4))
sns.scatterplot(data=plt_df,
                x="credit_limit_res",
                y="default",
                size="size",
                sizes=(1,100))

plt.title("Default Rate by Debiased Credit Limit")

Text(0.5, 1.0, 'Default Rate by Debiased Credit Limit')

4.3.2 步骤二:去噪

虽然去偏步骤对估算正确的因果效应至关重要,但去噪步骤也很好,尽管没有那么重要。它不会改变你的干预效应估算值,但会减少它的方差。在这一步中,你将在非干预的协变量上对结果进行回归。然后,你会得到结果的残差 d e f a u l t i ~ = d e f a u l t i − d e f a u l t i ^ \widetilde{default_i}=default_i-\widehat{default_i} defaulti =defaultidefaulti

同样,为了更好的可视化,你可以将平均违约率添加到去噪的默认变量中:
d e f a u l t i ~ = d e f a u l t i − d e f a u l t i ^ + d e f a u l t i ‾ \widetilde{default_i}=default_i-\widehat{default_i}+\overline{default_i} defaulti =defaultidefaulti +defaulti

denoising_model = smf.ols(
    'default ~ wage + credit_score1  + credit_score2',
    data=risk_data_deb
).fit()

risk_data_denoise = risk_data_deb.assign(
    default_res=denoising_model.resid + risk_data_deb["default"].mean()
)

4.3.3 回归估算量的标准误差

既然我们谈论的是噪声,我认为现在是时候看看如何计算回归标准误差了。回归参数估算值的SE由下式给出:
S E ( β ^ ) = σ ( ϵ ^ ) σ ( T ~ ) n − D F SE(\hat{\beta})=\frac{\sigma(\hat{\epsilon})}{\sigma(\tilde{T})\sqrt{n-DF}} SE(β^)=σ(T~)nDF σ(ϵ^)

其中 ϵ ^ \hat{\epsilon} ϵ^ 为回归模型的残差, D F DF DF 为模型的自由度(模型估算的参数个数)。如果你更喜欢在代码中看到这一点,下面就是:

model_se = smf.ols(
    'default ~ wage + credit_score1  + credit_score2',
    data=risk_data
).fit()

print("SE regression:", model_se.bse["wage"])


model_wage_aux = smf.ols(
    'wage ~ credit_score1 + credit_score2',
    data=risk_data
).fit()

# subtract the degrees of freedom - 4 model parameters - from N.
se_formula = (np.std(model_se.resid)
              /(np.std(model_wage_aux.resid)*np.sqrt(len(risk_data)-4)))

print("SE formula:   ", se_formula)
SE regression: 5.364242347548197e-06
SE formula:    5.364242347548201e-06

这个公式很好,因为它让你对回归有了进一步的直观感受,特别是去噪步骤。首先,分子告诉你,你对结果的预测越好,残差就越小,因此,估算值的方差就越低。这就是去噪步骤的全部内容。它还告诉你,如果干预能很大程度上解释结果,其参数估算也会有较小的标准误差。

有趣的是,误差也与(残差化)干预的方差成反比。这也是直观的。如果干预差异很大,衡量其影响就会更容易。你将在“4.9.1 噪音诱导控制”中了解更多相关内容。

连续干预实验

如果您打算设计一个实验,使用回归的参数估算来衡量效果,那么标准误差公式也可以很有用。如果随机化的干预是连续值,标准误差公式可以近似为:
S E ≈ σ ( y ) σ ( T ) n − 2 SE \approx \frac{\sigma(y)}{\sigma(T)\sqrt{n-2}} SEσ(T)n2 σ(y)

在单变量回归模型的情况下,这种近似是保守的,因为 σ ( y ) ≥ σ ( e ^ ) \sigma(y) \geq \sigma(\widehat{e}) σ(y)σ(e ),可能一定程度上解释干预对结果的影响。然后,你可以用这个标准误差,代入第2章的样本量计算公式。重要的是,设计这个测试有从 T T T 选择抽样分布的额外复杂性,这也会通过 σ ( T ) \sigma(T) σ(T) 影响标准误差。

4.3.4 最终步骤:结果模型

获得了两个残差: Y ~ \widetilde{Y} Y T ~ \widetilde{T} T ,你可以运行FWL定理描述的最后一步——对关于 T ~ \widetilde{T} T Y ~ \widetilde{Y} Y 进行回归:

model_w_orthogonal = smf.ols('default_res ~ credit_limit_res',
                             data=risk_data_denoise).fit()

model_w_orthogonal.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept0.14210.00530.4580.0000.1330.151
credit_limit_res3.063e-061.54e-061.9870.0474.17e-086.08e-06

干预的参数估算与你在去偏步骤和运行信用限额加所有其他协变量的回归模型时得到的完全相同。此外,标准误差和p值现在也像你第一次运行包括了所有变量的模型时一样。这是去噪步骤的效果。

当然,你也可以绘制去偏干预与去噪结果之间的关系,以及最终模型的预测,以了解发生了什么:

plt_df = (risk_data_denoise
          .assign(size=1)
          .assign(credit_limit_res = lambda d: d["credit_limit_res"].round(-2))
          .groupby("credit_limit_res")
          .agg({"default_res": "mean", "size":sum})
          .query("size>30")
          .reset_index()
          .assign(prediction = lambda d: model_w_orthogonal.predict(d)))

plt.figure(figsize=(10,4))
sns.scatterplot(data=plt_df,
                x="credit_limit_res",
                y="default_res",
                size="size",
                sizes=(1,100))

plt.plot(plt_df["credit_limit_res"], plt_df["prediction"], c="C1")

plt.title("Denoised Default Rate by Debiased Credit Limit")
Text(0.5, 1.0, 'Denoised Default Rate by Debiased Credit Limit')

4.3.5 FWL总结

我不知道你们是否能看出来,但我真的很喜欢插图。即使它们没有反映任何真实的数据,但是它们可以非常有用地可视化一些技术概念背后的情况,包括FWL。总结一下现在的情况,你想要估算干预 T T T 和结果 Y Y Y 之间的关系,但是有一些混杂因子 X X X,将干预绘制在x轴上,将结果绘制在y轴上,将混杂因子绘制为标注了颜色的维度。你最初看到干预和结果之间的斜率为负,但你有充分的理由(一些领域知识)相信这种关系应该是正相关关系,因此你决定消除数据的偏差。

要做到这一点,首先要使用线性回归对 E ( T ∣ X ) E(T|X) E(TX) 进行估算。然后,构建该干预的去偏版本: Y − E ( T ∣ X ) Y-E(T|X) YE(TX)(参见下图)。通过这个去偏干预,你可以看到你希望找到的正相关关系,但还是有很多噪声。

为了处理噪声,你也是使用回归模型来估算 E ( Y ∣ X ) E(Y|X) E(YX)。然后,你构造一个去噪版本的结果: Y − E ( T ∣ X ) Y-E(T|X) YE(TX)(见下图)。你可以把这个去噪的结果看作是包含了由 X X X 定义的所有分组方差的结果。如果 X X X 定义了 Y Y Y 的众多方差,去噪后的结果将不那么嘈杂,从而更容易看到你真正关心的关系: T T T Y Y Y 之间的关系。

最后,经过去偏和去噪,可以清楚地看到 T T T Y Y Y 之间的正相关关系。剩下要做的就是为这些数据建立一个最终的模型:

这个最后的回归,与你同时在 T T T X X X 上对 Y Y Y 回归有完全相同的斜率。

去偏和截距

不过,有一点需要提醒。在因果推断中,你最关心的是这条回归线的斜率,因为斜率是连续干预的效果的一个线性近似, ∂ ∂ t E ( y ∣ t ) \frac{\partial}{\partial t}E(y|t) tE(yt)。但是,如果你还关心截距——例如,如果你试图进行反事实预测——你应该知道去偏和去噪会使截距等于零。

4.4 结果模型回归

在本节中,我强调了回归是如何通过正交化处理来工作的。然而,你也可以将回归视为一种潜在结果归因技术。假设干预是二元的,如果在对照组(T=0)中关于X的Y的回归是 E ( Y 0 ∣ X ) E(Y_0|X) E(Y0X) 的很好的近似,那么可以使用该模型来对 Y 0 Y_0 Y0 进行计算,并估算ATT:
A T T = 1 N 1 ∑ 1 ( T i = 1 ) ( Y i − μ 0 ^ ( X i ) ) ATT=\frac{1}{N_1}\sum{1(T_i=1)(Y_i-\widehat{\mu_0}(X_i))} ATT=N111(Ti=1)(Yiμ0 (Xi))

其中 N 1 N_1 N1 是被干预的分析单元的个数。

指示函数(Indicator Function)

在本书中,我将用 1(.) 来表示指示函数。当函数内部的参数计算结果为真时,该函数返回1,否则返回0。

一个类似的论点可以证明,如果对被干预单元的回归可以建模为 E ( Y 1 ∣ X ) E(Y_1|X) E(Y1X),那么你可以使用这个回归来估算对未干预单元的平均影响。如果你把这两个参数放在一起,你就可以如下估算ATE:
A T E = 1 N ∑ ( μ 1 ^ ( X i ) − μ 0 ^ ( X i ) ) ATE=\frac{1}{N}\sum{(\widehat{\mu_1}(X_i)-\widehat{\mu_0}(X_i))} ATE=N1(μ1 (Xi)μ0 (Xi))

上式可以估算所有分析单元的两种潜在结果。这相当于在 X X X T T T 上对 Y Y Y 进行回归,并读取 T T T 的参数估计值。

或者,你也可以只考虑那些遗漏的潜在结果:
A T E = 1 N ∑ ( 1 ( T i = 1 ) ( Y i − μ 0 ^ ( X i ) ) + 1 ( T i = 0 ) ( μ 1 ^ ( X i ) − Y i ) ) ATE=\frac{1}{N}\sum{(1(T_i=1)(Y_i-\widehat{\mu_0}(X_i))+1(T_i=0)(\widehat{\mu_1}(X_i)-Y_i))} ATE=N1(1(Ti=1)(Yiμ0 (Xi))+1(Ti=0)(μ1 (Xi)Yi))

其中 T T T 是连续值,这有点难以概念化,但你可以将回归理解为对整个干预反馈函数的估算,它包括对潜在结果 Y ( t ) Y(t) Y(t) 的估算,就像它是一条线。

如果回归能够正确地估算正交化的 E ( T ∣ X ) E(T|X) E(TX) 或正确地估计潜在的结果 E ( Y t ∣ X ) E(Y_t|X) E(YtX),那么它就是有效的,这一事实赋予了它双重鲁棒性,这一点将在第5章中进一步探讨。当你在第四部分(第8、9章)学习双重差分时,通过这个视角来了解回归也很重要。

公立学校还是私立学校

在《Mastering Metrics》(普林斯顿大学出版社)一书中,安格里斯特(Angrist)和皮施克(Pischke)展示了在估计上私立学校对收入的影响时,如何使用回归来调整偏差。私立学校的毕业生通常比公立学校的毕业生赚得多,但很难说这种关系在多大程度上是因果关系。例如,你父母的收入可能会混淆这种关系,因为富裕家庭的孩子更有可能进入私立学校,也更有可能赚更多的钱。同样,由于私立学校非常挑剔,它们可能只招收那些原本就会变得更好的学生。

以至于对私立学校学生的收入进行简单的回归,几乎肯定会得出正相关关系。换句话说,估算下面的模型得到的 β 1 ^ \hat{\beta_1} β1^ 肯定是一个明显的正值:

i n c o m e i = δ 0 + β 1 p r i v a t e + e i income_i=\delta_0+\beta_1 private +e_i incomei=δ0+β1private+ei

然而,安格里斯特和皮施克指出,如果根据SAT分数和父母的收入进行调整,衡量出来的影响就会下降。也就是说,如果你用这两个变量来修补模型,得到的结果会比短模型得到的更小:

i n c o m e i = δ 0 + β 1 p r i v a t e + δ 1 S A T i + δ 2 P a r e n t I n c i + e i income_i=\delta_0+\beta_1 private +\delta_1 SAT_i +\delta_2 ParentInc_i +e_i incomei=δ0+β1private+δ1SATi+δ2ParentInci+ei

尽管如此,在对父母收入进行回归后,私立学校的影响仍然是积极且显著的,至少在作者使用的数据集中是如此。然而,最后一组控制集使这种关系变得无关紧要:作者把申请学校所有学生的平均SAT成绩包括进来(不管他们是否被录取)。这可以被解释为代表了学生的雄心壮志:

i n c o m e i = δ 0 + β 1 p r i v a t e + δ 1 S A T i + δ 2 P a r e n t I n c i + δ 3 A v g S A T S c h o o l i + e i income_i=\delta_0+\beta_1 private +\delta_1 SAT_i +\delta_2 ParentInc_i +\delta_3 AvgSATSchool_i +e_i incomei=δ0+β1private+δ1SATi+δ2ParentInci+δ3AvgSATSchooli+ei

一旦他们加入了代表雄心的控制变量,得到的估算值就变得无关紧要。有趣的是,仅保留代表雄心的控制变量,去掉SAT和父母收入控制因素,得出的估算结果仍然不显著。这表明,考虑到你的抱负水平,上公立学校还是私立学校并不重要,至少在你的收入方面是这样:

import graphviz as gr

g = gr.Digraph(format="png")

g.edge("Parent's Income", "Private")
g.edge("SAT", "Private")
g.edge("Ambition", "Private")
g.edge("...", "Private")

g.edge("Parent's Income", "Income")
g.edge("SAT", "Income")
g.edge("Ambition", "Income")
g.edge("...", "Income")

g.edge("Private", "Income")

g

4.5 正值性(Positivity)与外推法(Extrapolation)

由于回归将潜在结果作为一个参数函数进行建模,当你有所有干预场景的数据,允许你将潜在结果的区间进行外推。这可能是祝福,也可能是诅咒,完全取决于外推是否合理。例如,假设你现在必须在低重叠的数据集中估算处理效果,就称这个数据集为数据集1吧。数据集1的对照组分析单元的协变量x没有高值,干预组分析单元在相同的协变量上没有低值。如果你使用回归来估算该数据的干预效果,它将如下图中左边所示:

np.random.seed(1)

n = 1000
x = np.random.normal(0, 1, n)
t = np.random.normal(x, 0.2, n) > 0

y0 = x 
y1 = 1 + x

y = np.random.normal((1-t)*y0 + t*y1, 0.2)

df_no_pos = pd.DataFrame(dict(x=x,t=t.astype(int),y=y))

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 6), sharey=True)

sns.scatterplot(data=df_no_pos, x="x", y="y", hue="t", ax=ax1, label="treatment")

m0 = smf.ols("y~x", data=df_no_pos.query(f"t==0")).fit()
m1 = smf.ols("y~x", data=df_no_pos.query(f"t==1")).fit()

sns.lineplot(data=df_no_pos.assign(pred=m0.predict(df_no_pos)), x="x", y="pred", color=f"C0", ax=ax1)
sns.lineplot(data=df_no_pos.assign(pred=m1.predict(df_no_pos)), x="x", y="pred", color=f"C1", ax=ax1);
ax1.set_title("Dataset 1")

np.random.seed(1)

n = 1000
x = np.random.normal(0, 1, n)
t = np.random.binomial(1, 0.5, size=n)

y0 = x * (x<0) + (x>0)*0
y1 = 1 + x

y = np.random.normal((1-t)*y0 + t*y1, 0.2)

df_pos = pd.DataFrame(dict(x=x,t=t.astype(int),y=y))

sns.scatterplot(data=df_pos, x="x", hue="t", y="y", ax=ax2, label="treatment")

sns.lineplot(data=df_no_pos.assign(pred=m0.predict(df_pos)), x="x", y="pred", color=f"C0", ax=ax2)
sns.lineplot(data=df_no_pos.assign(pred=m1.predict(df_pos)), x="x", y="pred", color=f"C1", ax=ax2)
ax2.set_title("Dataset 2")
Text(0.5, 1.0, 'Dataset 2')

只要你在对照组中 x x x 较小时的 Y 0 Y_0 Y0 x x x 之间的关系同样适用于 x x x 较大时的情况,并且你在干预组中对 Y 1 Y_1 Y1 的拟合也可以很好地外推到 x x x 较小时的情况,就没关系。一般来说,如果协变量空间中的各结果的趋势看起来相似,那么小范围的外推就不是问题。

然而,过多的外推总是危险的。让我们假设你已经估算了数据集1的影响,但随后你收集了更多数据,现在将干预随机化。对于这个称为数据集2的新数据,你可以看到,当x为正值时,影响会越来越大。因此,如果你在这个新数据上估算你之前的拟合,你会意识到你严重低估了干预的真实效果。这表明,在没有正值的区间,你永远不可能真正知道干预效果会发生什么。你可能会选择相信你对这些区间的外推,但这并非没有风险。

4.6 线性回归的非线性

到目前为止,干预反馈曲线似乎是线性的。看起来,无论信用额度高低,信用额度的增加都会导致风险的不断增加。从额度范围1000-2000的增加的风险似乎和2000-3000一样。然而,你可能会遇到不同的情况。

例如,还是与之前相同的数据,但现在你的任务是估算信用限额对信用卡消费的因果影响:

spend_data = pd.read_csv("./data/spend_data.csv")

spend_data.head()
wageeducexpermarriedcredit_score1credit_score2credit_limitspend
0950.011161500.0518.03200.03848
1780.01171414.0429.01700.03144
21230.01491586.0571.04200.04486
31040.01581379.0411.01500.03327
41000.01611379.0518.01800.03508
g_risk = gr.Digraph(graph_attr={"rankdir":"LR"})

g_risk.edge("Wage", "Credit Limit")
g_risk.edge("Wage", "Spend")
g_risk.edge("Credit Limit", "Spend")

g_risk

为了简单起见,让我们考虑这里唯一的混淆因子是工资(假设这是银行在决定信用额度时使用的唯一信息)。这个过程的因果图看起来像这样:

因此,你必须以工资为条件来确定信贷额度对支出的影响。如果你想使用正交化来估算这种影响,你可以说,你需要通过将信用额度与工资进行回归并得到残差来消除偏差。到目前为止还没有新的知识点。但这里有一个问题,如果你按多个工资水平的信用额度绘制支出图,你可以清楚地看到,这种关系不是线性的:

plt_df = (spend_data
          .assign(wage_group = lambda d: pd.IntervalIndex(pd.qcut(d["wage"], 5)).mid)
          .groupby(["wage_group", "credit_limit"])
          [["spend"]]
          .mean()
          .reset_index())

plt.figure(figsize=(10,4))
sns.scatterplot(data=plt_df, 
                x="credit_limit",
                y="spend",
                hue="wage_group",
                palette="gray")
<AxesSubplot:xlabel='credit_limit', ylabel='spend'>

相反,干预反馈曲线似乎有某种凸性(Concave):信用额度越高,这条曲线的斜率越低。或者,用因果推理的语言来说,由于斜率和因果效应密切相关,你也可以说额度对消费的影响随着额度的增加而减弱:额度从1000到2000增加的消费比额度从2000到3000增加的消费要多。

4.6.1 干预的线性化

为了解决这个问题,你首先需要将干预转变为与结果有线性关系。例如,你知道这个关系是凸的,所以你可以尝试在额度上应用凸函数。对数函数、平方根函数,或者任何将信用额度作为幂的函数都是很好的选择。

在这种情况下,让我们试试平方根函数:

plt_df = (spend_data
          # apply the sqrt function to the treatment
          .assign(credit_limit_sqrt = np.sqrt(spend_data["credit_limit"]))
          # create 5 wage binds for better vizualization
          .assign(wage_group = pd.IntervalIndex(pd.qcut(spend_data["wage"], 5)).mid)
          .groupby(["wage_group", "credit_limit_sqrt"])
          [["spend"]]
          .mean()
          .reset_index())

plt.figure(figsize=(10,4))
sns.scatterplot(data=plt_df, 
                x="credit_limit_sqrt",
                y="spend",
                palette="gray",
                hue="wage_group")

<AxesSubplot:xlabel='credit_limit_sqrt', ylabel='spend'>

现在我们成功了!信用额度的平方根似乎与消费呈线性关系。这绝对不是完美的,如果你仔细观察,你仍然可以看到一些曲率,但这可能只是暂时的。

很遗憾,这个过程是相当依赖手动的。你需要尝试很多方法看看哪种方法能更好地使干预线性化。一旦您找到了满意的东西,就可以在运行线性回归模型时应用它。在这个例子中,它意味着你将估算模型:

s p e n d i = β 0 + β 1 l i n e i + e i spend_i=\beta_0+\beta_1 \sqrt{line_i} +e_i spendi=β0+β1linei +ei

因果参数是 β 1 \beta_1 β1

该模型可以通过statmodels进行估算,直接使用NumPy平方根函数:

model_spend = smf.ols(
    'spend ~ np.sqrt(credit_limit)',data=spend_data
).fit()

model_spend.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept493.00446.50175.8320.000480.262505.747
np.sqrt(credit_limit)63.25250.122519.2680.00063.01463.491

但这样还没有结束。回想一下,工资混淆了信贷额度和支出之间的关系。通过将前面模型的预测与原始数据绘制成图形,你可以看到这一点。注意它的斜率可能是向上倾斜的。这是因为工资增加会导致支出和信贷额度增加:

plt_df = (spend_data
          .assign(wage_group = lambda d: pd.IntervalIndex(pd.qcut(d["wage"], 5)).mid)
          .groupby(["wage_group", "credit_limit"])
          [["spend"]]
          .mean()
          .reset_index()
         )

x = np.linspace(plt_df["credit_limit"].min(), plt_df["credit_limit"].max())

plt.figure(figsize=(10,4))
plt.plot(x, model_spend.params[0] + model_spend.params[1]*np.sqrt(x), color="C1", label="prediction", lw=4)
plt.legend()
sns.scatterplot(data=plt_df, 
                x="credit_limit",
                y="spend",
                palette="gray",
                hue="wage_group")
<AxesSubplot:xlabel='credit_limit', ylabel='spend'>

如果你在模型中包含工资变量:

s p e n d i = β 0 + β 1 l i n e i + β 2 w a g e i + e i spend_i=\beta_0+\beta_1 \sqrt{line_i} +\beta_2 wage_i +e_i spendi=β0+β1linei +β2wagei+ei

然后在此估算 β 1 \beta_1 β1,你得到额度对支出影响的无偏估算(当然,假设工资是唯一的混杂因素)。这个估算比你之前得到的要小。这是因为将工资纳入模型固定了向上的偏差:

model_spend = smf.ols('spend ~ np.sqrt(credit_limit)+wage',
                      data=spend_data).fit()

model_spend.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept383.50022.746139.6620.000378.118388.882
np.sqrt(credit_limit)43.85040.065672.6330.00043.72343.978
wage1.04590.002481.8750.0001.0421.050

4.6.2 非线性FWL与去偏

至于FWL定理是如何在非线性数据中工作的,其实和之前完全一样,但现在你必须首先学会应用非线性。用线性回归估算非线性模型的过程分解如下:

  1. 找到将 T T T Y Y Y 之间的关系线性化的函数。
  2. 去偏步骤,在此步骤中,你对混杂因子 X X X 下的干预 F ( T ) F(T) F(T) 进行回归,获得干预残差 F ( T ) ~ = F ( T ) − F ( T ) ^ \widetilde{F(T)}=F(T)-\widehat{F(T)} F(T) =F(T)F(T)
  3. 去噪步骤,在此步骤中,你对混杂因子 X X X 下的结果 Y Y Y 进行回归,获得结果残差 Y ~ = Y − Y ^ \tilde{Y}=Y-\hat{Y} Y~=YY^
  4. 结果模型,在该模型中,你对干预残差 F ( T ) ~ \widetilde{F(T)} F(T) 下的结果残差 Y ~ \tilde{Y} Y~ 进行回归,获得 F ( T ) F(T) F(T) Y Y Y 的因果影响的估算值。

在这个例子中, F F F 是平方根函数,下面就是在考虑非线性的情况下如何应用FWL定理(分别在干预残差和结果残差中添加了 F ( l i n e s ) ‾ \overline{F(lines)} F(lines) s p e n d ‾ \overline{spend} spend,是可选的,但有助于更好的可视化):

debias_spend_model = smf.ols(f'np.sqrt(credit_limit) ~ wage',
                             data=spend_data).fit()
denoise_spend_model = smf.ols(f'spend ~ wage', data=spend_data).fit()

credit_limit_sqrt_deb = (debias_spend_model.resid 
                         + np.sqrt(spend_data["credit_limit"]).mean())
spend_den = denoise_spend_model.resid + spend_data["spend"].mean()


spend_data_deb = (spend_data
                  .assign(credit_limit_sqrt_deb = credit_limit_sqrt_deb,
                          spend_den = spend_den))

final_model = smf.ols(f'spend_den ~ credit_limit_sqrt_deb',
                      data=spend_data_deb).fit()

final_model.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept1493.69903.435434.8180.0001486.9661500.432
credit_limit_sqrt_deb43.85040.065672.6400.00043.72343.978

毫不奇怪,通过运行包括工资混杂因子和治疗的完整模型,你得到的 β 1 \beta_1 β1 估算值与你之前得到的完全相同。此外,如果您根据原始数据绘制该模型的预测图,您可以看到它不像之前那样有向上的偏差。相反,它直接穿过了工资分组的中间部分:

plt_df = (spend_data
          .assign(wage_group = lambda d: pd.IntervalIndex(pd.qcut(d["wage"], 5)).mid)
          .groupby(["wage_group", "credit_limit"])
          [["spend"]]
          .mean()
          .reset_index())

x = np.linspace(plt_df["credit_limit"].min(), plt_df["credit_limit"].max())

plt.figure(figsize=(10,4))
plt.plot(x, (final_model.params[0] 
             + final_model.params[1]*np.sqrt(x)),
         color="C1", label="prediction", lw=4)

plt.legend()

sns.scatterplot(data=plt_df, 
                x="credit_limit",
                y="spend",
                palette="gray",
                hue="wage_group")
<AxesSubplot:xlabel='credit_limit', ylabel='spend'>

4.7 使用虚拟变量的回归

虚拟变量(Dummies)

在数据分析领域,dummies通常被翻译为“虚拟变量”、“指示变量”或“哑变量”,这些术语都是比较通用的翻译。其中,虚拟变量(dummies)是最接近原始英文含义的翻译,而指示变量和哑变量则更加抽象。因此,通常建议使用“虚拟变量”,它能够比较直观地表达出数据转换的含义。

将分类变量的每个取值都转换为一个新的二进制变量,并用0和1表示。这样,原本的分类变量就可以被表示为多个二进制变量的组合。这种转换可以帮助我们更好地理解分类变量对于某个问题的影响,并且可以方便地用于机器学习算法的输入。

回归和正交化就已足够,但最终你必须做出一个独立性假设。你必须假设,当一些协变量被考虑在内时,干预看起来像是随机分配的。这可能是一个相当大的延伸,很难知道模型中是否包含了所有的混杂因子。因此,你有必要尽可能地推动随机实验。例如,在银行的例子中,如果信贷限额是随机的,那将是很好的,因为这样就能非常直接地估算信贷额度对违约率和客户支出的影响。问题是这样的随机实验非常昂贵。如果给非常有风险的客户随机的信用额度,这些客户可能会违约并造成巨大的损失。

4.7.1 条件随机实验

解决这个难题不能使用理想的随机对照试验,而是退而求其次的办法:分层随机试验,或称条件随机试验。开展信用额度完全随机的实验,在相同的概率分布中取样,这是你没法做到的;你能做的是创建多个本地实验,根据客户的协变量,从不同的分布中提取样本。例如,你知道变量credit_score1是客户风险的代表,因此你可以使用这个变量创建风险较高或较低的客户组,将用户划分到credit_score1定义的桶中。然后,对于高风险的桶(具有较低credit_score1),你可以从一个较低平均值的分布中抽样来随机分配信用额度;对于低风险客户(credit_score1较高的客户),你可以从一个平均值较高的分布中抽样来随机分配信用额度:

risk_data_rnd = pd.read_csv("./data/risk_data_rnd.csv")
risk_data_rnd.head()
wageeducexpermarriedcredit_score1credit_score2credit_score1_bucketscredit_limitdefault
0890.011161490.0500.04005400.00
1670.01171196.0481.02003800.00
21220.01491392.0611.04005800.00
31210.01581627.0519.06006500.00
4900.01611275.0519.02002100.00

按照credit_score1_buckets绘制信用额度的直方图,可以看到曲线是从不同的分布中抽样得到的。得分较高的客户(低风险客户)的直方图向左倾斜,额度较高。拥有风险较高客户(得分较低)的群体曲线向右倾斜,额度较低。这类实验探索的信用额度与可能的最佳额度的距离不太远,这将测试的成本降低到更易于管理的金额:

plt.figure(figsize=(15,6))
sns.histplot(data=risk_data_rnd,
             x="credit_limit",
             hue="credit_score1_buckets",
             kde=True,
             palette="gray");
plt.title("Conditional random experiment")
Text(0.5, 1.0, 'Conditional random experiment')

β \beta β 抽样

在本实验中,信用额度是从 β \beta β 分布中采样。 β \beta β 分布可以理解为均匀分布的一般化,当你希望将样本限制在特定范围内时,该采样就特别方便。

这并不意味着有条件随机实验比完全随机实验更好。它们确实更便宜,但它们增加了额外的复杂性。因此,如果你选择一个条件随机实验,不管出于什么原因,尽量让它接近于一个完全随机的实验。这意味着:

  • 组数越少,处理条件随机测试就越容易。本例只有5个分组,因为你将credit_score1按照200为一个桶,分数是从0到1000。将不同的组与不同的干预分布结合起来增加了复杂性,所以坚持较少的组是一个好主意。
  • 不同分组之间的干预分布重叠越大,处理起来就越容易,这与正值性假设有关。在这个例子中,如果高风险人群接受高额度的概率为零,你就必须依靠危险的推断来了解如果他们接受高额度会发生什么。

如果你把这两个经验法则发挥到极致,你就会得到一个完全随机的实验,这意味着这两个法则都有一个权衡:组的数量越少,重叠越高,实验越容易处理,但也会更昂贵,反之亦然。

分层实验还可以作为一种尽量减少方差的工具,并确保干预组和控制组的分层变量保持平衡。但在这些应用中,干预分布在所有分组或分层中被设计成相同的。

4.7.2 虚拟变量

条件随机实验的妙处在于条件独立假设更合理,因为额度是在你选择的一个分类变量上随机分配的。缺点是,对干预组的结果进行简单回归将产生有偏差的估算。例如,以下是在不包括混淆因子的情况下估算模型时发生的情况:

d e f a u l t i = β 0 + β 1 l i n e s i + e i default_i=\beta_0+\beta_1 lines_i +e_i defaulti=β0+β1linesi+ei

model = smf.ols("default ~ credit_limit", data=risk_data_rnd).fit()
model.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept0.13690.00915.0810.0000.1190.155
credit_limit-9.344e-061.85e-06-5.0480.000-1.3e-05-5.72e-06

正如你看到的,因果参数 β ^ 1 \hat\beta_1 β^1 是负值,在这里是说不通的,因为更高的信用额度可能不会降低客户的风险。上面的情况是,在这些数据中,由于实验的设计方式,使得风险较低的客户(那些信用评分较高的客户)平均获得了更高的额度。

在pandas中,你可以使用pd.get_dummies函数用于创建虚拟变量。在这里,我传递了用于分组的列credit_score1_buckets,表示我希望创建带有sb前缀(表示分数桶)的虚拟列。同时,我要去掉第一个虚拟桶,就是0到200的桶。这是因为其中一个虚拟列是多余的,如果我知道所有其他列都是0,那么我去掉的那一列一定是1。

pd.set_option('display.max_columns', 9)
risk_data_dummies = (risk_data_rnd
                     .join(pd.get_dummies(risk_data_rnd["credit_score1_buckets"],
                                          prefix="sb",
                                          drop_first=True)))
risk_data_dummies.head()
wageeducexpermarriedsb_400sb_600sb_800sb_1000
0890.0111611000
1670.011710000
21220.014911000
31210.015810100
4900.016110000

有了虚拟列,可以将它们添加到模型中,并再次估算:

d e f a u l t i = β 0 + β 1 l i n e s i + θ G i + e i default_i=\beta_0+\beta_1 lines_i +\theta G_i +e_i defaulti=β0+β1linesi+θGi+ei

现在,你会得到一个更合理的估算,至少是正的,表明更多的信用额度会增加违约风险。

model = smf.ols(
    "default ~ credit_limit + sb_200+sb_400+sb_600+sb_800+sb_1000",
    data=risk_data_dummies
).fit()

model.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept0.22530.0564.0000.0000.1150.336
credit_limit4.652e-062.02e-062.3050.0216.97e-078.61e-06
sb_200-0.05590.057-0.9810.327-0.1680.056
sb_400-0.14420.057-2.5380.011-0.256-0.033
sb_600-0.21480.057-3.7560.000-0.327-0.103
sb_800-0.24890.060-4.1810.000-0.366-0.132
sb_1000-0.25410.094-2.7150.007-0.438-0.071

我只是向你展示如何手工创建虚拟变量,这样你就知道表面之下在发生什么。如果你必须在其他非Python框架中实现这种回归,那么这种方式将非常有用。在Python中,如果你正在使用statmodels,C()函数可以为你完成以上这些动作:

model = smf.ols("default ~ credit_limit + C(credit_score1_buckets)",
                data=risk_data_rnd).fit()

model.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept0.22530.0564.0000.0000.1150.336
C(credit_score1_buckets)[T.200]-0.05590.057-0.9810.327-0.1680.056
C(credit_score1_buckets)[T.400]-0.14420.057-2.5380.011-0.256-0.033
C(credit_score1_buckets)[T.600]-0.21480.057-3.7560.000-0.327-0.103
C(credit_score1_buckets)[T.800]-0.24890.060-4.1810.000-0.366-0.132
C(credit_score1_buckets)[T.1000]-0.25410.094-2.7150.007-0.438-0.071
credit_limit4.652e-062.02e-062.3050.0216.97e-078.61e-06

最后,这里只有一个斜率参数。添加虚拟变量以控制混杂,使每个组都有一个截距,但所有组的斜率相同。我们将很快讨论这个问题,但这个斜率将是每组回归的方差加权平均值。如果绘制每个组的模型预测值,你可以清楚地看到,每个组都有一条线,但它们都有相同的斜率:

plt_df = (risk_data_rnd
          .assign(risk_prediction = model.fittedvalues)
          .groupby(["credit_limit", "credit_score1_buckets"])
          ["risk_prediction"]
          .mean()
          .reset_index())

plt.figure(figsize=(10,4))
sns.lineplot(data=plt_df,
             x="credit_limit", y="risk_prediction", hue="credit_score1_buckets", palette = 'gray');
plt.title("Fitted values by group")
plt.legend(loc='center left', bbox_to_anchor=(1, 0.5))

<matplotlib.legend.Legend at 0x7fc79c685610>

4.7.3 饱和回归模型

还记得这一章的开头我是如何强调回归和条件平均之间的相似性的吗?我向你展示了,用二元干预进行回归的结果,与对干预组和对照组的平均值进行比较的结果,是完全相同的。现在,由于虚拟变量是二进制列,这种迁移在这里也是适用的。如果将你的条件随机实验数据交给一个不精通回归的人,他们的第一反应可能是简单地按credit_score1_buckets分割数据,并分别估算每组的效果:

def regress(df, t, y):
    return smf.ols(f"{y}~{t}", data=df).fit().params[t]

effect_by_group = (risk_data_rnd
                   .groupby("credit_score1_buckets")
                   .apply(regress, y="default", t="credit_limit"))
effect_by_group
credit_score1_buckets
0      -0.000071
200     0.000007
400     0.000005
600     0.000003
800     0.000002
1000    0.000000
dtype: float64

这将根据分组分别给出结果,意味着你还必须决定如何平均结果。一个自然的选择是加权平均值,其中权重是每个组的大小:

group_size = risk_data_rnd.groupby("credit_score1_buckets").size()
ate = (effect_by_group * group_size).sum() / group_size.sum()
ate
4.490445628748722e-06

当然,通过运行所谓的饱和模型,你可以使用回归做完全相同的事情。您可以将虚拟变量与干预进行交互,以获得每个虚拟变量定义的分组的效果。在本例中,由于删除了第一个虚拟项,因此与credit_limit关联的效果参数实际上省略了虚拟组sb_100中的效果。此处的效果参数与前面的credit_score1_buckets中的0到200分组的估算值完全相同(-0.000071):

model = smf.ols("default ~ credit_limit * C(credit_score1_buckets)",
                data=risk_data_rnd).fit()
model.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept0.31370.0774.0860.0000.1630.464
C(credit_score1_buckets)[T.200]-0.15210.079-1.9260.054-0.3070.003
C(credit_score1_buckets)[T.400]-0.23390.078-3.0050.003-0.386-0.081
C(credit_score1_buckets)[T.600]-0.29570.080-3.6900.000-0.453-0.139
C(credit_score1_buckets)[T.800]-0.32270.111-2.9190.004-0.539-0.106
C(credit_score1_buckets)[T.1000]-0.31370.428-0.7330.464-1.1530.525
credit_limit-7.072e-054.45e-05-1.5880.112-0.0001.66e-05
credit_limit:C(credit_score1_buckets)[T.200]7.769e-054.48e-051.7340.083-1.01e-050.000
credit_limit:C(credit_score1_buckets)[T.400]7.565e-054.46e-051.6960.090-1.18e-050.000
credit_limit:C(credit_score1_buckets)[T.600]7.398e-054.47e-051.6550.098-1.37e-050.000
credit_limit:C(credit_score1_buckets)[T.800]7.286e-054.65e-051.5670.117-1.83e-050.000
credit_limit:C(credit_score1_buckets)[T.1000]7.072e-058.05e-050.8780.380-8.71e-050.000

交互参数是由第一组(被省略组)的效果来定的。因此,如果将credit_limit相关的参数与其他交互项相加,你可以看到使用回归估算的每组的影响。它们与分别估算的每组效果完全相同:

(model.params[model.params.index.str.contains("credit_limit:")]
 + model.params["credit_limit"]).round(9)
credit_limit:C(credit_score1_buckets)[T.200]     0.000007
credit_limit:C(credit_score1_buckets)[T.400]     0.000005
credit_limit:C(credit_score1_buckets)[T.600]     0.000003
credit_limit:C(credit_score1_buckets)[T.800]     0.000002
credit_limit:C(credit_score1_buckets)[T.1000]    0.000000
dtype: float64

按组绘制该模型的预测图,也表明你现在就好像正在为每个组拟合一个单独的回归。每条直线不仅有不同的截距,而且有不同的斜率。此外,在其他条件相同的情况下,饱和模型的参数(自由度)更多,也意味着方差更大。如果你看下面的图,你会看到一条负斜率的线,在这里是没有意义的。不过,这一斜率在统计上并不显著。可能只是噪音,因为该组的样本很小:

plt_df = (risk_data_rnd
          .assign(risk_prediction = model.fittedvalues)
          .groupby(["credit_limit", "credit_score1_buckets"])
          ["risk_prediction"]
          .mean()
          .reset_index())

plt.figure(figsize=(10,4))
sns.lineplot(data=plt_df,
             x="credit_limit", y="risk_prediction", hue="credit_score1_buckets",  palette="gray");
plt.title("Fitted values by group")
plt.legend(loc='center left', bbox_to_anchor=(1, 0.5))
<matplotlib.legend.Legend at 0x7fc78a026f50>

4.7.4 方差加权平均回归

但是,如果饱和回归和按分组计算效果得到的结果完全相同,你可能会问自己一个非常重要的问题。当你在没有交互项的情况下运行模型 default ~ credit_limit + C(credit_score1_buckets) 时,您会得到一个单一的效果:只有一个斜率参数。重要的是,如果回头看,这个效果估算值与通过估算每个组的效果并使用组大小作为权重进行平均的结果是不同的。因此,某种程度上,回归结合了不同分组的效果。所以回归的结果不是样本大小作为权重的加权平均。那么,它是什么呢?

同样,回答这个问题的最好方法是使用一些非常具有说明性的模拟数据。在这里,让我们模拟两组不同的数据。第1组规模为1000,平均干预效果为1。第2组规模为500,平均处理效果为2。另外,第1组干预的标准差为1,第2组标准差为2:

np.random.seed(123)

# std(t)=1
t1 = np.random.normal(0, 1, size=1000)
df1 = pd.DataFrame(dict(
    t=t1,
    y=1*t1, # ATE of 1
    g=1,
))

# std(t)=2
t2 = np.random.normal(0, 2, size=500)
df2 = pd.DataFrame(dict(
    t=t2,
    y=2*t2, # ATE of 2
    g=2,
))

df = pd.concat([df1, df2])
df.head()
tyg
0-1.085631-1.0856311
10.9973450.9973451
20.2829780.2829781
3-1.506295-1.5062951
4-0.578600-0.5786001

如果你分别估算每一组的影响,并以组大小作为权重来平均结果,你得到的ATE大约为1.33,(1*1000+2*500)/1500:

effect_by_group = df.groupby("g").apply(regress, y="y", t="t")
ate = (effect_by_group *
       df.groupby("g").size()).sum() / df.groupby("g").size().sum()
ate
1.333333333333333

但如果你在t上对y进行回归,同时对分组进行控制,你会得到一个非常不同的结果。现在,组合效果更接近第2组的效果,尽管第2组的样本只有第1组的一半:

model = smf.ols("y ~ t + C(g)", data=df).fit()
model.params
Intercept    0.024758
C(g)[T.2]    0.019860
t            1.625775
dtype: float64

究其原因,回归并没有以样本量作为权重来组合各组效果。相反,它使用的权重与每组干预的方差成比例,回归倾向于干预方差很大的组。这一开始可能看起来很奇怪,但如果你仔细想想,就会发现它很有道理。如果干预在一个组内变化不大,你怎么能确定它的效果呢?如果干预变化很大,其对结果的影响将更加明显。

总而言之,如果你有多个组,每个组内的干预都是随机的,条件原则规定,效果是每个组内效果的加权平均值:

A T E = E ( ∂ ∂ t E ( Y i ∣ T = t , G r o u p i ) ) ω ( G r o u p i ) ATE=E{(\frac{\partial}{\partial t}E(Y_i|T=t,Group_i))\omega(Group_i)} ATE=E(tE(YiT=t,Groupi))ω(Groupi)

根据不同的方法,你会有不同的权重。回归中, ω ( G r o u p i ) ∝ σ 2 ( T ) ∣ G r o u p \omega(Group_i) \propto \sigma^2(T)|Group ω(Groupi)σ2(T)Group,但你也可以选择使用样本量作为权重: ω ( G r o u p i ) = N G r o u p \omega(Group_i)=N_{Group} ω(Groupi)=NGroup

另请参阅

了解这种差异是理解回归背后发生的事情的关键。即使是最优秀的研究人员也需要经常被提醒这个事实:回归是根据方差对每组效果进行加权的。2020年,计量经济学领域经历了一次关于diff-in-diff方法的复兴(你将在第四部分看到更多关于它的信息)。问题的中心是回归,而不是根据样本大小对影响进行加权。如果你想了解更多,我建议你看看安德鲁·古德曼-培根(Andrew Goodman-Bacon)的论文《干预时序变化的双重差分》(difference -in- difference with Variation in Treatment Timing)。或者等到第四部分(第8、9章)。

4.7.5 去均值(De-Meaning)与固定效应(Fixed Effects)

你刚刚看到了如何在模型中包含虚拟变量,以考虑不同组之间的不同干预分配。但FWL定理真正的亮点是在虚拟变量上。如果你有很多的组,那么为每个组添加一个虚拟变量不仅繁琐,而且计算成本也很高。你会创建很多很多值几乎都是0的列。通过应用FWL,理解回归如何在涉及到虚拟变量时使干预正交化,可以轻松解决这个问题。

你已经知道FWL中的去偏步骤涉及到从协变量中预测干预,在当前场景下,协变量就是指虚拟变量:

model_deb = smf.ols("credit_limit ~ C(credit_score1_buckets)",
                    data=risk_data_rnd).fit()
model_deb.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept1173.0769278.9944.2050.000626.1931719.961
C(credit_score1_buckets)[T.200]2195.4337281.5547.7980.0001643.5302747.337
C(credit_score1_buckets)[T.400]3402.3796279.64212.1670.0002854.2243950.535
C(credit_score1_buckets)[T.600]4191.3235280.34514.9510.0003641.7904740.857
C(credit_score1_buckets)[T.800]4639.5105291.40015.9210.0004068.3095210.712
C(credit_score1_buckets)[T.1000]5006.9231461.25510.8550.0004102.7715911.076

由于虚拟变量基本上是作为组内平均值,因此可以看到,使用此模型,你预测的情况如下:如果credit_score1_buckets=0,则你正在预测credit_score1_buckets=0的组的平均额度;如果credit_score1_buckets=1,则你正在预测credit_score1_buckets=1的组的平均额度(通过对该组系数的截距求和得到,1173.0769+2195.4337=3368.510638),以此类推。这些正是各组平均值:

risk_data_rnd.groupby("credit_score1_buckets")["credit_limit"].mean()
credit_score1_buckets
0       1173.076923
200     3368.510638
400     4575.456498
600     5364.400448
800     5812.587413
1000    6180.000000
Name: credit_limit, dtype: float64

这意味着如果你想重新利用干预,你可以用一种更简单有效的方法。首先,计算每组的平均干预:

risk_data_fe = risk_data_rnd.assign(
    credit_limit_avg = lambda d: (d
                                  .groupby("credit_score1_buckets")
                                  ["credit_limit"].transform("mean"))
)

然后,为了得到残差,从干预中减去该组的平均值。由于这种方法减去了平均干预,它有时被称为干预去均值。如果你想在回归公式中这样做,你必须在上面的数学运算上包装I(…)函数:

model = smf.ols("default ~ I(credit_limit-credit_limit_avg)",
                data=risk_data_fe).fit()
model.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept0.09350.00332.1210.0000.0880.099
I(credit_limit - credit_limit_avg)4.652e-062.05e-062.2730.0236.4e-078.66e-06

你得到的参数估算值和你在模型中加入虚拟变量时得到的完全一样。这是因为,从数学上讲,它们是等价的。这种想法被称为固定效应,因为你正在控制一个组中固定的任何东西。它来自与时间结构(面板数据panel data)的因果推断的文献,你将在第四部分(第8、9章)探索到更多相关内容。

这个文献的另一种思路是将每组平均干预纳入回归模型(from Mundlak’s, 1978)。回归将从所包括的额外变量中对干预进行再分析,因此这里的效果是相同的:

model = smf.ols("default ~ credit_limit + credit_limit_avg",
                data=risk_data_fe).fit()
model.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept0.43250.02021.4180.0000.3930.472
credit_limit4.652e-062.02e-062.3050.0216.96e-078.61e-06
credit_limit_avg-7.763e-054.75e-06-16.3340.000-8.69e-05-6.83e-05

营销组合建模

衡量广告对销售的影响是非常困难的,因为你通常无法随机地确定谁能看到你的广告。在广告业,随机化的一个流行替代方案是营销组合建模(marketing mix modeling, MMM)。尽管名称很花哨,但MMM只是销售对营销策略指标和一些混杂因子的回归。例如,假设你想知道在电视、社交媒体和搜索广告上的预算对产品销售的影响。你可以运行一个回归模型,其中每个分析单元是一天:

S a l e s i = δ 0 + β 1 T V i + β 2 S o c i a l i + β 3 S e a r c h i + δ 1 C o m p e t i t o r S a l e s i + δ 2 M o n t h i + e i Sales_i=\delta_0+\beta_1 TV_i +\beta_2 Social_i + \beta_3 Search_i +\delta_1 CompetitorSales_i +\delta_2 Month_i +e_i Salesi=δ0+β1TVi+β2Sociali+β3Searchi+δ1CompetitorSalesi+δ2Monthi+ei

考虑到你可能在业绩良好的月份增加了营销预算这一事实,你可以通过在回归中添加额外的控制来调整这一混杂因子。例如,你可以包含竞争对手的销售额、每个月的虚拟变量和趋势变量。

4.8 遗漏变量偏差(Omitted Variable Bias):回归视角下的混淆

希望我在第3章已经说得很清楚了,当我说到共同原因(混淆因子)会使干预和结果之间的关系估算值产生偏差。这就是为什么需要考虑混杂因子的原因,例如,将它们包括在一个回归模型中。然而,回归有其独特的混淆偏差。当然,到目前为止所说的一切仍然成立。但回归可以让你更精确地了解混淆偏差。例如,假设你想估计信用额度对违约的影响,而工资是唯一的混淆因子:

g = gr.Digraph(graph_attr={"rankdir":"LR"})


g.edge("Lines", "Default")
g.edge("Wage", "Default"),
g.edge("Wage", "Lines")


g

在这种情况下,你知道你应该估算包含混淆因子的模型:

d e f a u l t i = β 0 + β 1 l i n e s i + β 2 > w a g e i + e i default_i=\beta_0 +\beta_1 lines_i +\beta_2 >wage_i +e_i defaulti=β0+β1linesi+β2>wagei+ei

但如果你转而估算一个更短的省略了混淆因子的模型:

d e f a u l t i = β 0 + β 1 l i n e s i + e i default_i=\beta_0 +\beta_1 lines_i +e_i defaulti=β0+β1linesi+ei

结果估算值就变得有偏差了:

short_model = smf.ols("default ~ credit_limit", data=risk_data).fit()
short_model.params["credit_limit"]
-2.401961992596885e-05

正如你所看到的,更高的信用额度导致违约下降,但你已经知道了这是无稽之谈。你不知道的是,其实你可以精确地估计偏差的大小。可以说在回归中,遗漏变量的偏差,等于包含该变量的模型中的效果,加上遗漏变量对结果的影响乘以遗漏变量对包含变量的回归。别担心,我知道这有点拗口,让我们慢慢消化吧。简单地说,这意味着在 T T T 上对 Y Y Y 的简单回归将是真正的因果参数 τ \tau τ 加上一个偏差项:

C o v ( T , Y ) V a r ( T ) = τ + β o m i t t e d ′ δ o m i t t e d \frac{Cov(T,Y)}{Var(T)}=\tau+\beta^\prime_{omitted}\delta_{omitted} Var(T)Cov(T,Y)=τ+βomittedδomitted

该偏差项是遗漏的混杂变量对结果的系数 β o m i t t e d \beta_{omitted} βomitted,乘以在干预上的遗漏变量的回归系数 δ o m i t t e d \delta_{omitted} δomitted。为了验证这一点,你可以使用以下代码获得早些时候得到的有偏参数估算,它再现了遗漏变量偏差公式:

long_model = smf.ols("default ~ credit_limit + wage",
                     data=risk_data).fit()

omitted_model = smf.ols("wage ~ credit_limit", data=risk_data).fit()

(long_model.params["credit_limit"] 
 + long_model.params["wage"]*omitted_model.params["credit_limit"])
-2.4019619925968762e-05

4.9 中性控件(Neutral Controls)

到目前为止,你可能对回归如何对混杂变量进行调整有了很好的了解。如果想知道在调整混杂因子 X X X 时干预 T T T Y Y Y 的效果,你所要做的就是将 X X X 包括在模型中。或者,为了得到完全相同的结果,你可以从 X X X 预测 T T T,得到残差,并使用它作为干预的去偏版本。对这些残差下 Y Y Y 进行回归,可以在保持 X X X 不变的情况下,得到 T T T Y Y Y 的关系。

但是 X X X 应该包含哪些变量呢?同样,并不是因为将变量添加到模型中就能对它们进行调整,所以你就希望在回归模型中包括一切变量。正如在前几章中所看到的,你不希望包括共同效果(对撞机)或中介,因为这些会导致选择偏差。但是在回归的环境中,还有你应该知道的更多类型的控件。一开始,它们看起来是无害的,但实际上是相当有害的。这些控件被称为中性(Neutral),因为它们不影响回归估算中的偏差。但它们可能会在方差方面产生严重影响。正如你将看到的,当涉及到在回归中包括某些变量时,存在偏见-方差权衡。例如,考虑以下DAG:

g = gr.Digraph(graph_attr={"rankdir":"LR"})

g.edge("credit_score1_buckets", "Default"),
g.edge("credit_score1_buckets", "Lines"),
g.edge("credit_score2", "Default"),
g.edge("Lines", "Default")

g

应该在模型中包括credit_score2吗?如果你不把它包括在内,你会得到你一直看到的同样的结果。该结果是无偏的,因为你正在针对credit_score1_buckets进行调整。但是请看看包含credit_score2时会发生什么,与您之前不包括credit_score2得到的结果进行比较,是什么发生了改变?

formula = "default~credit_limit+C(credit_score1_buckets)+credit_score2"
model = smf.ols(formula, data=risk_data_rnd).fit()
model.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept0.55760.05510.1320.0000.4500.665
C(credit_score1_buckets)[T.200]-0.03870.055-0.7100.478-0.1460.068
C(credit_score1_buckets)[T.400]-0.10320.054-1.8980.058-0.2100.003
C(credit_score1_buckets)[T.600]-0.14100.055-2.5740.010-0.248-0.034
C(credit_score1_buckets)[T.800]-0.11610.057-2.0310.042-0.228-0.004
C(credit_score1_buckets)[T.1000]-0.04300.090-0.4790.632-0.2190.133
credit_limit4.928e-061.93e-062.5510.0111.14e-068.71e-06
credit_score2-0.00072.34e-05-30.2250.000-0.001-0.001

首先,credit_limit的参数估算值有所提高。但更重要的是,标准误差减小了。这是因为credit_score2是结果 Y Y Y 的一个很好的预测器,它将有助于线性回归的去噪步骤。在FWL的最后一步中,由于包含了credit_score2, Y ~ \widetilde{Y} Y 的方差将会减少,在 T ~ \widetilde{T} T 上对 Y ~ \widetilde{Y} Y 进行回归将会产生更精确的结果。

这是线性回归的一个非常有趣的特性。结果表明,该方法不仅能有效地调节混杂因子,而且能有效地降低噪声。例如,如果你有来自适当随机化的A/B测试的数据,你就不需要担心偏差。但你仍然可以使用回归作为降噪工具,只要包括对结果有高度预测性的变量(而且不会导致选择偏差)。

降噪技术

还有其他的降噪技术。最著名的是CUPED,它是由微软的研究人员开发的,被广泛用于科技公司。CUPED与FWL定理的去噪部分非常相似。

4.9.1 噪声诱导控制

就像控制可以减少噪音一样,它们也可以增加噪音。例如,再次考虑条件随机实验的情况,但这一次,你感兴趣的是信用额度对支出的影响,而不是风险。与前面的示例一样,信用额度是随机分配的,且给定credit_score1。但这一次,我们假设credit_score1不是一个混淆因子。它会影响干预,但不会影响结果。这个数据生成过程的因果图如下所示:

g = gr.Digraph(graph_attr={"rankdir":"LR"})


g.edge("credit_score1_buckets", "Lines"),
g.edge("U", "Spend"),
g.edge("Lines", "Spend")

g

这意味着你不需要根据credit_score1进行调整以获得信用限额对支出的因果影响。一元回归模型就可以了。在这里,我保留了平方根函数,以考虑干预响应函数中的凸性:

spend_data_rnd = pd.read_csv("data/spend_data_rnd.csv")

model = smf.ols("spend ~ np.sqrt(credit_limit)",
                data=spend_data_rnd).fit()

model.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept2153.2154218.6009.8500.0001723.7232582.708
np.sqrt(credit_limit)16.29152.9885.4520.00010.42022.163

但是,如果包含credit_score1_buckets会发生什么情况呢?

model = smf.ols("spend~np.sqrt(credit_limit)+C(credit_score1_buckets)",
                data=spend_data_rnd).fit()

model.summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept2367.4867556.2734.2560.0001274.5283460.446
C(credit_score1_buckets)[T.200]-144.7921591.613-0.2450.807-1307.1851017.601
C(credit_score1_buckets)[T.400]-118.3923565.364-0.2090.834-1229.211992.427
C(credit_score1_buckets)[T.600]-111.5738570.471-0.1960.845-1232.4291009.281
C(credit_score1_buckets)[T.800]-89.7366574.645-0.1560.876-1218.7911039.318
C(credit_score1_buckets)[T.1000]363.8990608.0140.5990.550-830.7201558.518
np.sqrt(credit_limit)14.59533.5234.1420.0007.67321.518

可以看到,它增加了标准误差,扩大了因果参数的置信区间。这是因为,就像你在4.7.4 方差加权平均回归中看到的那样,OLS喜欢有高方差的干预。但如果你控制了一个解释干预的协变量,你就有效地减少了它的方差。

4.9.2 特征选择:偏差-方差权衡

在现实中,很难出现协变量影响干预而不是结果的情况。最有可能的是,有一堆混杂因子影响 T T T Y Y Y,但程度不同。在下图中, X 1 X_1 X1 T T T 有强影响,但对 Y Y Y 有弱影响; X 3 X_3 X3 T T T 有弱影响,但对 Y Y Y 有强影响, X 2 X_2 X2 处于中间地带,如每个箭头的粗细所示。

g = gr.Digraph(graph_attr={"rankdir": "LR"})


g.edge("X1", "T", penwidth="5"),
g.edge("X2", "T", penwidth="3"),
g.edge("X3", "T", penwidth="1"),

g.edge("X1", "Y", penwidth="1"),
g.edge("X2", "Y", penwidth="3"),
g.edge("X3", "Y", penwidth="5"),

g.edge("T", "Y"),

g

在这种情况下,你很快就会被夹在一块石头和一个坚硬的地方之间。一方面,如果你想消除所有的偏差,你必须包含所有的协变量;毕竟,它们是需要调整的混杂因子。另一方面,对干预的原因进行调整将增加估算量的方差。

为此,我们根据上图的因果图来模拟数据。这里,真实ATE是0.5。如果你试图在控制所有混杂因子的同时算计效果,得到的估算值的标准误差将太高,无法得出任何结论:

np.random.seed(123)

n = 100
(x1, x2, x3) = (np.random.normal(0, 1, n) for _ in range(3))
t = np.random.normal(10*x1 + 5*x2 + x3)

# ate = 0.05
y = np.random.normal(0.05*t + x1 + 5*x2 + 10*x3, 5)
df = pd.DataFrame(dict(y=y, t=t, x1=x1, x2=x2, x3=x3))

smf.ols("y~t+x1+x2+x3", data=df).fit().summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept0.27070.5270.5140.608-0.7751.316
t0.86640.6071.4270.157-0.3392.072
x1-7.06286.038-1.1700.245-19.0494.923
x20.01433.1280.0050.996-6.1956.224
x39.62920.88710.8610.0007.86911.389

如果你知道其中一个混杂因子是干预的强预测因素和结果的弱预测因素,可以选择从模型中删除它。在这个例子中,就是 X 1 X_1 X1。现在,请注意!这将使你的估算有偏差。但如果这也能显著减少方差,也许这是值得付出的代价:

smf.ols("y~t+x2+x3", data=df).fit().summary().tables[1]
coefstd errtP>|t|[0.0250.975]
Intercept0.18890.5230.3610.719-0.8491.227
t0.15850.0463.4100.0010.0660.251
x23.60950.5826.1970.0002.4534.766
x310.45490.53719.4530.0009.38811.522

底线是,你在模型中包含(调整)的混杂因素越多,你的因果估算的偏差就越低。然而,如果你包括那些对结果的预测较弱但对干预有很强预测的变量,这种偏差的减少将在方差增加方面付出巨大的代价。有时为了减少方差,接受一点偏差是值得的。此外,你应该非常清楚,并不是所有混淆因子都是平等的。当然,所有的混淆因子都是 T T T Y Y Y 共有的。但如果他们对干预影响太多,而对结果几乎只字不提,你真的应该考虑把它从你的调整中剔除。这对于回归是有效的,对于其他调整策略也是如此,如倾向得分加权(见第5章)。

不幸的是,在因果推断中,混杂因子在解释消干预方面有多弱才合适被移除,仍然是一个悬而未决的问题。尽管如此,仍然有必要知道这种偏差-方差权衡的存在,因为它将帮助理解和解释线性回归的情况。

4.10 要点总结

这一章是关于回归的,但是从一个非常不同于你通常在机器学习书籍中看到的角度,回归在这里不是一个预测工具。注意我怎么一次都没提到 R 2 R^2 R2!相反,这里使用回归作为一种主要调整混杂因子的方法,有时作为一种减小方差的技术。

这一章的核心是,如果条件独立性成立,正交化作为一种方法,可以使干预看起来像是随机分配的。形式上,如果 Y t ⊥ T ∣ X Y_t \perp T|X YtTX,你可以通过回归 X X X 下的 T T T 来调整 X X X 的混淆偏差,并获得残差。这些残差可以被视为干预的一种无偏差版本。

该方法进一步使用了Frisch-Waugh-Lovell定理,该定理指出,多元回归可以分解为以下步骤:

  1. 去偏步骤,在此步骤中,你对混杂因子 X X X 下的干预 T T T 进行回归,获得干预残差 T ~ = T − T ^ \tilde{T}=T-\hat{T} T~=TT^
  2. 去噪步骤,在此步骤中,你对混杂因子 X X X 下的结果 Y Y Y 进行回归,获得结果残差 Y ~ = Y − Y ^ \tilde{Y}=Y-\hat{Y} Y~=YY^
  3. 结果模型,在该模型中,你对干预残差 T ~ \tilde{T} T~ 下的结果残差 Y ~ \tilde{Y} Y~ 进行回归,获得 T T T Y Y Y 的因果影响的估算值

本章的所有内容都是基于这个定理——无论是非线性干预响应函数,理解使用分类变量的回归如何实现加权平均,还是回归中好的和坏的控制的作用。


系列文章专栏:
使用Python进行因果推断(Causal Inference in Python)

  第1章 因果推断导论
  第2章 随机实验与统计学回顾
  第3章 图形化因果模型
  第4章 线性回归的不合理有效性
  第5章 倾向分
  第6章 效果异质性
  第7章 元学习器
  第8章 双重差分

  持续更新中:
  第9章 综合控制
  第10章 Geo实验与Switchback实验
  第11章 不依从性与工具
  第12章 后续行动


【参考】

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
好的,我来给您详细介绍一下如何完成这个任务。 首先,我们需要导入 matplotlib 和 numpy 库来生成随机点并画图。请确保您已经安装了这些库。 ``` python import matplotlib.pyplot as plt import numpy as np ``` 接下来,我们定义两组随机点。为了方便起见,我们将两组点都设置为二维的,并且每组点有100个。 ``` python np.random.seed(0) N = 100 x1 = np.random.randn(N, 2) + np.array([0, 2]) x2 = np.random.randn(N, 2) + np.array([2, 0]) ``` 现在,我们可以用 matplotlib 将这些点可视化出来。我们可以使用 scatter 函数来绘制散点图。 ``` python plt.scatter(x1[:,0], x1[:,1], label='x1') plt.scatter(x2[:,0], x2[:,1], label='x2') plt.legend() plt.show() ``` 接下来,我们手工生成一条直线将这两组点分开。这里我们设置 $w_1=1$,$w_2=-1$,$b=0$。这条直线的方程是 $x_1-x_2=0$。 ``` python w = np.array([1,-1]) b = 0 x_line = np.linspace(-4, 4, 100) y_line = -x_line*w[0]/w[1] + b/w[1] plt.plot(x_line, y_line, 'g-', label='separating line') plt.scatter(x1[:,0], x1[:,1], label='x1') plt.scatter(x2[:,0], x2[:,1], label='x2') plt.legend() plt.show() ``` 现在,我们开始使用 PyTorch 来构建神经网络并优化得到网络参数。首先,我们需要将数据转换为 PyTorch 张量,并将标签转换为 0 和 1。 ``` python import torch x = np.vstack([x1, x2]).astype(np.float32) y = np.concatenate([np.zeros(N), np.ones(N)]).astype(np.float32) x = torch.from_numpy(x) y = torch.from_numpy(y) ``` 接下来,我们使用 PyTorch 定义一个简单的两层神经网络,并使用交叉熵损失函数和随机梯度下降(SGD)优化器。 ``` python class Net(torch.nn.Module): def __init__(self): super(Net, self).__init__() self.fc1 = torch.nn.Linear(2, 1) def forward(self, x): y = torch.sigmoid(self.fc1(x)) return y net = Net() criterion = torch.nn.BCELoss() optimizer = torch.optim.SGD(net.parameters(), lr=0.1) ``` 最后,我们使用 PyTorch 进行训练。我们迭代100次,每次随机选择一个小批量数据进行训练。 ``` python for epoch in range(100): optimizer.zero_grad() outputs = net(x) loss = criterion(outputs.view(-1), y) loss.backward() optimizer.step() if (epoch+1) % 10 == 0: print('Epoch [%d/%d], Loss: %.4f' % (epoch+1, 100, loss.item())) ``` 训练完成后,我们可以使用训练好的模型来预测每个点所属的类别,并将分类结果可视化出来。 ``` python x_grid, y_grid = np.meshgrid(np.linspace(-4, 4, 100), np.linspace(-4, 4, 100)) xy_grid = np.hstack([x_grid.reshape(-1,1), y_grid.reshape(-1,1)]) xy_grid_tensor = torch.from_numpy(xy_grid.astype(np.float32)) z_grid_tensor = net(xy_grid_tensor).detach().numpy().reshape(x_grid.shape) plt.contourf(x_grid, y_grid, z_grid_tensor, cmap=plt.cm.binary, alpha=0.3) plt.scatter(x1[:,0], x1[:,1], label='x1') plt.scatter(x2[:,0], x2[:,1], label='x2') plt.legend() plt.show() ``` 现在,您已经完成了这个任务。如果您在执行过程中遇到了任何问题,请告诉我。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

MangoGO芒狗狗

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值