用生存分析搭建补贴时机模型
问题定义:补贴什么时候触发?
在许多互联网产品中,动态补贴策略都需要回答两个核心问题:补多少(补贴率)和什么时候补(补贴时机)。无论是出行平台的司机接单激励,还是前端页面的用户留存补贴,背后的数学建模是相通的。本文聚焦后者——补贴时机建模。
一个典型的前端补贴场景:用户进入活动页面后,系统在合适的时机弹出优惠券或补贴提醒。触发太早,补贴在用户本就会完成转化时白白送出,浪费预算;触发太晚,用户可能已经跳出页面,补贴失去意义。出行场景中的情形同理:订单派发后,若服务方迟迟未接单,系统需要在合适的时机追加补贴激励。这两类场景本质上都是同一个问题——在”等待时间”的分布中,找到统计上有意义的关键时间节点作为触发时机。
这本质上是一个”事件发生时间”的分布建模问题:给定一批历史样本(用户会话或订单),每个样本都有一个”从开始到完成目标动作”的等待时间。我们希望从这些数据中提取出统计上有意义的分位数时间点(如 P25、P50、P75),作为补贴触发的时间锚点。
核心挑战
并非所有样本都”完成了目标动作”。相当一部分样本因为观察窗口结束(如系统超时、用户跳出)而失效——我们只知道它们的等待时间”大于观察窗口阈值”,但不知道如果给足时间,目标动作最终会不会发生。这类数据就是右删失数据。
生存分析基础概念
什么是删失(Censoring)?
删失是生存分析区别于普通统计的核心概念。当研究的”事件”(如用户完成目标动作)在观察期内没有发生,但我们知道它在某个时间点之后才可能发生,这类观测就称为删失观测。
最常见的是右删失(Right Censoring):事件发生时间大于某个已知值,但具体值未知。在补贴时机建模场景中:
- 完整事件:用户在观察窗口结束前完成目标动作 → 我们知道精确的等待时间t
- 右删失事件:观察窗口结束(系统超时或用户跳出),目标动作未发生 → 我们只知道t > 观察窗口阈值,但无法观测到若继续等待,目标动作何时会发生

右删失概念示意图。实心圆点表示完整事件(已知精确等待时间),空心圆+箭头表示右删失事件(只知道等待时间超过观察窗口阈值)。
生存函数 S(t)
生存函数(Survival Function)定义为事件发生时间 T 大于某个值 t 的概率:
$$S(t) = P(T > t) = 1 – F(t)$$
在补贴时机场景中,S(t) 表示”开始 t 秒后,目标动作仍未发生的概率”。当 t = 0 时 S(0) = 1(刚开始时肯定无人完成),随着 t 增大,S(t) 递减。
Kaplan-Meier 估计器
Kaplan-Meier(KM)估计器是一种非参数方法,用于从含删失数据中估计生存函数。它的核心思想是:在每个”事件发生”的时间点上,计算条件生存概率的连乘。
$$S(t) = \Pi (1-\frac{d_i}{n_i} ),t_i<t$$
其中 $n_i$是在时间$t_i$之前的”风险集”大小(尚在观察中的个体数),$d_i$是在时间$t_i$发生事件的个体数。
为什么 KM 而不是直接求平均?
KM 估计器的核心优势是在含删失数据下无偏。如果直接对已完成的样本求经验分位数,相当于把删失样本当作”不存在”,这会系统性地低估等待时间,因为删失的样本恰恰是那些等待时间更长的”慢”样本。
为什么经验分位数不够好?
很多团队在搭建补贴时机模型时,会用一个”看起来很自然”的方法:直接对所有已发生目标动作的样本的等待时间求分位数(P25/P50/P75),作为触发时间。这个方法有一个致命缺陷:选择性偏差。
| 对比维度 | KM 分位数 | 经验分位数 |
| 数据使用 | 包含完整事件 + 删失事件 | 仅使用已完成目标动作的样本(完整事件) |
| 偏差 | 无偏估计 | 系统性低估等待时间 |
| 原因 | KM 通过风险集调整,正确处理删失观测 | 删失样本(等待更久的”慢”样本)被丢弃 |
| 触发时机 | 合理偏晚(考虑了未完成的等待) | 过早触发(被快速完成拉低) |
| 实际影响 | 补贴在更接近真实需要时触发 | 补贴预算浪费在不必要的早期触发 |
用一个具体例子来说明:假设有 100 个样本,60 个在观察窗口内完成目标动作(平均 120 秒),40 个观察窗口结束(等待时间 > 300 秒)。
- 经验分位数P50 = 只看 60 个已完成 → 中位数约 120 秒
- KM 分位数P50 = 考虑全部 100 个 → 中位数约 300+ 秒(因为 40% 的样本等待更久)
差距显而易见。用经验分位数会导致 P50 触发时机比实际需要早了 180 秒,大量补贴在”其实不必要”的早期被触发。
从 KM 曲线提取触发时间
百分位映射原理
有了 KM 生存曲线 S(t) 后,如何从中提取”等待时间的 p 分位数”?这里有一个容易踩坑的语义问题。
生存函数 S(t) 给出的是” t 时刻目标动作仍未发生的概率”。而分位数 p 的定义是”有 p 比例的样本在此时间前已完成目标动作”。因此:
P(等待时间 ≤ t) = 1 – S(t) = p → S(t) = 1 – p
也就是说,要找等待时间的 p 分位数,需要在 KM 曲线上找到 S(t) = 1 – p 对应的时间 t。

三档触发时间
通常设置三个触发档位,对应不同的补贴力度和干预时机:
| 档位 | 分位数 | 生存概率 | 业务含义 |
| T1 | P25 | S(t) = 0.75 | 25% 的样本已完成 → 轻度补贴触发 |
| T2 | P50 | S(t) = 0.50 | 50% 的样本已完成 → 中度补贴触发 |
| T3 | P75 | S(t) = 0.25 | 75% 的样本已完成 → 重度补贴触发 |
代码实现
以下代码展示从 KM 曲线提取分位数触发时间的核心逻辑(使用 Python lifelines 库):
# pip install lifelines
from lifelines import KaplanMeierFitter
import numpy as np
def fit_kaplan_meier(durations, observed):
"""
拟合 KM 生存曲线并提取 T1/T2/T3 触发时间
参数:
durations: 每个样本的等待时间(秒)
observed: 删失标记 (1=完成目标动作/完整事件, 0=观察窗口结束/右删失)
返回:
dict: {t1, t2, t3, km_fallback}
"""
kmf = KaplanMeierFitter()
kmf.fit(durations, event_observed=observed)
def km_percentile(p):
# 关键:求等待时间 p 分位,需传生存概率 1-p
# percentile(q) 返回 S(t) = q 时的 t 值
q = kmf.percentile(1 - p)
if np.isfinite(q):
return float(q)
# KM 曲线在该分位无定义 → 返回 None 由下游兜底
return None
return {
't1': km_percentile(0.25), # P25 → S(t)=0.75
't2': km_percentile(0.50), # P50 → S(t)=0.50
't3': km_percentile(0.75), # P75 → S(t)=0.25
}
# 使用示例
durations = [...] # 等待时间列表
observed = [...] # 删失标记列表 (1=已完成, 0=窗口结束)
triggers = fit_kaplan_meier(durations, observed)
print(triggers) # {'t1': 85.0, 't2': 180.0, 't3': 320.0}
API 语义陷阱(重要):lifelines 的 percentile(q) 方法中,参数 q 是生存概率,不是分位数。求等待时间的 P25,需要传 percentile(0.75) 而非 percentile(0.25)。传反了会导致 P75 < P50(非单调),触发时间顺序错乱。
完整模型流程管线
将以上概念串联起来,完整的补贴时机模型流程分为五个阶段:

各阶段说明
- 数据准备:从行为日志中提取每个样本的”等待时间”(从进入页面/派发到完成目标动作或观察窗口结束的时长)和”删失标记”(1=完成目标动作/完整事件,0=观察窗口结束/右删失)。这是建模的基础数据结构。
- 场景分组:按业务特征(如渠道来源、页面类型、时段等)对样本进行切分,每个场景组合拟合独立的 KM 曲线。这样不同场景的触发时机可以差异化。
- Kaplan-Meier 拟合:对每个场景内的样本数据拟合 KM 生存曲线S(t)。lifelines 的 KaplanMeierFitter 在一行代码内完成。
- 分位数反推触发时间:调用percentile(1-p) 从 KM 曲线反推 P25/P50/P75 对应的时间,作为 T1/T2/T3 三档触发时间。
- 触发时间输出:每个场景输出一组 {T1, T2, T3} 和一个 km_fallback 标记字段(标记哪些分位数走了兜底),供下游补贴引擎消费。
降级机制设计
为什么需要降级?
按特征组合切分后,某些”长尾场景”的样本量可能极少(如”深夜+特定渠道+低频页面”组合可能只有几十个样本)。样本量太少时,KM 曲线不稳定,提取的分位数置信度低。
解决方案是逐级降级:当某场景样本量不足时,丢弃最不重要的特征维度,将更多场景合并到一起,直到样本量满足阈值。

特征重要性排序
降级的关键问题:先丢弃哪个特征?
最合理的方案是借助一个已有的预测模型(如转化率预测模型的 LightGBM)输出特征重要性(Gain importance),按重要性从低到高排序,重要性最低的先丢弃。这样确保保留的维度对等待时间预测贡献最大。
降级设计原则
- 重要性最低的特征先丢弃;
- 每级降级后检查合并后的样本量是否达标;
- 最底层级保留最核心的 2-3 个维度作为兜底;
- 降级后需记录使用了哪个层级,供下游分析。
兜底策略
即使降级到最粗层级,仍可能遇到极端情况:KM 曲线在某个高分位数处无定义(比如 P75 对应 S(t) = 0.25,但曲线只降到 0.30 就平台化了——说明数据中删失比例太高,永远没有 75% 的样本完成目标动作)。
此时 kmf.percentile(1-p) 会返回 inf 或 nan,需要兜底处理:
def compute_trigger_times(durations, observed):
kmf = KaplanMeierFitter()
kmf.fit(durations, event_observed=observed)
def km_pct(p):
q = kmf.percentile(1 - p)
if np.isfinite(q):
return float(q)
return None # 不回退经验值!
# 取已完成样本的中位数作为最终兜底
accepted_durations = [d for d, o in zip(durations, observed) if o == 1]
ovd_median = float(np.median(accepted_durations)) if accepted_durations else 0
t1 = km_pct(0.25) or ovd_median
t2 = km_pct(0.50) or ovd_median
t3 = km_pct(0.75) or ovd_median
return {
't1': t1, 't2': t2, 't3': t3,
'km_fallback': bool(t1 == ovd_median or t2 == ovd_median or t3 == ovd_median),
}
不要混用 KM 分位数和经验分位数
一个常见错误是:KM 无定义时,回退到对已完成子集求经验分位数。但 KM 分位数来自全量分布(含删失),经验分位数来自已完成子集的条件分布,两者在统计上不可比——混用会导致 T1 > T2 或 T2 > T3 的非单调问题。正确做法是 KM 无定义时返回 None,由统一的兜底值(如全局中位数)接管,保证单调性。
实现踩坑指南
lifelines percentile API 语义
这是最容易踩的坑。KaplanMeierFitter.percentile(q) 中的 q 参数是生存概率,而非分位数。它的语义是”返回 S(t) = q 时的 t”。
| 你想要的 | 分位数 p | 需要传入 percentile 的参数 | 原因 |
| P25(25%已完成) | 0.25 | percentile(0.75) | S(t)=0.75 → 25%已完成 |
| P50(中位数) | 0.50 | percentile(0.50) | S(t)=0.50 → 50%已完成 |
| P75(75%已完成) | 0.75 | percentile(0.25) | S(t)=0.25 → 75%已完成 |
KM 与经验分位数混用 → 非单调
如前所述,KM 分位数和经验分位数来自不同的概率分布(全量 vs. 条件子集),数值上不可直接比较。混用会导致 P50 > P75 的非单调现象,这在业务上是不可接受的(中位数触发时间不应晚于 75 分位数触发时间)。
解决方案:统一使用 KM 分位数,KM 无定义时返回 None,由下游 or 逻辑统一兜底到一个全局值,保证单调性。
scipy 版本兼容性
lifelines 0.27.x 版本内部有 from scipy.integrate import trapz 的导入,而 scipy >= 1.14 移除了 trapz(改名为 trapezoid)。这会导致 ImportError。
解决方案是在导入 lifelines 之前注入一个 shim:
import scipy.integrate
if not hasattr(scipy.integrate, 'trapz'):
scipy.integrate.trapz = scipy.integrate.trapezoid
from lifelines import KaplanMeierFitter # 现在可以正常导入
版本验证建议
不同版本的 lifelines(如 0.27.x 与 0.30.x)在 percentile 的语义上是否一致,建议用模拟数据交叉验证:
- 生成一批已知分布的模拟数据(如指数分布)
- 人工实现 KM 分位数反推逻辑(从头到尾不依赖 lifelines)
- 与 lifelines 的 percentile() 结果对照
- 若两者一致,说明该版本语义正确
总结
| 关键决策 | 选择 | 原因 |
| 估计方法 | Kaplan-Meier | 非参数、含删失数据无偏 |
| 分位数提取 | percentile(1-p) 反推 | 生存概率与等待时间分位互补 |
| 兜底策略 | None + 全局中位数 | 避免 KM/经验混用导致非单调 |
| 降级机制 | 按特征重要性逐级丢弃 | 保证长尾场景也有合理触发时间 |
| 场景分组 | 多维度特征组合 | 不同场景的等待时间分布差异大 |
生存分析在补贴时机建模中的价值,本质上是正确处理了”未完成目标动作的样本”携带的信息。经验分位数把它们当作不存在,KM 把它们当作”等待时间大于某个值”的部分信息——这个区别在删失比例高时尤为关键。
一旦理解了右删失、生存函数、百分位映射这三个核心概念,整个模型的逻辑链就贯通了:删失标记 → KM 拟合 → 分位数反推 → 触发时间。剩下的工程问题(降级、兜底、版本兼容)都是对这条主链的防御性加固。





