用DoWhy做因果推断:模拟电商优惠券场景估算ATE全流程
上次写完了环境搭建和因果图基础不少读者都在问同一个问题能不能给一个完整案例从生成数据到跑出ATE把DoWhy全流程走一遍这篇就专门满足这个需求。我们用DoWhy框架对一个模拟的电商优惠券投放场景做因果推断你会看到混淆变量怎么把真实处理效应带偏也会看到同一个数据用不同估计方法各得到什么结果。如果你刚接触因果推断跟着代码敲一遍会比干看几篇理论收获大得多。案例本身不复杂平台随机给一部分用户发放优惠券我们想知道“发券”这件事对用户消费金额的真实影响。难点在于用户是否会收到优惠券并不完全随机历史消费越高、近期活跃度越强的用户越容易被系统自动选中而这些变量同时也会直接影响消费金额。如果直接对比发券组和未发券组的平均消费得到的差值里混合了“发券本身的因果效应”和“用户本身更爱花钱的组间差异”。因果推断要干的事就是把这种混杂拆干净。1. 实战场景拆解为什么这个案例值得跑一遍1.1 关联不等于因果得用数据说话很多人理解因果推断第一反应是“分组对比一下不就行了”。这在完全随机实验里确实成立但现实业务里几乎没有理想的随机分组。营销系统会按用户画像定向投放产品经理会对高活跃用户做促活算法模型本身就会学习用户特征于是处理组和对照组在干预之前就已经不一样了。用一个经典类比冰淇淋销量和溺水人数高度正相关但谁也不会觉得吃冰淇淋会导致溺水。共同原因是夏天来了——气温既让冰淇淋好卖也让下水游泳的人变多。如果只看到两个事件的数字同步上涨就动手优化业务属于典型的把相关性当因果。因果推断工具就是帮你在不具备随机实验条件时靠观测数据模拟出一套“可接受的分组”得到更接近真实因果的数字。我们的电商案例里历史消费X和活跃度Z就是“气温”这个角色。它们既会推高用户拿到优惠券的概率也会直接抬高用户的消费金额。如果不控制它们发券组和未发券组的消费差异里混入了太多先天的用户差异优惠券的真实效果就被污染了。1.2 模拟一个带混杂的观测数据集为了让整个过程可复现我在本地生成了一份模拟数据样本量2000。每个样本包含四个变量T是否发放优惠券0或1X用户近三个月的累计消费金额连续变量Z用户最近一周的活跃度评分连续变量Y实验期内的消费金额连续变量生成逻辑用到了逻辑回归和线性回归目的是让变量之间的关系足够明显又不至于完美到看不出干扰。下面是生成代码import numpy as np import pandas as pd np.random.seed(42) n 2000 X np.random.normal(loc100, scale10, sizen) # 历史消费 Z np.random.normal(loc5, scale1.5, sizen) # 活跃度评分 # 发放优惠券概率受X和Z影响历史消费越高、活跃度越高越容易被发券 logit_p -3.0 0.02 * (X - 100) - 1.0 * (Z - 5) np.random.normal(0, 1, sizen) p_t 1 / (1 np.exp(-logit_p)) T np.random.binomial(1, p_t) # 消费金额Y真实ATE设定为2.0同时受X和Z影响 Y 10.0 2.0 * T 0.15 * (X - 100) 0.8 * (Z - 5) np.random.normal(0, 2, sizen) df pd.DataFrame({T: T, X: X, Z: Z, Y: Y}) print(df.head()) print(T1的均值, df[df[T]1][Y].mean()) print(T0的均值, df[df[T]0][Y].mean()) print(朴素差异, df[df[T]1][Y].mean() - df[df[T]0][Y].mean())我故意把真实ATE设定为2.0也就是说在其他条件全部相同时发券能让用户多消费2个单位的金额。但你跑完上面这段代码会发现朴素分组差异大概率不是2.0可能偏高或者不稳定。这就是混杂导致的偏差也是后面所有步骤要解决的问题。1.3 先想清楚要估计什么因果推断的第一步不是写代码而是想清楚目标量。这里我们关心的是“如果所有人都发券对比所有人都没发券平均消费金额会差多少”。这叫做平均处理效应简称ATE。在DoWhy里我们最终就是想从模型里拿到一个合理估计并且附上置信区间。除此之外你还可以估计“仅对实际收到券的人发券与不发券的差异”也就是ATT或者关注某个特定用户群体内部的异质性处理效应。这篇不展开这些变体只围绕ATE走完标准流程。明确目标量有个额外好处能逼你去画因果图。如果不把变量之间的假设关系写清楚后面识别和估计阶段都会非常被动。2. DoWhy框架的工作流程先建模再识别后估计最后反驳2.1 四步流程背后的设计逻辑DoWhy把因果推断拆成了四个阶段建模、识别、估计、反驳。我个人的理解是这四步分别对应四个问题模型你对数据生成机制知道多少识别在已知机制下ATE能不能用一个公式表达出来估计用哪个统计模型去算这个公式反驳估算结果是不是脆弱到换个假设就崩了这个流程很像医生诊断。建模阶段是在画“家族病史图”把症状之间的可能影响关系理清楚识别阶段是判断“按这张图能不能确诊”估计阶段是开药选合适的统计方法反驳阶段则是做个复诊排除误诊风险。很多传统分析方法直接跳到最后一步用回归跑数也不画图、不讨论混杂结构最后汇报一个系数就完了。一旦业务方问你“这个系数能不能指导投放”很难回答。2.2 因果图怎么画才不容易被识别器骂在DoWhy里建模主要靠创建CausalModel对象把数据、处理变量、结果变量、共同原因传进去。这里的“共同原因”就是图中的混淆变量它们同时指向处理T和结果Y。在我们的模拟数据里X和Z都是共同原因。画图的细节容易被忽略我踩过一次坑把某个只影响处理、不影响结果的变量也写进了common_causes里。这样不会报错但会无谓地增加估计方差甚至让置信区间变大。反过来如果你漏掉了一个同时影响T和Y的变量那才是大问题——后门路径没堵住ATE会出现系统性偏差。如果变量关系更复杂比如存在中介变量发券通过增加点击量再影响消费或者存在工具变量一个只影响发券、不直接通过其他路径影响消费的变量就需要用图论去判断识别策略。DoWhy允许你传入graph对象或者一个dot语言的字符串还可以用view_model()把图画出来。我建议每分析一个案例都先认真审视一下图不要觉得麻烦。2.3 识别策略后门准则、前门准则、工具变量识别阶段做的事是把ATE这个因果目标翻译成统计上可以估计的形式。翻译的桥梁有三类常用准则后门准则找到一组变量使得控制了它们之后所有从T到Y的后门路径都被阻断然后可以用条件期望之差来表示ATE。前门准则存在一个中介变量MM完全介于T和Y之间且没有其他未观测混杂影响M到Y的过程可以用两阶段估计实现。工具变量找到一个只影响T、不直接影响Y、也不受Y影响的变量利用它的随机变化来提取T的作用。我们的案例没有工具变量也不涉及中介最简单的做法就是选择后门准则把X和Z作为调整集。DoWhy的identify_effect()会自动帮你识别估计量estimand代码里能看到它是否识别成功。如果识别失败通常说明图中有未观测混淆或者某个变量同时被放在Treatment和common_causes里概念上自相矛盾。3. Python实战从建模到反驳的完整代码3.1 前期准备安装依赖这一步比较基础但版本问题值得单独说。建议用相对新且稳定的DoWhy版本实际测试中0.11.x版本能让下面的API平稳运行。需要安装的包包括dowhy、networkx、statsmodels、scikit-learn、pandas、numpy。pip install dowhy networkx statsmodels scikit-learn pandas numpy如果你已经装了旧版本还遇到了CausalModel导入异常建议直接升级到最新版再继续。不同版本之间API有变化网上很多教程代码跑不通八成是版本不匹配。3.2 建模阶段创建CausalModel代码非常直观把数据框和变量名传进去就行。from dowhy import CausalModel model CausalModel( datadf, treatmentT, outcomeY, common_causes[X, Z] ) model.view_model()这里有一点要注意common_causes列表里的变量必须是数据里真实存在的列。如果你想表达变量之间存在因果方向比如X影响ZDoWhy只把common_causes当作都与T和Y关联的混淆集合不会自动帮你推断X和Z的先后顺序。更精细的图表达需要给gml_graph参数传入一份DAG不过本案例用common_causes就足够了。构建完模型之后查看图时会看到X和Z指向T和Y的双向关系。其实在DoWhy内部common_causes会被处理为同时影响treatment和outcome的节点后门路径也被自动标记出来。3.3 识别阶段让DoWhy判断ATE能不能算这一步只需要调用一个方法identified_estimand model.identify_effect(proceed_when_unidentifiableTrue) print(identified_estimand)如果一切正常输出里会出现Estimand的三种表达式。其中应该有Estimand 1通过后门准则得到的因果估计量Estimand 2通过后门准则的另一种形式注意那个proceed_when_unidentifiable参数默认是False。当识别有问题时设为True可以让流程继续但结果可能没有意义。如果你刚接触建议不要随便把它设为True否则容易在后续阶段拿到一个虚假的ATE却不自知。我在项目里用过一次结果解释起来非常痛苦因为你不确定这个ATE是不是真的被识别了。如果识别失败DoWhy会抛异常通常提示“缺乏足够信息来识别因果估计量”。这多半意味着你提供的图中存在没有控制的混杂路径或者没有工具变量。解决方式是回到变量选择阶段补充合适的混淆变量或者补充工具变量。我们的模拟数据里X和Z作为混淆变量已经足够所以识别阶段不会遇到障碍。打印出来的estimand会有“backdoor”字样的公式意思是需要控制后门变量。3.4 估计阶段多种估计器对比着看识别只是确定了“用什么公式算”具体怎么算可以有很多选择。DoWhy支持线性回归、倾向得分分层、倾向得分匹配、逆倾向加权、双重机器学习等。强烈建议至少跑两种方法对比因为不同估计器对模型设定和权重分布的敏感度不同结果稳定才敢说结论可靠。先跑最常用的线性回归后门调整estimate model.estimate_effect( identified_estimand, method_namebackdoor.linear_regression, target_unitsate, test_significanceTrue ) print(estimate)这里target_units可以填ate或att。如果填ate估计的就是全体的平均处理效应如果填att则是实际受处理群体的效应。案例里我们想要ATE所以就填ate。test_significanceTrue会给出p值和置信区间。跑完能看到线性回归估计出的ATE非常接近2.0同时置信区间也不会太宽。注意DoWhy返回的estimate有value属性、置信区间属性和p值建议直接打印它的.str()看完整详情更直观。再跑一个倾向得分分层作为对比estimate_strat model.estimate_effect( identified_estimand, method_namebackdoor.propensity_score_stratification, target_unitsate ) print(estimate_strat)倾向得分分层的逻辑是先估计每个样本被发券的概率然后根据这个概率把样本分层在每一层内对比T1和T0的Y差异再按层加权汇总。它不假设Y和混淆变量之间是简单的线性关系所以在很多非线性场景下比线性回归稳。你也可以顺手跑一下逆倾向加权estimate_ipw model.estimate_effect( identified_estimand, method_namebackdoor.propensity_score_matching, target_unitsate ) print(estimate_ipw)这里我实际用的backdoor.propensity_score_matching底层是倾向得分匹配。不过匹配方法在样本重叠不足时容易丢失样本所以可以作为参考不必作为唯一结论。3.5 反驳阶段给结论做压力测试估计出的ATE只是一个点估计如果不做反驳很难说服业务方。DoWhy的反驳方法本质上是“换一个世界再检验一次”。常用的三个测试安慰剂处理变量随机打乱T模拟“处理没有效果”的世界。如果估计结果还显著说明原始模型可能吸收了噪声。随机共同原因往模型里加一个随机生成的无关变量看ATE是否保持稳定。如果ATE大幅波动说明模型识别依赖关键条件。数据子集验证用样本的一部分重新估计检验ATE是否对样本选择敏感。代码实现如下refute_placebo model.refute_estimate( identified_estimand, estimate, method_nameplacebo_treatment_refuter, placebo_typepermute, num_simulations100 ) print(refute_placebo) refute_random model.refute_estimate( identified_estimand, estimate, method_namerandom_common_cause_refuter, num_simulations100 ) print(refute_random) refute_subset model.refute_estimate( identified_estimand, estimate, method_namedata_subset_refuter, subset_fraction0.6, num_simulations100 ) print(refute_subset)看结果时主要看new effect是否接近原始估计。安慰剂测试的new effect应该接近0并且p值较大这样说明我们没有把随机噪声误以为因果效应。随机共同因测试中加入随机变量后的新估计应该和原始估计差距很小。数据子集测试中用60%样本重新估计的ATE也应该落在原始置信区间附近。我们这里的模拟数据真实ATE是2.0正常跑完上述三个测试应该是全部通过尤其安慰剂测试会得到一个非常接近0的值。如果某个测试给出异常结果就要回头检查因果图、common_causes的变量选择以及是否发生了极端的不平衡。4. 问题排查与避坑实录4.1 常见报错和应对方法DoWhy的报错信息通常比较友好但也有一些容易误导人的情况。下面列几个我实际遇到过的问题报错“Treatment variable is required”一般是构造dataset时列名大小写或前后空格问题检查一下df.columns。报错“Causal Model has no path from treatment to outcome”说明你的图不完整treatment和outcome之间没有有效连边可能是common_causes写错了或者数据中T和Y完全没有相关性。报错“pandas.errors.DataError: No objects to concatenate”多数是倾向得分分层中某个层为空尤其是在样本量太小或倾向得分极端分布时。可以增加样本量或者改用连续倾向得分加权方法。报错“numpy.linalg.LinAlgError: Singular matrix”通常是因为混淆变量之间存在完全共线性。先做相关性检查把重复变量删掉。遇到报错不要急着换方法先看是不是数据层面出了问题。DoWhy强依赖pandas变量的编码类型最好是数值型如果T是字符串需要先转成0/1。4.2 因果图画错结果会出现什么症状如果你在common_causes里漏掉了一个真正的混淆变量而恰好这个变量对处理分配和结果影响都很大你做后门调整时就会看到ATE系统性偏离真实值。而且这种偏离通常不会报警R方、置信区间看起来都正常这是最棘手的。我曾经在一个项目里把某个用户生命周期阶段变量忘在了模型外面只控制了活跃度。估计出的ATE比真实值高了一大截还特别显著。后来是反驳阶段随机共同因测试没过才逼着我回头审查变量。所以反驳测试不只是走形式它真的能发现隐藏的混杂问题。另一个常见画图错误是过度控制中介变量。如果你把发券之后才产生的中间变量比如用户点击次数也放进common_causes那就等于把一部分因果效应也“控制”掉了。这样的ATE会偏小甚至方向改变。判断方法是如果一个变量本身受到处理T的影响同时又影响结果Y那它大概率是中介不要放进入口变量集合。4.3 倾向得分匹配的样本重叠问题倾向得分匹配如果做得不好会出现倾向得分极端接近0或1的样本匹配时找不到合适的邻居就会丢掉样本或者引入偏倚。检查方法很简单画出倾向得分的直方图分别看T1和T0分组。如果两组倾向得分几乎完全分离说明很多估计落在共同支撑区域外此时匹配方法的可信度大打折扣。在我的经验里碰到这种情况可以改用逆倾向加权同时对权重做截断比如把权重控制在0.1到10之间避免单条样本权重过大。还有一种思路是使用双重稳健估计它结合了倾向得分模型和结果模型只要其中有一个正确ATE就是一致的比单个模型鲁棒性更好。4.4 关于显著性、p值和业务判断讲个很多人忽略的细节DoWhy的显著性检验默认是基于正态近似的参数检验当样本量较小时可能不够准确。所以如果我们的样本只有几十条不建议直接依赖p值做决策。更稳妥的做法是同时看置信区间宽度和多个方法的一致性。置信区间实在太大时就算p值小于0.05这个ATE也未必能支持产品决策。我还习惯在做完反驳之后把ATE换算成业务语言。比如发券的平均边际消费增加是2.0元再乘上发券用户数才是平台期望的增量销售额。如果这个增量的置信区间下界连1元都不到那运营策略就要再掂量掂量。因果推断给出的是方向和一个可信的区间而不是一个金科玉律。写在最后的小建议如果你要在一个实际业务里用DoWhy我有一个非常个人的建议不要把整个流程封装成一个黑盒函数就完事至少保留因果图和识别结果的可视化输出。因为真正需要解释的往往不是代码而是你为什么选择这些变量作为common_causes。把这套流程跑完以后我对“控制变量”这四个字的理解也深了一层。以前做回归分析时加变量、减变量都凭感觉现在会先画一张因果图再动手。因果推断不是让你多背一个工具而是逼你在跑数之前先把脑子里那个默认的因果模型写下来然后让代码替你去检验它。这也是我觉得比某一个具体ATE更有价值的东西。