先讲一个我亲身经历过的场景。某电商团队做满199减30的券运营拿着一份高转化概率人群名单用梯度提升树预测了每个用户的购买概率取了分数最高的一万人发券。上线一周GMV 确实涨了但财务一算毛利整体是亏的。原因不复杂:那一万人里有七成是本来就打算下单的老客券只是把他们原本要付的钱打了个折。这件事的核心问题不在模型精度而在目标定义——他们预测的是谁会买业务真正需要的是谁因为这张券才买。这就是因果推断与 Uplift 营销增益建模要解决的问题也是 X-Learner 这类增量模型存在的理由。这篇内容会从业务目标翻译、模型选型、手写实现、效果验证到最终发券策略完整走一遍适合已经会用常规机器学习模型、但发现预测准不等于赚钱的算法同学和增长运营同学参考。1. 从谁会买到优惠券带来多少购买:响应模型的天花板1.1 一个让发券预算打水漂的常规操作大多数团队做营销模型的第一步都是把历史订单拉出来构造一个是否购买的标签然后塞进 LightGBM 或者 XGBoost训练一个二分类器。这套流程技术上完全没问题AUC 做到 0.8 以上也不难。但它学到的本质是 P(Y1|X)也就是在具备这些特征的人里购买这件事发生的频率有多高。这个量里混了两部分人群:一部分是被券推着走的人另一部分是无论如何都会买的人。模型没有能力区分它们因为从数据里看这两类人的标签都是 1。后果是很直接的。高转化概率的人群往往就是高消费力、高复购频次、近期活跃的那批人。给他们发券转化率当然漂亮但增量接近零甚至是负的——券面额直接吃掉了原本的毛利。我见过一个做母婴品类的团队用响应模型跑了三个月报表上的核销率一直在 40% 以上看起来很健康直到他们做了一次留出对照组的实验才发现真实的增量只有 6 个百分点左右剩下 34 个百分点全是自然转化。1.2 把人群按增量重新切一刀Uplift 建模的第一个价值是换了一套人群划分的坐标系。原来的坐标轴是购买概率高低现在换成发券与不发券之间的购买概率差。这一换人群就分成了四类业内一般叫四象限:人群类型不发券时转化发券后转化业务处置敏感型persuadable低高重点投放增量主要来源自然转化sure thing高高不发或发小额避免利润让渡无动于衷lost cause低低不发省成本反效果sleeping dog高低坚决不发券会起反作用前两类是响应模型唯一能看见的后两类它完全无能为力。特别是 sleeping dog 这一类虽然占比通常不大但危害很实在。典型例子是那些对价格不敏感、更看重品质感的高客单用户一张大额折扣券反而会让他们怀疑商品是不是要清仓了或者觉得自己的购买决策被廉价化了。这类反效果在美妆、轻奢、高端母婴这些品类里不算罕见我在几个项目里都观察到过虽然幅度不大通常 -1 到 -3 个百分点但人群一旦被精准命中损失是放大的。1.3 反事实不可观测:这件事到底难在哪Uplift 建模之所以比普通预测难根子在一个物理限制上:对同一个用户你不可能同时观测到他收到券和没收到券两种状态下的结果。因果推断里把这个叫潜在结果potential outcomes记为 Y(1) 和 Y(0)个体层面的增量 τ_i Y_i(1) - Y_i(0) 永远是不可观测的。所以整件事只能退一步做:不去估个体增量而是估在给定特征 Xx 的条件下的平均增量也就是 CATEConditional Average Treatment Effect。这个量是可以从数据里估出来的前提是数据本身没被污染。而现实里的数据往往是被污染的——运营在发券时本来就有偏好比如优先发给高价值用户、优先发给即将流失的用户这种选择性发放会把处理组和对照组的人群结构彻底打乱直接拿去训练模型学到的其实是谁被选中发券而不是券产生了什么效果。这里有一条硬规则:如果发券策略本身是人为决策的那这批数据只能用来做相关性分析不能直接用来估因果效应。要估增量最干净的路子是做随机分流实验。2. 把业务问题翻译成因果量:目标定义与数据底座2.1 CATE 的形式化表达与它的业务含义把上面那段话写成公式就是 τ(x) E[Y(1) - Y(0) | X x]。注意这里期望是对同一个特征取值下的所有个体求的所以它回答的问题是:对于画像长成这样的人发券平均能带来多少额外的转化概率。和它对应的是 ATE E[Y(1) - Y(0)]也就是全体人群的平均增量。ATE 回答的是这次活动整体值不值适合做活动复盘CATE 回答的是该发给谁适合做投放决策。做营销落地必须拿到 CATE 层面的估计因为预算永远是有限的你要做的是在人群之间做排序和取舍。这里还有一个容易被忽略的细节:业务上的转化未必是二值的。有些团队用 GMV 做目标那就是连续变量下的 CATE有些团队用下单且未退款做目标那就需要在标签构造阶段就把退款窗口考虑进去。目标定义错了后面模型再精细也是白搭。我的经验是先把这次发券到底想优化什么写成一句话——是拉新人数、是订单量、还是毛利额——然后严格按这句话去构造 Y不要中途改口径。2.2 实验数据与观测数据:两条路的成本差距数据来源基本就两种取舍逻辑很清楚:维度随机分流实验历史观测数据因果可信度高混淆因素被随机化抹平低需要额外去偏数据获取成本需要留出对照组短期有损失零成本历史数据直接可用样本结构处理组比例可控常见 10%~50%处理组比例由历史策略决定常严重倾斜适用阶段模型冷启动、效果校准日常迭代、特征扩展实际项目里最常见的做法是实验打底、观测补充:先用一次覆盖几十万用户的随机分流实验拿到一份干净的训练集把模型跑通、把 Qini 曲线画出来后续再考虑用观测数据扩样本但扩样本时必须配合倾向得分加权或者双重稳健估计这类去偏手段否则前功尽弃。2.3 宽表怎么拼:字段设计与时间窗口一份能用来训 Uplift 模型的宽表最少要有四类字段:用户标识:user_id用于去重和后续关联处理标记:treatment1 表示发券0 表示未发券。如果是多种面额可以先做多值处理或者退化成发/不发二值问题先跑通结果标签:conversion观察窗口内的转化标记协变量:下单前可获得的全部特征包括历史购买频次、近30天活跃天数、客单价、品类偏好、会员等级、领券历史敏感度等字段本身不复杂坑在时间窗口上。观察窗口太短会漏掉那些看到券想了三天才下单的慢决策用户增量被系统性低估窗口太长又会混进自然复购和季节性因素。快消、生鲜这类决策周期短的品类7 天窗口基本够用家电、家居这种长决策品类往往要拉到 30 天甚至更久。我一般会同时跑 7 天和 30 天两个版本对比看 Qini 曲线的形状是否稳定如果两个版本排序差异很大说明窗口选择对结论影响太重得再想清楚。2.4 样本量估算:别等模型跑完才发现不够这一步被跳过的频率高得惊人。很多团队花两周搭好模型最后发现置信区间宽得没法用。粗略的估算可以用这个公式:n_per_group ≈ (z_{α/2} z_β)² × [p₁(1-p₁) p₂(1-p₂)] / (p₂ - p₁)²举个具体数:基线转化率 p₁ 2%希望检测到相对提升 10%即 p₂ 2.2%取 α0.05、β0.2则 (1.960.84)² ≈ 7.84分子是 7.84 × (0.02×0.98 0.022×0.978) ≈ 0.322分母是 (0.002)² 4×10⁻⁶算下来每组约 8 万人。但这只是能测出 ATE的样本量。Uplift 模型估的是异质性效应需要把人群切成若干细分后每一块都还有足够样本实际需求通常要再放大 3 到 5 倍。所以看到每组 8 万够了就收手往往会做出一个在所有分桶上都看不出显著差异的模型。我的建议是如果条件允许实验组和对照组各准备 30 万以上处理组比例保持在 30% 左右这个配置在小样本侧的偏差和大样本侧的成本之间比较平衡。3. S-Learner、T-Learner、X-Learner:三种套路的分水岭3.1 T-Learner:两个模型各管一摊简单但方差大T-Learner 的思路最直白:在处理组样本上训一个模型 μ₁(x) 预测 Y(1)在对照组样本上训一个模型 μ₀(x) 预测 Y(0)然后 τ(x) μ₁(x) - μ₀(x)。它的优点是灵活性拉满两个模型可以用不同的特征子集、不同的超参数甚至不同的算法。缺点是误差会叠加——两个模型各自的预测误差在相减时不会抵消反而可能放大。更麻烦的是营销场景里处理组往往只占 10%~20%对照组占大头这意味着 μ₁ 是在很少的样本上训出来的方差天然就大。你拿一个高方差的 μ₁ 去减一个低方差的 μ₀结果的波动主要来自前者。3.2 S-Learner:一个模型带个哑变量特征是会被稀释的S-Learner 反过来把所有样本混在一起训一个模型把 treatment 当成一个普通特征塞进去预测时对同一个 x 分别取 treatment1 和 0两次预测值相减得到 τ(x)。这个做法在样本少的时候很稳但它有个内伤:模型并不知道 treatment 才是关键变量。树模型在分裂节点时会优先选那些增益大的特征如果历史购买频次、近30天活跃度这些特征本身就极强treatment 这个二值变量很可能在几十轮分裂里一次都没被选中于是模型算出来的 τ(x) 处处接近于零。我调过好几个这样的模型Qini 曲线和随机线几乎重合一开始还以为是数据没信号后来把特征维度砍到只剩五六个treatment 才开始被分裂到。这个现象在特征维度高、样本量中等的时候特别明显。3.3 X-Learner 的动机:专治处理组样本少X-Learner 的设计动机非常贴合营销场景。回想一下前面的样本量估算:实验组 30 万、对照组 70 万这种不平衡是常态而不是例外。X-Learner 的核心思想是用大样本那侧的信息去补小样本那侧的缺口通过交叉插补的方式让处理组和对照组各自都能得到一个覆盖全样本的效应估计。方法处理组样本少时表现实现复杂度适用场景S-Learner较稳但效应易被稀释低特征少、处理效应弱T-Learner方差大排序不稳定低两组样本量接近X-Learner较稳专门为此设计中处理组显著少于对照组3.4 X-Learner 的三步拆解与权重逻辑X-Learner 分三步走每一步都有明确目的:第一步训两个结果模型。μ₁(x) 用处理组样本训μ₀(x) 用对照组样本训。注意这里是结果模型预测的是 Y不是增量。第二步交叉插补反事实结果。对处理组里的每个样本 i它的 Y_i(1) 是真实观测到的Y_i(0) 未知那就用 μ₀(x_i) 去插补得到增量估计 D_i¹ Y_i - μ₀(x_i)。对对照组里的每个样本 jY_j(0) 是观测到的Y_j(1) 用 μ₁(x_j) 插补得到 D_j⁰ μ₁(x_j) - Y_j。注意符号方向两个都是处理减对照。这一步是 X-Learner 的精髓:它把估增量这个难题转化成了在已有观测结果的样本上用一个学到的模型去补另一半的问题从而让每个样本都能贡献一条增量样本。第三步训两个增量模型并加权融合。用 (X₁, D¹) 训一个 τ₁(x)用 (X₀, D⁰) 训一个 τ₀(x)。最终的 τ(x) g(x)·τ₀(x) (1-g(x))·τ₁(x)其中 g(x) 是倾向得分也就是该样本被分到处理组的概率。权重为什么这么设计当 g(x) 很大这类用户大概率会被发券说明处理组里这类样本多μ₁ 学得准进而 D⁰ 插补得准τ₀ 更可信所以给 τ₀ 更高权重。反过来当 g(x) 很小τ₁ 更可信。这个加权本质上是按哪一侧的样本更充足来分配信任度逻辑非常自洽。4. 手写一个 X-Learner:代码骨架与三个关键实现点4.1 先造一份带真值的合成数据验证因果模型最靠谱的方式是先造一份知道标准答案的数据。下面这个生成器里真实增量 τ(x) 是显式写出来的这样才能在最后拿来比对:import numpy as np import pandas as pd from sklearn.ensemble import GradientBoostingRegressor, GradientBoostingClassifier from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split def make_uplift_data(n200000, seed42): rng np.random.default_rng(seed) X rng.normal(size(n, 5)) # 倾向得分让高活跃用户更容易被发券模拟真实的选择偏差 logit 0.8 * X[:, 0] - 0.5 * X[:, 1] 0.3 * X[:, 2] p_treat 1 / (1 np.exp(-logit)) treat rng.binomial(1, p_treat) # 基线购买意愿 base 0.05 0.03 * X[:, 1] 0.02 * X[:, 3] # 真实增量只有 X[:,0] 高的用户对券敏感X[:,4] 高的是反效果人群 tau 0.06 * (X[:, 0] 0.5).astype(float) - 0.04 * (X[:, 4] 1.0).astype(float) prob np.clip(base treat * tau, 0.001, 0.999) y rng.binomial(1, prob) cols [fx{i} for i in range(5)] df pd.DataFrame(X, columnscols) df[treatment] treat df[conversion] y df[true_tau] tau return df这段数据的构造有几个刻意的设计:倾向得分依赖特征所以处理组和对照组的人群结构天然不同这模拟了真实业务里的选择性发放增量只由 x0 和 x4 决定且 x4 上是负效应用来测试模型能不能把 sleeping dog 找出来。4.2 第一步与第二步:训练与交叉插补class XLearnerManual: def __init__(self, base_modelNone): self.base_model base_model or GradientBoostingRegressor( n_estimators200, max_depth3, learning_rate0.05, subsample0.8, random_state42 ) def fit(self, X, t, y): X np.asarray(X, dtypefloat) t np.asarray(t).ravel() y np.asarray(y).ravel() m1 X[t 1] y1 y[t 1] m0 X[t 0] y0 y[t 0] # Step 1: 两个结果模型 self.mu1 clone_model(self.base_model).fit(m1, y1) self.mu0 clone_model(self.base_model).fit(m0, y0) # Step 2: 交叉插补 d1 y1 - self.mu0.predict(m1) # 处理组样本上的增量估计 d0 self.mu1.predict(m0) - y0 # 对照组样本上的增量估计 # Step 3: 两个增量模型 self.tau1 clone_model(self.base_model).fit(m1, d1) self.tau0 clone_model(self.base_model).fit(m0, d0) # 倾向得分模型 self.ps LogisticRegression(max_iter1000).fit(X, t) return self def predict(self, X): X np.asarray(X, dtypefloat) g self.ps.predict_proba(X)[:, 1] return g * self.tau0.predict(X) (1 - g) * self.tau1.predict(X)clone_model就是一个简单的 sklearnclone包装保证三个基模型超参一致但不共享状态。这段代码不到 30 行但每个细节都有讲究。第一基模型用的是GradientBoostingRegressor而不是分类器。因为第二步插补出来的 D 是连续值可能为负必须用回归器来接。有些人会直接把二分类的 Y 塞进回归器这也可以但如果正样本比例只有 2%回归器容易塌缩到接近 0此时可以考虑做样本加权或者对 D 做截断。第二max_depth3这个参数不是随手写的。增量模型要估的是异质性效应如果树太深模型会把噪声当成信号拟合进去插补出的 D 本身就带噪再深挖就是垃圾进垃圾出。我一般会把增量模型的树深控制得比结果模型更浅。第三也是我认为最容易出问题的地方:mu0是在对照组上训的而对照组样本量大模型会比较稳mu1是在处理组上训的样本少容易过拟合。如果mu1过拟合d0 mu1.predict(m0) - y0这一项就会失真而d0恰恰是训练tau0的全部数据。所以我在处理组样本特别小的时候会给mu1加更强的正则比如把n_estimators调小或者用更浅的树宁可偏差大一点也别方差炸掉。4.3 第三步的权重:倾向得分模型的选择权重函数 g(x) 用的是逻辑回归拟合出来的倾向得分。这里不建议用强模型比如深度森林或者很深的 GBDT原因有两个:一是倾向得分的目的是平衡不是预测准过拟合的倾向模型会让权重极端化把某一侧的 τ 完全压掉二是逻辑回归的系数可解释万一模型出问题你能快速看出是哪个特征导致权重异常。如果处理组比例严重不均衡比如只有 5%可以在逻辑回归里加上class_weightbalanced让正负样本在损失里权重对等学出来的 g(x) 分布会更平缓。4.4 跑一遍看结果df make_uplift_data(n200000) feat [c for c in df.columns if c.startswith(x)] X_tr, X_te, t_tr, t_te, y_tr, y_te, tau_tr, tau_te train_test_split( df[feat].values, df[treatment].values, df[conversion].values, df[true_tau].values, test_size0.3, random_state7 ) xl XLearnerManual().fit(X_tr, t_tr, y_tr) pred_tau xl.predict(X_te) from scipy.stats import spearmanr print(Spearman 相关:, spearmanr(pred_tau, tau_te).correlation) print(预测均值:, pred_tau.mean(), 真实均值:, tau_te.mean())在合成数据上Spearman 相关系数一般能到 0.5 以上这个数值看起来不高但对增量模型来说已经算不错——因为个体层面的 τ 本来就受大量随机性影响排序相关性比绝对值更重要。如果这个数只有 0.1 上下基本可以判断模型没学到东西先去检查处理组样本量、特征是否泄漏、标签口径是否正确。5. 模型准不准:反事实看不见那就换个评法5.1 Qini 曲线:把人群按增量从高到低排Qini 曲线是 Uplift 场景下最常用的评估工具思路和 ROC 曲线有点像但纵轴换成了累计增量。具体做法是:把测试集按预测的 τ 从高到低排序取前 k 个人计算这 k 个人里的增量总和:累计增量(k) Σ_{i1}^{k} [ Y_i·t_i/e(x_i) - Y_i·(1-t_i)/(1-e(x_i)) ]这个公式是逆概率加权IPW版本e(x_i) 就是倾向得分。用 IPW 而不是直接求差是因为测试集里每个人的处理组和对照组样本量不一定成比例直接求均值会有偏。def qini_curve(y_true, t_true, uplift, ps): order np.argsort(-uplift) y y_true[order]; t t_true[order]; e ps[order] treated np.where(t 1, y / e, 0) control np.where(t 0, y / (1 - e), 0) cum np.cumsum(treated - control) x np.arange(1, len(y) 1) return x, cum def qini_coefficient(x, cum): # 曲线下面积减去随机线面积再归一化 area np.trapz(cum, x) rand np.trapz(cum[-1] * x / x[-1], x) return (area - rand) / x[-1]Qini 曲线有几个判断要点:曲线要明显高于对角线随机投放线曲线在某个位置会掉头向下那个拐点就是发券发到头了的位置如果曲线一开始就是平的说明模型在前 10% 人群里没找出敏感用户。5.2 常见的误判:拿 AUC 评价 Uplift 模型这是我见过最多的踩坑方式。有人训完 Uplift 模型顺手算了个 AUC0.85觉得很不错。但 AUC 衡量的是对 Y 的判别能力一个把 sure thing 全部排在前面的模型AUC 会非常漂亮可它的增量几乎为零。道理很简单:AUC 高只说明模型分得清会不会买分不清因为什么买。这两个问题在数学上是两回事。所以 Uplift 模型的评估指标必须用 Qini、AUUC 或者分桶后的增量单调性绝不能用 AUC 或者 KS。5.3 用合成数据做真值校验除了 Qini我习惯在合成数据上再做一层校验:把测试集按真实 τ 分成十桶看每一桶的预测 τ 均值是否呈单调下降以及是否和真实 τ 均值同号。这个检查能抓出很多 Qini 看不出来的问题比如模型整体偏高或偏低系统性偏差或者在某一段人群上符号反了。桶号按真实τ真实τ均值预测τ均值判断1最高0.0580.041偏低但方向正确5中间0.0020.006接近零合理10最低-0.037-0.018方向正确幅度被压缩幅度被压缩是回归类增量模型的通病因为损失函数对极端值不敏感。实践上不影响排序只要方向对做分位数分桶投放就没问题。但如果符号反了那就是严重问题必须回头查数据。6. 从分数到发券:策略层的最后一公里6.1 分位数分桶:把连续分数变成可执行的人群包模型输出的 τ 是个连续值运营没法直接用它做决策。标准做法是按预测分数分十分位每桶约占总用户的 10%然后对每桶算实际的增量:分位预测τ区间人数占比实测增量券成本/人净收益建议1~2 0.03520%0.04183.2全量发3~50.008~0.03530%0.0138-2.1改小额券6~8-0.005~0.00830%0.0018-7.6不发9~10 -0.00520%-0.0128-11.2坚决不发这张表是策略落地的核心产出。它的价值在于把该不该发变成了一个可以算账的问题:净收益 增量转化率 × 客单价 × 毛利率 - 券面额 - 触达成本。假设客单价 120、毛利率 30%那么一单位增量转化的毛利约 36 元一张 8 元的券需要至少带来 0.22 的增量转化率才回本。用这个临界值去卡分桶表第 3~5 桶就已经不划算了应该换成 3 元的小额券或者满减力度更低的组合。6.2 sleeping dog 的处理:别只是不发对预测为负增量的人群最直接的做法是不发。但更稳妥的是先做一次小流量验证因为模型预测负值可能是噪声。做法是从负值人群里随机抽 5%一半照常发券一半不发跑两周看差异是否显著。如果确认是负效应那这批人以后就进永久免打扰名单如果不显著说明只是噪声归到不发桶就好没必要过度反应。这一步很多团队会跳过理由是负效应人群占比小不值得花实验资源。但如果你的品类是高客单、高决策门槛的负效应人群占比可能到 8%~12%这部分损失反而是可以被量化并避免的。6.3 上线后的衰减与再训练节奏模型上线不代表结束。发券策略本身会改变用户行为进而改变训练数据的分布。比如你把敏感人群全量覆盖了下一轮实验里这批人的增量就会下降——不是因为模型变差而是因为敏感的人都已经被用光了。我的经验是:Uplift 模型的半衰期比响应模型短得多通常 4 到 8 周就要重新跑一次实验、重训一次。同时要保留 5% 的永久对照组一直不发券这条基线是所有增量计算的锚丢了它后面所有的增量数字都是空中楼阁。7. 我在实操中踩过的几个坑7.1 特征里混进了发券之后才有的信息这是最致命也最隐蔽的问题。比如把是否点击券、是否领券、券后浏览时长这些字段放进特征模型在训练集上表现会好得离谱Qini 系数高到 0.3 以上。但上线后完全失效因为这些字段在发券之前根本拿不到。判断方法很朴素:把每个特征问一遍这个值在决策发生的那一刻也就是决定发不发券之前能拿到吗。拿不到的一律剔除。我在一个项目里就因为混进了近7天优惠券曝光次数这个字段导致模型离线评估虚高线上跑了一周才发现问题。7.2 处理组和对照组的曝光不对等对照组不是什么都不做而是看不到这张券。如果实验设计里对照组用户仍然能看到其他渠道的同类优惠那两组之间的差异就被稀释了估出来的增量会系统性偏低。这个坑在多渠道运营的公司里特别常见需要在上线前和其他渠道的负责人对齐排期。7.3 实验人群和生产人群不一致实验往往只在一部分城市或一部分用户里跑但模型上线时会推广到全量。如果实验人群的画像和生产人群差异大模型的排序关系就可能失效。检查方法是对比两组人群在关键特征上的分布如果 KS 统计量超过 0.2就要谨慎。7.4 多面额场景下的二值化损失现实里券有很多种面额直接压成发/不发会丢失信息。一个折中的做法是先按面额分组建模或者用多值处理的方法比如把不同面额当作不同 treatment 分别和对照组比。我在一个项目里试过直接把面额当连续特征塞进模型效果很差因为模型无法理解面额是一个处理变量而不是协变量这条弯路建议直接绕开。最后分享一个我在实际项目里学到的小技巧:先把所有特征砍到只剩 5 到 8 个最有业务解释力的用这份精简特征跑通整条链路、把 Qini 曲线和分桶表拿到手然后再逐步加特征。这种做法看似慢但能让你在每一步都清楚地知道是哪个特征带来了提升而不是最后面对一个 200 维的黑盒模型连问题出在哪都定位不了。
阅读完成 · 觉得有帮助?