简介:这份PDF课件出自DataFunSummit 2021实验与因果推断论坛,由腾讯高级数据研发工程师郭棋林分享,面向数据分析师、增长与产品算法从业者,聚焦观测数据下的因果效应识别,并落到「启动重置」等业务场景。内容先厘清因果关系与相关关系,借「穿鞋睡觉与起床头疼」讲混淆因子、对撞因子与样本选择偏差,分析RCT的伦理与实现局限,再展开DID、倾向得分匹配、逆概率加权、工具变量、断点回归等准实验方法及平行趋势等假设检验。案例覆盖极端天气下天气资讯对用户留存的双重差分分析、小说业务首章完成率的断点回归,以及用户离开约40分钟后返回、搜索内容丢失与广告等待是否伤害体验的重置类问题,并强调时间序列对照。包内仅1个PDF、约46.98MB,已有281人学习,适合按目录逐节研读,用作方法梳理与业务归因参考。
1. 用户搜索完关掉 App,40 分钟后再进来,看到的是被重置的信息流首页加开屏广告。产品经理问:这个重置伤不伤体验、值不值得留?直觉答案是开 A/B,随机一半用户不重置。工程上却做不到:重置是客户端冷启动链路的固定行为,改它要发版灰度,周期长;开屏广告和首页推荐两个改动还会互相污染。
问题只能落回观测数据——从已经发生的启动行为里把「重置」的效应估出来。这是观测数据因果推断的典型战场:没有随机分配,只有混杂。腾讯郭棋林在 DataFunSummit 2021 实验与因果推断论坛上,用启动重置类问题把 DAG 建模、准实验方法选型、假设检验串成一条完整链路。适合已经会跑 A/B、但被「没法做实验」卡住的数据分析师和算法同学。
2. 从 DAG 出发:混杂因子、对撞因子与后门调整
观测数据里两组用户的差异,永远可以拆成三份:干预的真实效应、混杂带来的偏差、随机噪声。准实验方法做的所有事情,本质上都是在第三份之外,尽量把第二份剥离干净。剥离的前提是先把业务假设画成一张有向无环图(DAG),否则后面选什么方法都是盲猜。
2.1 三个反例,区分三种变量角色
「穿鞋睡觉」和「第二天起床头疼」相关性很高,但让用户脱鞋睡并不会缓解头疼——共同原因是「昨晚喝酒」,这是混杂因子(confounder),它同时影响 T 和 Y,不控制就会把虚假关联算成因果。
「才华和相貌成反比」听起来像真的,但如果只在「是否在娱乐圈」这个条件下看,两个本来独立的变量会凭空出现负相关。因为「是否在娱乐圈」同时由才华和相貌决定,这是对撞因子(collider),控制它反而制造偏差。
第三种是中介因子(mediator):干预 T 先改变「首屏加载完成率」,再影响留存。控制中介会把总效应拆成直接效应,如果你只想看「重置总共值不值」,中介不能放进回归。
| 变量角色 | 与 T 的关系 | 与 Y 的关系 | 处理方式 |
|---|---|---|---|
| 混杂因子 | T ← Z → Y | 有 | 必须控制(后门调整) |
| 对撞因子 | T → Z ← Y | 有 | 严禁控制 |
| 中介因子 | T → Z → Y | 有 | 按需控制,看的是直接效应 |
| 工具变量 | Z → T,无后门到 Y | 仅通过 T 影响 Y | 用于 T 不可观测混杂时 |
| 代理混杂 | 是 Z 的观测替代 | 有 | 控制后仍需敏感性分析 |
2.2 后门准则的最小实现
画完 DAG,先判断「是否存在一条从 T 到 Y 的后门路径」,如果有,能不能被一组观测变量 Z 全部阻断。这组 Z 就是后门调整集。把它丢进回归,是最朴素也最常用的估计方式:
import statsmodels.formula.api as smf # 数据列:reset(1=冷启动被重置到首页), retention(次日留存), # age, city_tier, hist_dur_7d, device_band 为观测到的混杂因子 m = smf.ols( 'retention ~ reset + age + C(city_tier) + hist_dur_7d + C(device_band)', data=df ).fit(cov_type='HC1') print(m.params['reset'], m.pvalues['reset'])C()表示把分类变量展开成哑变量,cov_type='HC1'是异方差稳健标准误,防止不同人群方差差异拉偏显著性。回归系数就是调整后的平均处理效应(ATE)。这段代码的隐患在于:它假设混杂因子全部被观测到且形式线性可加。一旦有未观测混杂,或者 T 与 Y 的关系非线性,系数就会偏。所以回归只能当作起点,不能当作终点。
2.3 ATE、ITE、CATE:你估的到底是哪个量
同一份数据,问的问题不同,要估的量也不同。ATE 是「如果让所有人都重置」和「让所有人都不重置」的期望差异;ITE 是单个用户身上的差异,天生不可观测(一个人不可能同时经历两种状态);CATE(Conditional Average Treatment Effect)是在给定协变量 X 下的人群平均效应,也就是常说的因果推断 CATE,它是唯一能在真实业务里被估计和验证的量。
CATE 的价值在于,它能回答「重置对重度搜索用户是负向、对信息流用户是正向」这类结构性差异。后面要做的 PSM 和 IPTW,本质都是在为 CATE 估算一个可比的对照组。
3. DID 双重差分:天气资讯留存案例的完整实现
有了 DAG 和可识别的调整集,接下来是方法选型。启动重置最大的麻烦是:重置和「用户主动搜索过」强相关,直接用全体用户做对照,混杂极重。准实验方法里,双重差分(DID)处理的是「有一批用户在某时间点被暴露,另一批没有」这类场景,正好对上。
3.1 天气资讯案例的分组与数据准备
原分享里给的例子是:极端天气(8 月 6 日)下,实验组 100 万用户在当天被曝光了天气资讯,对照组 300 万用户没曝光。实验组次留 50%,对照组 30%,直接相减得到 20%——这个数字显然是假的,因为两组用户本身就不一样。
DID 的做法是再减一层时间趋势:看两组在干预前的留存差距,再和干预后的差距做差。
| 分组 | 用户量 | 干预前留存 | 干预后留存 | 前后差 |
|---|---|---|---|---|
| 实验组(8.6 曝光天气) | 100w | 48.0% | 50.0% | +2.0% |
| 对照组(8.6 未曝光) | 300w | 29.4% | 30.0% | +0.6% |
| DID 估计 | — | — | — | +1.4% |
直接对比得到 20%,DID 得到 1.4%。差出来的 18.6% 全是样本选择偏差。这个案例后来还被产品团队叠加了「天气专门的链路审核策略 + 推荐策略」和「产品表达形态」两层结构分析,因为 1.4% 的均值掩盖了不同人群的异质性。
3.2 DID 回归实现与聚类标准误
手工做差只适用于单期两组的简单结构。真实数据里,干预往往持续多天、涉及多个城市、多个版本,需要写成回归形式:
import pandas as pd import statsmodels.formula.api as smf # df 列:city, date, group(1=曝光天气资讯), post(1=8.6 及之后), retention(次日留存) df['did'] = df['group'] * df['post'] m = smf.ols( 'retention ~ did + C(city) + C(date)', data=df ).fit(cov_type='cluster', cov_kwds={'groups': df['city']}) print(m.params['did'], m.pvalues['did'])C(city)吸收城市间的固定差异,C(date)吸收所有城市的共同时间波动,did的交乘项系数就是双重差分估计量。关键细节在cov_type='cluster':同一个城市内部多天的观测并非独立,不聚类会把标准误算小,导致明明不显著也报成显著。城市数少于 40 个时,聚类标准误本身也会偏小,通常还要配 wild bootstrap 校正。
3.3 平行趋势检验:DID 唯一扛不住的假设
DID 的核心识别假设是平行趋势:如果没做干预,实验组和对照组的时间趋势形状相同。这个假设不可直接验证,只能用事件研究法间接检验——把干预前的每一期相对效应估出来,如果它们都在 0 附近徘徊,说明趋势平行。
import numpy as np # rel = 相对 8.6 的天数;用 rel × group 的交互项替换单一 post df['rel'] = (df['date'] - pd.Timestamp('2021-08-06')).dt.days win = df[df['rel'].between(-7, 7)].copy() # 以 -1 期为基期,其余各期估交互项 win['grp_rel'] = win['group'].astype(str) + '_' + win['rel'].astype(str) ev = smf.ols( 'retention ~ C(grp_rel, Treatment(reference="1_-1")) + C(city) + C(date)', data=win ).fit(cov_type='cluster', cov_kwds={'groups': win['city']}) # 只看 group=1 的 7 个事前交互项,系数应不显著且量级小 pre_coefs = {k: v for k, v in ev.params.items() if k.startswith('C(grp_rel')[0] and '1_' in k and '_-' in k} print(pre_coefs)如果事前系数里出现明显偏离 0 的项,说明平行趋势不成立,DID 结果不可信。原分享里还提到 DID 的第二个假设no pretrement effect——干预前不能已经有别的事件提前改变了实验组。这个只能靠业务侧的排查,统计上无法证伪。配安慰剂检验(假设一个假干预日,看是否也能算出显著效应)是常规防御手段。
4. 启动重置类问题的通用分析链路:PSM、IPTW 与 CUPED
启动重置和天气资讯不太一样,它不是「某一天被曝光」,而是「每一次冷启动都在发生」。用户可能在 40 分钟内多次进出 App,每次返回都触发重置;对照组的「不重置」用户不是被随机抽出来的,而是「没搜索过、没走出过信息流」的自然群体。这类问题没有明显的时间断点,DID 就不合适了,得换 PSM 和 IPTW。
4.1 先把「重置」定义清楚,再切人群
这一步比建模重要十倍。启动重置有三种常见触发路径,混在一起会让估计直接失效:
- 路径 A:用户搜索后离开 40 分钟以上再返回,被重置到首页主 Tab,搜索内容丢失;
- 路径 B:用户刷完小视频离开 1 分钟返回,重新看到启动页和广告闪屏;
- 路径 C:用户主动杀进程后启动,触发冷启动重置。
三条路径的混杂结构完全不同。路径 A 混杂了「搜索意图强度」,路径 B 混杂了「短时离开频次」,路径 C 混杂了「设备性能与内存」。必须先按触发路径切成独立分析单元,每条路径单独建模,最后再合并。
4.2 PSM 匹配与平衡性检验
倾向得分匹配(PSM)的思路是:把每个「被重置」用户,在对照组里找一个协变量分布几乎一样的用户。协变量就是上一步识别出的混杂因子。
from sklearn.linear_model import LogisticRegression from sklearn.neighbors import NearestNeighbors from sklearn.preprocessing import StandardScaler import numpy as np FEATURES = ['hist_search_cnt_7d', 'hist_feed_dur_7d', 'sessions_7d', 'search_click_rate', 'device_band', 'city_tier'] X = StandardScaler().fit_transform(df[FEATURES]) y = df['reset'].values ps = LogisticRegression(max_iter=1000, C=1.0).fit(X, y).predict_proba(X)[:, 1] df['ps'] = ps treat = df[df['reset'] == 1] ctrl = df[df['reset'] == 0] nn = NearestNeighbors(n_neighbors=1).fit(ctrl[['ps']].values) _, idx = nn.kneighbors(treat[['ps']].values) matched_ctrl = ctrl.iloc[idx.ravel()].copy() matched_ctrl.index = treat.index att = treat['retention'].mean() - matched_ctrl['retention'].mean() print(f'PSM ATT = {att:.4f}')StandardScaler让量纲差距大的特征不会主导距离;C=1.0是逻辑回归的正则强度,越小越强,混杂因子维度高时适当调大防过拟合;1:1 最近邻是默认做法,样本量悬殊时可用 1:k 提高方差稳定性。
匹配完必须做平衡性检验,不然匹配了个寂寞:
def smd(a, b): return (a.mean() - b.mean()) / np.sqrt((a.var() + b.var()) / 2) for f in FEATURES: print(f'{f:22s} before={smd(treat[f], ctrl[f]):+.3f} after={smd(treat[f], matched_ctrl[f]):+.3f}')判断标准是标准化均差(SMD)的绝对值降到 0.1 以下。如果hist_search_cnt_7d从 before=0.42 只降到 after=0.28,说明这条路径的搜索意图强度根本没匹配上,要么加协变量,要么改用下面的 IPTW。
4.3 IPTW 加权与 CUPED 方差削减
PSM 扔掉了大量未匹配样本,统计功效下降;逆概率加权(IPTW)保留全部样本,用 1/ps 和 1/(1-ps) 给用户加权,把两组人群「重加权」到同一个协变量分布上:
df['w'] = np.where(df['reset'] == 1, 1 / df['ps'], 1 / (1 - df['ps'])) # 权重截断,防止 ps 接近 0 或 1 的用户把方差炸掉 df['w'] = df['w'].clip(upper=df['w'].quantile(0.99)) t = df[df['reset'] == 1] c = df[df['reset'] == 0] att_iptw = ( (t['retention'] * t['w']).sum() / t['w'].sum() - (c['retention'] * c['w']).sum() / c['w'].sum() ) print(f'IPTW ATT = {att_iptw:.4f}').quantile(0.99)这一步是必须的——未截断时权重上界可能达到几百,单个用户就能带偏整个估计。截断比例建议在 0.95~0.99 之间做敏感性对比。
如果实验本来就有 A/B 框架、只是缺一个冷启动指标,还可以用 CUPED 压低方差:拿用户干预前 7 天的留存当协变量,减掉它与目标指标的相关部分,标准误通常能降 20% 以上。
X = df['retention_pre_7d'] Y = df['retention'] theta = np.cov(Y, X)[0, 1] / np.var(X) df['retention_cuped'] = Y - theta * (X - X.mean())theta是最优系数,由协方差除以方差得到,保证调整后方差最小。CUPED 不含因果识别的成分,它只是降噪,前提是协变量必须取值于干预之前。
5. 断点回归与稳健性检验:把结论钉死在具体阈值上
启动重置里有一类问题特别适合断点回归(RD):重置行为并不是连续发生的,而是被某个阈值触发。腾讯小说业务那个案例就是典型——新用户首日阅读时长约 115 秒(大致等于读完首章所需时间),超过这个时长,产品会推送下一章;低于这个时长,用户回到首页被重置推荐。115 秒是一个天然的驱动变量阈值。
5.1 局部线性回归实现
断点回归只关心阈值附近的样本,因为只有在这里,两侧用户的其他特征才近似随机可比。
import statsmodels.formula.api as smf c = 115 # 断点 h = 60 # 带宽,只看 ±60 秒内的样本 df['center'] = df['first_day_read_sec'] - c df['treat'] = (df['center'] >= 0).astype(int) sub = df[df['center'].abs() <= h].copy() rd = smf.ols( 'first_chapter_finish ~ treat + center + treat:center', data=sub ).fit(cov_type='hc2') print(f'RD 估计 = {rd.params["treat"]:.4f}, p = {rd.pvalues["treat"]:.4f}')treat捕捉的是断点处的跳跃,也就是因果效应;center和treat:center让断点两侧各拟合一条独立的局部直线,这样即使两侧斜率不同也不会互相污染。hc2是异方差稳健标准误的小样本校正版本,样本量小时比hc1更保守。
5.2 稳健性检验清单
单个带宽下的 RD 结果不可直接采信,必须跑完下面这组检验:
| 检验项 | 目的 | 通过标准 |
|---|---|---|
| 带宽敏感性 | 换 h = 30/45/60/90/120 重估 | 系数符号一致、量级稳定 |
| 协变量连续性 | 把年龄、城市等级等当结果跑 RD | 断点处无显著跳跃 |
| 伪断点检验 | 在 90s、140s 等假断点重估 | 系数不显著 |
| 驱动变量分布连续性 | 检查 115s 附近有无堆积 | 密度平滑,无断崖 |
| 参考变量分布检验 | 检查密度函数是否连续 | McCrary 检验 p > 0.1 |
最容易翻车的是驱动变量堆积:如果产品在 115 秒附近做了强制引导,用户会精确卡在 114 秒或 116 秒,两侧样本的分布被人为切断,跳跃就不再是因果效应。画一张 5 秒分箱的直方图基本能肉眼看出来。
5.3 一个容易忽略的操作细节
断点回归对断点值本身的精度非常敏感。如果first_day_read_sec的来源埋点在客户端上做了 10 秒级向上取整,115 秒这个断点就会落在 110 或 120 上,估计出的效应会系统性错位。常见做法是先用原始毫秒级日志重建连续变量,再和产品侧的阈值配置逐条比对,确认埋点没有做本地舍入。阈值对不准,后面所有检验都白做。
具体到启动重置这个场景,还有一层技巧:把 RD 估出的局部效应当作先验,去校准 PSM/IPTW 在整个人群上的估计。两者在重叠区间内应该量级一致,如果差出一个数量级,通常说明倾向得分模型里漏了驱动变量相关的高阶交互项。
本文还有配套的精品资源,点击获取