1. 为什么“发券”这件事,九成人第一次做都亏了
我先抛一个真实场景,你看看是不是很熟悉:运营同学拉了一张表,把过去 30 天复购率低于 20% 的用户圈出来,一口气发了 50 万张满 199 减 30 的券,然后盯着一周后的核销率——12%,看起来还行,GMV 涨了几个点,复盘会上皆大欢喜。但我通常只问一句:这波人里,有多少本来就会下单?
这个问题一问出来,会议室基本就安静了。因为按照行业里被反复验证过的经验值,促销活动的增量往往集中在很小一部分人身上,剩下的大头是两类“白花钱”的人:一类是无论发不发券都会买的铁杆用户,券对他们来说纯属白送利润;另一类是无论怎么发都不会买的低意图用户,券对他们来说只是噪音,甚至养成“不打折不下单”的坏习惯。真正因为这张券而多买一次、多买一点的人,才是我们想要的增量客群,而在整体人群里,他们的占比经常只有个位数。
这里就是因果推断要进场的地方。传统营销模型做的是“预测谁会买”,本质是相关性建模——它找的是“高概率购买者”,而高概率购买者大概率本来就是活跃用户。我们要的不是这个,我们要的是Uplift 建模,也就是营销增益建模,它预测的是“因为干预而多买的那部分概率变化”。用一句大白话概括:不是预测谁最容易买,而是预测谁的购买行为会被这张券改变。
这个差异听起来只是换了个目标变量,实际落地时几乎是两套完全不同的工程逻辑、数据逻辑和评估逻辑。X-Learner 是我在这些项目里用得比较顺手的一种 Uplift 建模方法,它特别适合那种“干预组样本远少于对照组”的场景——现实营销里太常见了,一次 A/B 实验里对照组几十万人,真正被推到优惠券的只有几万人,比例悬殊得很。你如果直接上 S-Learner,干预变量的信息会被样本量碾压,模型很容易学出一个“干预几乎没影响”的结论,然后所有增益分数挤在一坨,完全没法排序。
这篇内容适合谁看:做过一些用户分层、跑过基础 A/B 实验、能从数据库里拉出用户特征和标签的分析同学,或者想从“预测购买”升级到“预测增量”的算法同学。完全没接触过因果推断也能看懂,我会把逻辑拆到能直接照做的程度;如果你已经在做 Uplift 模型但增益分数总是“分不开”,下面的评估体系和避坑技巧应该能帮你省掉几周的试错。
我会把整条链路拆成四块:方案选型背后的取舍逻辑、特征与标签构造的关键细节、X-Learner 的具体实现与参数选择、以及线上跑起来之后怎么排查问题。全过程会用模拟数据复现,代码可以直接复制运行。
2. 整体方案设计:为什么是 X-Learner 而不是更“主流”的那些
2.1 Uplift 建模的本质是一次反事实推理
先把最容易含糊的概念钉死。Uplift 建模的核心量叫 CATE,条件平均处理效应,对应到营销场景就是一个用户在接受干预(比如收到券)和不接受干预两种情况下的购买概率之差。麻烦在于,现实中同一个人不可能同时既收到券又没收到券,我们只观测到其中一种结果,这叫反事实不可观测。所有的 Uplift 方法,本质上都是在用什么姿势去近似这个看不见的差值。
按照 Meta-Learner 家族的经典分类,大致有这么几种做法:S-Learner 把干预变量当普通特征塞进一个模型,简单但容易把干预效应“稀释”掉;T-Learner 给干预组和对照组各训一个模型,然后相减,思路直观,样本不均衡时会抖得厉害;X-Learner 是 T-Learner 的加强版,多了两步纠偏,专门处理组间样本量差异大的情况;还有直接对增益建模的类方法(比如带变换的标签方法),思路更讨巧但对数据质量要求更苛刻。
我先把几种方案的差异摊开说清楚,因为选型是后面所有工作的地基:
| 方案 | 核心思路 | 优势 | 主要短板 | 适用场景 |
|---|---|---|---|---|
| S-Learner | 干预变量作为特征进单一模型 | 实现最快、样本利用率高 | 干预效应被样本多的组主导,易趋向于零 | 组间均衡、特征维度低 |
| T-Learner | 两组分别建模后相减 | 逻辑直观、易解释 | 小组样本少时方差极大 | 组间样本量接近 |
| X-Learner | 两组建模后互相插补残差再融合 | 小组样本少时更稳、可输出个体级 CATE | 流程环节多、实现稍复杂 | 干预组远小于对照组 |
| 直接增益建模 | 对变换后的标签直接回归 | 目标直接、单模型 | 强假设、标签噪声敏感 | 实验设计干净、样本充足 |
我做营销场景十有八九落在 X-Learner 这一栏。原因只有一个:样本比例。真实投放里,干预组的量永远是被预算卡死的,而对照组可以随便扩,30:1 甚至 100:1 都不稀奇。在这种失衡下,T-Learner 中干预组的那个模型基本是靠几万条数据硬扛,方差大得吓人;X-Learner 多出来的插补步骤就是专门来压这个方差的。
2.2 X-Learner 的两阶段逻辑,用一句话讲清
X-Learner 的做法可以拆成“先各自估、再交叉补、最后加权合”三步。设干预组为 T、对照组为 C。
第一步,分别在 T 组和 C 组上训练两个结果模型。记mu1(x)是干预组模型预测的购买概率,mu0(x)是对照组模型预测的购买概率。
第二步是它的精髓:交叉插补残差。对于干预组里每一个真实用户,我们观测到了他接受干预后的真实结果Y1,但我们不知道他如果不接受干预会怎样,于是用对照组的模型mu0(x)去估这个反事实值,得到一个插补出来的个体增益D1 = Y1 - mu0(x)。同理,对对照组每个用户,用干预组的模型去估他的反事实结果,得到D0 = mu1(x) - Y0。
第三步,用这两批插补出来的增益当作新标签,各自再训一个模型,然后按倾向得分(就是这个人被分到干预组的概率)加权融合成最终的 CATE 估计。
这套设计的巧妙之处在于:样本少的那一组(干预组)不再孤立地自证增益,它会借用对照组模型做参照;样本多的对照组也会借用干预组模型做参照。两边互相打光,方差自然就压下来了。我第一次看到这个结构的时候觉得有点像“互相借作业抄”,但数学上确实成立,前提是结果模型本身估得不太离谱。
2.3 一个必须提前说清的取舍
X-Learner 不是银弹。它的稳定性建立在两个假设上:一是无混淆假设,即所有同时影响干预分配和结果的因素都被观测到并进了特征;二是SUTVA,即一个人的结果不受其他人是否被干预的影响。第二个在营销里往往会被打破——优惠券在社群里被转发、同事之间互相推荐,都会造成溢出。这种情况下算出来的增益是有偏的,而且偏向哪个方向不好说。
我在项目里处理这个的办法很朴素:一是尽量按用户 ID 做社交隔离圈选,把强关联的用户尽量分到同一侧;二是把实验窗口拉短,减少传播时间。做不到的话,至少在报告里明确写上“本次增益估计可能受溢出效应影响”,别把数字说得太满。这一点很多人会跳过,但我吃过亏:某次大促期间社群里券被大规模分享,模型算出来的增益高得离谱,后来拆开看,很大一部分“增量”其实是同一批人换号重复领取。
提示:如果你的干预是公开渠道投放(信息流、开屏),溢出效应基本无法避免,这时 Uplift 分数更适合做排序参考而不是绝对增量预估。别拿它去算 ROI 的绝对值。
3. 数据与特征:Uplift 建模里最容易翻车的一段
3.1 标签和时间窗口的定义,比模型重要十倍
Uplift 建模里最容易崩的不是模型,是标签。我把这件事的顺序搞反过,代价是重跑了两周。
先说标签。这里有两列,一列是干预标识,一列是结果变量。结果变量要清楚定义“什么算买”——是下单、支付成功、还是核销券?三者差别巨大。券的核销率天然低于下单率,如果拿核销当标签,模型学出来的是“谁会核销券”,而不是“谁会被券推动下单”,这两个人群不完全重叠。我的做法是:主标签用支付成功,核销作为辅助特征,两个都保留,效果对比后再定。
时间窗口更讲究。窗口太短,很多延迟购买的人被误判成“没买”,增益被低估;窗口太长,混进来的自然复购和外部因素太多,因果归因不清。我的经验值是:快消、外卖类 7 天够用;低频高客单(家电、教育)得 14 到 30 天;订阅类要看续费周期,至少覆盖一个完整周期。
还有一个坑:干预时间和结果时间的错位。券是周一发的,用户周五才用,中间他可能还做了什么?如果他在周三自己搜了关键词进来,那这次购买到底算不算券的功劳?我在特征里会加一列“干预后是否发生主动访问”,作为归因置信度的修正项,必要时把这类样本单独切出来观察。
3.2 特征工程:把“羊毛党”和“真需求”区分开
Uplift 模型的特征和普通预测模型特征高度重合,但侧重点不同。普通模型关心“会不会买”,Uplift 模型关心“会不会被改变”,所以那些能刻画用户价格敏感度、决策惯性和历史促销响应的特征格外重要。
我一般会分四组来构造:
- 基础画像:年龄段、城市等级、注册时长、会员等级。这些是打底,别指望它们有强解释力,但少了会让模型缺少分层骨架。
- 历史行为:近 30/90 天订单数、客单价、加购未付款次数、浏览深度、最近一次下单距今天数。注意时序,必须严格取干预时点之前的数据,这条我单独强调,凡是用到未来信息的特征,整个模型结论全废。
- 促销响应史:过去 N 次促销活动中该用户是否参与、参与后的下单金额变化、近一年领券张数和核销率。这是 Uplift 模型的“灵魂特征”,因为它直接刻画了这个人对优惠的敏感程度。
- 价格相关:历史订单的平均折扣率、是否曾购买高折扣商品、购物车里是否有降价提醒。这类特征在树模型里会把“羊毛党”和“真需求用户”切开。
这里要特别提一个独家经验:给每个用户算一个“历史领券核销率”和“历史无券下单占比”的交叉特征。领券核销率高、无券下单占比也高的人,很可能是价格不敏感但爱占便宜的成熟用户;领券核销率高、无券下单占比低的人,才是典型的券驱动型。这个交叉特征在我做过的几次项目里,重要性排名几乎都在前十。
3.3 处理组与对照组的可比性检查
在跑任何模型之前,我必做一件事:倾向得分分布对比 + 关键特征的标准差对比。如果两组的特征分布差异巨大,说明分组本身就有偏,模型学出来的差异可能来自“用户本来就不同”,而不是“干预起了作用”。
检查方法很土但极其有效:把所有特征按组算均值和方差,算标准化差异,超过 0.1 的特征单独拿出来看。发现大的偏差就做倾向打分加权(IPW)或者按倾向分做分层。这一步花半天,能省掉后面反复怀疑模型的几周。
注意:很多团队直接拿运营后台的“随机分流”当随机实验,实际上分流逻辑里常有“排除高价值用户”“优先覆盖新客”之类的隐藏规则,这不是真随机。分组不可比是 Uplift 项目最常见的“假失败”原因,不是模型不行,是数据从源头就歪了。
4. X-Learner 实操:从模拟数据到可解释的增益分数
4.1 用模拟数据把链路跑通
先用numpy和sklearn构造一个带真实增益的数据集,方便验证模型是不是真的把增益学出来了。这里我故意把干预组做小,模拟真实投放的失衡结构。
import numpy as np import pandas as pd from sklearn.ensemble import GradientBoostingClassifier from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split rng = np.random.default_rng(42) n = 120000 # 5 个基础特征 X = rng.normal(size=(n, 5)) X[:, 0] = X[:, 0] * 0.6 + 2.0 # 用户活跃度 X[:, 1] = np.abs(X[:, 1]) * 1.5 # 价格敏感度 # 倾向得分:干预组天然少于对照组 linear = 0.9 * X[:, 0] - 0.7 * X[:, 1] - 1.8 pscore = 1 / (1 + np.exp(-linear)) T = rng.binomial(1, pscore * 0.35) # 整体干预比例压到 ~12% # 真实增益:只对部分人有效,且随特征变化 tau = np.where((X[:, 0] > 2.0) & (X[:, 1] < 1.2), 0.18, 0.02) tau += 0.04 * (X[:, 2] > 0.5) # 基线购买概率 + 干预效应 base = 1 / (1 + np.exp(-(0.8 * X[:, 0] - 0.5 * X[:, 1] + 0.3 * X[:, 2] - 1.2))) p = np.clip(base + T * tau, 0.01, 0.99) Y = rng.binomial(1, p) df = pd.DataFrame(X, columns=[f"f{i}" for i in range(5)]) df["treatment"] = T df["label"] = Y print(df["treatment"].mean(), df["label"].mean())跑出来可以看到干预组占比在 12% 左右,这个比例故意做得比真实场景温和一点,方便调试。真实项目里我会在数据构造之后立刻检查增益的真实分布:这里tau集中在 0.02 和 0.18 两档,中间的过渡人群很少,这正好可以用来检验模型是否能把人群切成清晰的几段。如果模型输出的增益分数是一坨高斯分布,说明它没学到结构。
4.2 基线模型与 X-Learner 的完整实现
为了方便你抄作业,我把 X-Learner 的核心步骤写成一个独立函数,不依赖第三方因果库,纯用sklearn拼出来,逻辑透明,出问题好改。
def x_learner(df, feats, treat_col="treatment", label_col="label"): d_t = df[df[treat_col] == 1] d_c = df[df[treat_col] == 0] # 阶段一:两组的结局模型 m1 = GradientBoostingClassifier( n_estimators=200, learning_rate=0.05, max_depth=3, subsample=0.8, random_state=42) m0 = GradientBoostingClassifier( n_estimators=200, learning_rate=0.05, max_depth=3, subsample=0.8, random_state=42) m1.fit(d_t[feats], d_t[label_col]) m0.fit(d_c[feats], d_c[label_col]) # 阶段二:交叉插补个体增益 d1 = d_t[label_col].values - m0.predict_proba(d_t[feats])[:, 1] d0 = m1.predict_proba(d_c[feats])[:, 1] - d_c[label_col].values # 若干预组较小,两段加权前的两个增益模型 tau1_model = GradientBoostingClassifier( n_estimators=120, learning_rate=0.05, max_depth=2, random_state=42) tau0_model = GradientBoostingClassifier( n_estimators=120, learning_rate=0.05, max_depth=2, random_state=42) tau1_model.fit(d_t[feats], (d1 > 0).astype(int)) tau0_model.fit(d_c[feats], (d0 > 0).astype(int)) # 倾向得分模型,用于加权融合 ps = LogisticRegression(max_iter=500) ps.fit(df[feats], df[treat_col]) g = ps.predict_proba(df[feats])[:, 1] def predict_uplift(X_new): p1 = tau1_model.predict_proba(X_new)[:, 1] p0 = tau0_model.predict_proba(X_new)[:, 1] ps_new = ps.predict_proba(X_new)[:, 1] return ps_new * p1 + (1 - ps_new) * p0 return predict_uplift, (m1, m0, tau1_model, tau0_model, ps)有一点必须说明,这也是我踩过的坑:交叉插补得到的d1、d0是连续值,不是 0/1 标签。上面代码为了用分类器,把连续增益转成了“增益是否为正”的二分类问题,这是我在实战里的简化处理,好处是抗噪、输出稳定,坏处是失去了增益的幅度信息。如果你需要精确的增量金额预估,就把第二阶段的模型换成回归器(如GradientBoostingRegressor),直接拟合d1、d0。我在做“发多少面额”这类决策时会用回归版,做“发给谁”这类排序决策时用分类版,前者重幅度,后者重排序,别搞混。
4.3 参数选择:不是照着别人的配置抄
X-Learner 的几个关键参数,我按重要性排一下。
第一阶段结果模型的深度。这里我用max_depth=3,理由是结果模型的职责是估准“基础购买概率”,它不需要捕捉太复杂的高阶交互,深了容易在小组样本上过拟合。我在一个 3 万干预组样本的项目里试过max_depth=6,叠加n_estimators=400,验证集上增益曲线的分桶单调性明显变差,典型过拟合表现。
第二阶段增益模型的深度。我用max_depth=2,更浅。原因是增益模型面对的是插补出来的噪声标签,容噪能力比拟合能力重要。这一条我摔得最狠:最开始按常规配置用深度 4,线上跑出来 Top10% 人群的增益排序几乎随机,把深度降到 2、树数降到 120 之后才稳定。
倾向得分模型的复杂度。这个我倾向用逻辑回归而不是树模型。原因很实际:倾向分数的用途是加权,加权需要的是校准好的概率,而树模型的概率输出在极端区域常常不够平滑,加权时会把少数样本的权重放大到失真。逻辑回归加正则,输出平滑,是更稳的选择。
采样策略。干预组样本少时,我不做简单过采样,而是用class_weight或者在数据构造阶段按倾向分分层重采样。SMOTE 这类方法我也试过,在 Uplift 场景里效果不稳定,因为它会在特征空间里插值造样本,但增益是因果量,两个真实样本中间插一个合成样本,它的增益没有物理意义。
| 参数 | 我的常用值 | 调大后的表现 | 调小后的表现 |
|---|---|---|---|
| 结果模型 max_depth | 3 | 小组过拟合、排序抖动 | 欠拟合、增益被压平 |
| 结果模型 n_estimators | 200 | 收益递减、训练变慢 | 欠拟合 |
| 增益模型 max_depth | 2 | 噪声放大、排序失稳 | 欠拟合、增益无区分度 |
| 增益模型 n_estimators | 120 | 边际收益低 | 明显欠拟合 |
| 学习率 | 0.05 | 需同步减少树数 | 收敛不足 |
4.4 怎么验证它真的学到了“增益”而不是“概率”
这一步是分出内行和外行的分水岭。普通模型看 AUC 就行,Uplift 模型看 AUC 会骗你——一个把“购买概率”学得很准的模型,AUC 可以很高,但它的增益排序可能完全是乱的。
我用三类校验,缺一不可:
第一类,增益分桶校验。把人群按预测增益从高到低切 10 桶,算每桶的真实增益(用各桶内干预组和对照组的真实购买率之差来估)。理想情况下这条线是单调下降的。这条线如果不单调,先别急着调模型,回头查分组可比性。
第二类,Qini 曲线与 Qini 系数。Qini 是 Uplift 建模的标准评估曲线,横轴是按增益排序后覆盖的人群比例,纵轴是累计增量。和随机基线之间的面积就是 Qini 系数。它比 AUC 更贴合“我要按排序投放”的实际目标。
第三类,Top 分位的人群画像回看。纯数据之外,我一定会把 Top 10% 的人群特征拉出来看,问一句:这些人讲不讲得通?如果 Top 人群全是“注册 7 天内、零订单、零浏览”的僵尸号,那模型大概率在拟合噪声。我在一次项目里就发现 Top 人群全是“加购了但从未下单”的用户,逻辑上非常合理——他们会因为券而下定决心,于是把这批人单独拿出来做了定向投放,效果比大盘好一大截。
下面给出 Qini 的计算代码,直接可用:
def qini_curve(y_true, t, uplift_score, n_bins=100): order = np.argsort(-uplift_score) y_sorted, t_sorted = y_true[order], t[order] n = len(y_true) frac = np.arange(1, n + 1) / n # 累计干预组转化 / 对照组转化,按比例缩放 cum_t_y = np.cumsum(y_sorted * t_sorted) cum_c_y = np.cumsum(y_sorted * (1 - t_sorted)) n_t = np.cumsum(t_sorted) n_c = np.cumsum(1 - t_sorted) # 避免除零 n_c = np.where(n_c == 0, 1, n_c) n_t = np.where(n_t == 0, 1, n_t) treated_rate = cum_t_y / n_t control_rate = cum_c_y / n_c qini = (treated_rate - control_rate) * frac return frac, qini用这段代码画图,横轴是人群比例,纵轴是累计增量,观察它和随机线(一条斜线)的分离度。分离得越开、到达峰值的比例越靠前,说明模型的投放效率越高。
5. 上线之后:分数怎么用,以及问题怎么查
5.1 分数落地:分档策略比模型精度更有商业价值
模型跑完,最忌讳的就是拿一个连续分数直接扔给运营。我给运营的东西一定是分档的,而且每档配了明确的动作。
典型的分档逻辑是这样:按预测增益的百分位切成四档,高增益且高基线概率的档,给小额券(比如满 99 减 10),因为这些人本来就大概率会买,我们只需要一点点推动力;高增益但低基线概率的档,给大额券,这批人需要用强刺激撬动;低增益高基线的档,不给券,省下来的预算就是净赚,这是 Uplift 最容易被低估的价值——它帮你省的钱,往往比帮你赚的钱更多;低增益低基线的档,不进这次投放,留着做长期培育。
这个分档表我会直接做成运营后台可查的标签,每周刷新。有意思的是,第一次给运营看这个表的时候,他们的反应通常是“怎么这么多人被标成不用发券”,等看到试点结果——同等预算下增量提升了三成多——态度就变了。
| 增益档 | 基线概率档 | 券策略 | 预期增量 | 备注 |
|---|---|---|---|---|
| 高 | 高 | 小额券 | 中 | 省预算,守住转化 |
| 高 | 低 | 大额券 | 高 | 核心撬动对象 |
| 低 | 高 | 不发 | 极低 | 白花钱区,坚决砍掉 |
| 低 | 低 | 不发/培育 | 低 | 长期运营,不做短促 |
5.2 问题排查速查表
上线之后一定会遇到各种怪现象,我把高频的整理成一张表,方便你对照。
| 现象 | 最可能的原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 增益分数几乎全集中在 0 附近 | 特征里混入强相关变量,把干预效应盖住 | 检查是否有“是否领券”类特征被当作特征而非标签 | 剔除后验特征 |
| 增益分桶曲线不单调 | 分组不可比或样本太少 | 检查倾向分分布、各组样本量 | IPW 加权或扩样本 |
| 线上效果远低于离线评估 | 溢出效应或时间窗口定义错 | 检查是否有社群传播、窗口是否过短 | 缩短窗口、做社交隔离 |
| Top 人群画像不合理 | 模型拟合噪声或标签泄漏 | 抽查 Top 样本原始数据 | 清理特征、修正标签 |
| 换一周数据模型重跑,排序大改 | 干预组样本太少,模型不稳定 | 看每档人群规模 | 补实验样本或做平滑 |
| 增益为正但实际 GMV 没涨 | 增量来自低毛利商品或券核销挤占 | 拆分商品结构和毛利 | 把利润而不是 GMV 作为优化目标 |
我特别想强调最后一行。增益建模的目标如果设成“购买概率”,模型会去找最容易转化的那批人;如果设成“金额”,它会导向高客单人群;如果设成“毛利”,结论又会变。优化目标的选择,本身就是一个业务决策,不要让算法同学默认用“购买概率”糊过去。我见过一个项目,模型很漂亮,增量也确实存在,但增量全集中在低毛利品类,扣掉券成本之后是亏的。这不是模型的问题,是目标函数定错了。
5.3 几个我认为最重要的实操心得
第一,先跑通 T-Learner 再去碰 X-Learner。别嫌 T-Learner 粗糙,它的分桶曲线和 Qini 是 X-Learner 的天然对照,两组结果放一起比对,你才能确认多出来的那两步插补到底带来了多少实际收益。如果 T-Learner 和 X-Learner 结论差异巨大,而你解释不了原因,那多半是哪里写错了。
第二,留一组“老办法选出来的人”做对照。这是最难说服团队但最值得做的一步。拿历史规则圈人作为基线策略,新模型圈人作为实验策略,同一批预算同时投放,直接比增量。这个设计能回答“模型到底有没有用”这个终极问题,比任何离线指标都硬。
第三,别把增益分数当成真值。它是估计量,有方差。我在分档时会在边界附近做软处理,把相邻两档的分数用相近动作,避免因为模型抖动导致同一类用户这周发券下周不发,体验上很糟糕。稳定性和精度之间,营销场景里我更偏稳定。
第四,定期重训,但别频繁换模型结构。人群的价格敏感度会随大促节奏变化,重训必要;但每次重训都改结构,你永远不知道效果变化来自数据还是结构。我的节奏是数据每月更新、结构半年评估一次。
关于这条链路后续还能怎么扩展,我自己在试的方向是把 Uplift 和券面额联合优化——不只是“发给谁”,而是“给谁发多少”。思路是把面额也当成一个多值干预,用多臂的 Uplift 方法做,这样就从一个二分类问题变成了一个多处理问题,复杂度上了一个台阶,但在预算固定的场景里,收益确实比单纯选人更明显。这个我还在摸索,有稳定结论再单独整理。