ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

留存率计算中的幸存者偏差:Python实验与SQL口径解析

留存率计算中的幸存者偏差:Python实验与SQL口径解析 留存指标是最容易沾上幸存者偏差的数据指标之一。所谓幸存者偏差就是你看到的结果是从一个已经被筛选过的样本中统计出来的而样本背后那些早期流失的用户被静默移除了于是所有比例都会被拉高。尤其在计算留存率时如果分母取了“当前还活跃的用户”而不是“一开始进入观察队列的用户”那么留存率会明显虚高。更麻烦的是这种虚高在图表上看起来非常健康反而会掩盖真实的流失问题。下面用一个 Python 最小实验复现这个过程整个代码运行时间不超过 12 秒任何人都能立刻验证。这里不讨论复杂的业务口径也不引入庞大的数据平台。只需要一个随机数生成器、一条简单的寿命模拟规则就能让“幸存者偏差”从抽象概念变成一个可以直观看到的数字差异。看完之后你会在 SQL 和指标看板里对“分母是谁”这个问题格外敏感。1. 留存指标里的幸存者偏差到底是什么1.1 从统计口径上看透幸存者偏差幸存者偏差最早来自一个经典案例二战时研究者给飞机加装装甲只统计返航飞机的弹孔分布忽略了没有返航的飞机因为被击落的飞机根本看不到弹孔。这个案例放在留存指标里同样成立流失用户已经不再产生活动数据所以他们从报表视野里消失了。你看到的“留存用户的那部分活跃记录”天然只来自那些没有流失的人。在统计上正确留存率的定义是P(第 60 天活跃 | 第 0 天注册)而幸存者偏差则会把指标计算成P(第 60 天活跃 | 第 30 天还活跃)这两个条件概率的分母完全不同。第一个分母是初始注册用户第二个分母是已经被筛选过的活跃用户。活跃用户本来就是“活得长”的群体所以在这个群体里继续计算留存结果一定会比真实留存率高。这个偏差的麻烦之处在于它不会报错。SQL 能跑通看板能出图指标上下浮动也正常但方向上错得离谱。如果团队没有认真核对分母这种错误口径很容易长期存在。1.2 两个不同的分母两种不同含义以 30 天和 60 天为观察点可以更清楚地看出两种口径的差异。指标分母分子实际含义是否代表真实留存正确留存率第 0 天注册用户数第 60 天活跃用户数新用户在 60 天后的存活比例是错误留存率第 30 天活跃用户数第 30 天和第 60 天都活跃的用户数已存活到第 30 天的用户继续存活到第 60 天的条件概率不是错误口径更像“活跃用户连续性”或“回访率”。它回答的问题是“当前这批活跃用户过一段时间还会不会回来”这个问题有价值但它不是新用户留存率。如果把它当成留存率汇报就会误导管理层让团队以为产品留存很好实际上新增用户已经在早期大量流失。1.3 一个数据生成器就能看清整个过程为了把偏差复现出来只需要一个非常简单的用户寿命模型每个用户从注册第 0 天开始有一个“活跃寿命”寿命结束之后就不再产生活跃记录。有些用户第 2 天就流失有些用户能活到第 60 天以后。在这个模型下“第 30 天活跃用户”就是那些寿命大于等于 30 天的用户。他们天然是更耐用的用户因为不活跃的人已经被提前排除掉了。所以如果我们计算“第 30 天活跃用户在第 60 天仍然活跃的比例”这个比例一定大于“全部注册用户在第 60 天仍然活跃的比例”。这就是幸存者偏差的全部来源。它不需要复杂的业务逻辑只要筛选条件先于统计动作偏差就一定存在。2. 一个 12 秒可复现的最小实验2.1 模拟设计用户寿命模型实验使用 NumPy 生成 1000 个用户的活跃寿命。寿命服从指数分布指数分布适合用来模拟用户流失因为它在每个时间点上的流失概率接近常数。实际业务里的用户流失曲线通常会比指数分布更平缓或更陡峭但不影响演示偏差的方向。代码固定随机种子保证任何人都能得到相同结果。这里设定两个观察点第 30 天作为“存活到当前”的筛选条件。第 60 天作为留存观察点。两个指标分别为正确留存率寿命大于等于 60 的用户数 / 总用户数。错误留存率寿命大于等于 60 的用户数 / 寿命大于等于 30 的用户数。注意错误留存率的分子和正确留存率一样都是寿命大于等于 60 的用户数差别只在分母。这正是许多留存报表里最容易出错的地方已经算出了留存用户数却在除法时分母选错了。2.2 Python 代码生成用户寿命并计算两个口径新建一个 Python 脚本例如survivorship_demo.py内容如下import numpy as np rng np.random.default_rng(42) N 1000 # 模拟每个用户从注册开始的活跃寿命 # scale20 表示平均寿命约为 20 天 lifetime rng.exponential(scale20, sizeN) # 第 30 天还活跃的用户数 n_active_30 int(np.sum(lifetime 30)) # 第 60 天还活跃的用户数 n_active_60 int(np.sum(lifetime 60)) # 正确口径分母是初始注册用户 correct_retention n_active_60 / N # 错误口径分母是第 30 天仍活跃的用户 wrong_retention n_active_60 / n_active_30 print(f总注册用户数: {N}) print(f第 30 天活跃用户数: {n_active_30}) print(f第 60 天活跃用户数: {n_active_60}) print(f正确留存率: {correct_retention:.2%}) print(f错误留存率: {wrong_retention:.2%}) print(f偏差倍数: {wrong_retention / correct_retention:.2f} 倍)这段代码的统计逻辑非常直观。lifetime数组保存了每个用户的活跃寿命。lifetime 30是布尔数组求和后就是活跃用户数。lifetime 60求和后是留存用户数。唯一要注意的是指数分布会生成很大的值比如某个用户可能活到 500 天。在真实业务里这并不常见但不会影响观察点判断因为计算只关心是否大于等于 30 和 60。2.3 运行结果与解释在命令行执行python survivorship_demo.py运行时间通常在 1 秒以内这里说的“12 秒”更多是包含打开文件、粘贴代码、执行和思考结果的时间。输出大致如下总注册用户数: 1000 第 30 天活跃用户数: 223 第 60 天活跃用户数: 50 正确留存率: 5.00% 错误留存率: 22.42% 偏差倍数: 4.48 倍不同随机种子会略微改变数字但偏差方向完全一致。从结果可以看到正确留存率只有 5%而错误留存率高达 22%。第 30 天活跃的用户本来就是能够活到第 30 天的“幸存者”他们继续存活到第 60 天的概率自然远高于一个随机新用户。注意这个实验并不是为了否定“活跃用户后续活跃率”这个指标而是想说明这个问题——如果把它命名为“留存率”并用它指导运营决策就会得出完全不同的结论。3. 正确口径与错误口径的差异分析3.1 留存率的标准定义是“初始队列”的留存率留存率的标准定义服务于这样一个业务问题某个日期进入产品的用户在经历一段时间后还有多少人持续使用。比如 7 日留存率的分母是“某一天或某一周新增注册用户数”分子是“这些用户在 7 天后仍然活跃的独立用户数”。把这条定义拆开来有两个关键点分母必须是一个固定队列后面不允许因为用户流失而改变。分母必须在计算开始时就能确定不依赖观察点之后的任何条件。这就是“队列分析”的基本思想。它保证了不同批次用户之间可以横向比较也保证了时间趋势能够反映产品和运营的真实变化。3.2 错误口径为什么会被当作“留存率”很多错误的留存口径长这样从活跃用户表里取第 30 天活跃的用户再统计其中第 60 天活跃的人最后把比例写成“第 60 天留存率”。这种写法在 SQL 里非常容易实现因为活跃表天然只包含活跃用户。如果一个人没有先想清楚分母定义就会顺手把活跃用户数作为分母。结果就是第 30 天活跃用户数只有 223而错误留存率把 50 人除以 223数字自然会高。这种指标更像“活跃用户的持续率”或“回访率”。它回答的是“当前活跃用户有多忠诚”而不是“新用户有多少留下来”。一种更迷惑的写法是不过滤“第 30 天活跃”而是从用户表里筛选出“最近 30 天内有登录记录”的用户再观察这些人下个周期是否登录。这种口径会比“第 30 天活跃用户”更复杂但本质一样先选了一批看起来活跃的人再算这批人的留存率。3.3 用条件概率拆解偏差来源把两个口径写成概率符号。设定事件 A 表示“用户活到第 30 天”事件 B 表示“用户活到第 60 天”。正确留存率是P(B)这也是无条件概率等于寿命大于等于 60 的用户占比。错误留存率是P(B | A)条件概率表示“已知用户活到第 30 天继续活到第 60 天的概率”。因为事件 B 发生必然意味着事件 A 发生也就是说活到 60 天的人一定也活到了 30 天所以 A 是一个比 B 更大的集合。在集合更大的情况下条件概率的分子相同分母更小结果自然更大。对于指数分布这个差异可以被精确量化P(寿命 60) e^{-60/20} e^{-3} ≈ 4.98% P(寿命 60 | 寿命 30) e^{-30/20} e^{-1.5} ≈ 22.31%两者相差约 4.48 倍和实验输出一致。真实业务中不需要真的知道分布参数只需要记住这个结论只要用户寿命分布不是全部相同的确定性长寿命筛选活跃用户后计算的留存率就一定会高于真实留存率。3.4 在真实业务中偏差带来的后果如果留存率被系统性高估团队会误判产品健康度。假设真实留存率是 5%错误指标显示 22%产品经理可能认为留存很好于是把工作重点放在拉新而不是激活上。更危险的是当真实留存率下降时错误口径可能变化不大甚至上升。因为早期流失的用户早就被筛掉了留下来的都是最忠诚的用户他们的持续活跃率自然会更高。于是报表看起来稳定业务却在大幅流失这会让团队错失处理问题的窗口期。挽回方法只有一个每个指标都回到定义先想清楚分母应该是什么。4. 用 SQL 复现同样的逻辑并检查口径4.1 表结构与数据准备在真实数据仓库中留存分析通常有两张表一张是用户注册表一张是用户活跃事件表。为了演示可以用临时表模拟。CREATE TEMP TABLE users ( user_id INT, registered_day INT ); CREATE TEMP TABLE user_active_days ( user_id INT, day_offset INT );users表保存所有注册用户user_active_days表保存每天活跃的用户。比如用户 1 在第 0 天到第 10 天活跃那么user_active_days表中就有 11 条记录。实际插入数据时可以根据 Python 实验生成的寿命数据来构造。为了方便阅读这里不展开完整插入脚本只说明建模思路每个用户对应未活跃区间为0 active_day lifetime的记录。4.2 错误 SQL把第 30 天活跃用户当作分母错误 SQL 一般长这样SELECT a.active_30 AS active_30, b.active_30_and_60 AS active_30_and_60, b.active_30_and_60 * 1.0 / a.active_30 AS wrong_retention FROM ( SELECT COUNT(DISTINCT user_id) AS active_30 FROM user_active_days WHERE day_offset 30 ) a CROSS JOIN ( SELECT COUNT(DISTINCT a.user_id) AS active_30_and_60 FROM ( SELECT DISTINCT user_id FROM user_active_days WHERE day_offset 30 ) a INNER JOIN ( SELECT DISTINCT user_id FROM user_active_days WHERE day_offset 60 ) b ON a.user_id b.user_id ) b;这段 SQL 的意图是先取出第 30 天活跃的用户再统计这些人里第 60 天还活跃的人数最后相除。这对应错误口径分母是第 30 天活跃用户数。这段查询没有任何语法错误也能得到稳定结果但业务含义已经偏了。它只代表幸存到第 30 天的用户继续幸存到第 60 天的概率。4.3 正确 SQL以注册用户表为分母正确 SQL 需要保证分母来自users表也就是初始注册队列。SELECT COUNT(DISTINCT u.user_id) AS registered_users, COUNT(DISTINCT d.user_id) AS active_60, COUNT(DISTINCT d.user_id) * 1.0 / COUNT(DISTINCT u.user_id) AS correct_retention FROM users u LEFT JOIN user_active_days d ON u.user_id d.user_id AND d.day_offset 60;用LEFT JOIN保留所有注册用户即使他们第 60 天没有活跃记录也会在结果中以d.user_id为 NULL 出现。COUNT(DISTINCT d.user_id)自动忽略 NULL因此分子只统计第 60 天活跃的用户。这里的重点在分母COUNT(DISTINCT u.user_id)是整个注册队列的规模不会因为流失用户被过滤而缩小。如果数据库查询性能有要求可以先把user_active_days按用户做聚合避免大量重复行WITH active_60_users AS ( SELECT DISTINCT user_id FROM user_active_days WHERE day_offset 60 ) SELECT COUNT(u.user_id) AS registered_users, COUNT(au.user_id) AS active_60, COUNT(au.user_id) * 1.0 / COUNT(u.user_id) AS correct_retention FROM users u LEFT JOIN active_60_users au ON u.user_id au.user_id;这种写法更容易阅读也方便扩展为按注册日期分组的队列留存率。4.4 运行后如何核对数据执行完两条 SQL 后可以对照 Python 实验的数字做验证指标Python 实验预期SQL 查询预期第 30 天活跃用户数223约 223第 60 天活跃用户数50约 50正确留存率5.00%约 5%错误留存率22.42%约 22%核对时优先看两个绝对数活跃用户数和留存用户数。如果活跃用户数远小于注册用户数说明该系统已经存在较强流失那么任何只用活跃用户做分母的留存率都会偏高。注意在生产环境核对指标时不要只看留存率曲线还要同时展示分母和分子的绝对值。否则分子分母同时变化时很难判断比率变化来自哪一边。5. 常见问题排查与口径清单5.1 三个容易被当成分母的错误范围第一个错误范围是活跃用户表。活跃用户表只包含当天或最近一段时间活跃的用户。把它作为留存率分母相当于用“幸存者”作为分析基础。第二个错误范围是回访用户表。有些报表会把“昨日活跃用户中今天仍活跃的用户比例”命名为留存率。这个数字可能很高但它描述的是活跃用户粘性而不是新增用户留存。第三个错误范围是混合群组。比如查询时用了全局用户表没有按注册日期分组。这样不同周期注册的用户混在一起可能会把老用户的回访行为也算作留存导致指标失真。错误范围错误现象为什么会错推荐做法活跃用户表分母很小留存率虚高流失用户被过滤掉用注册用户表作为分母回访用户表指标看起来像留存实为回访率统计对象不包含流失用户单独定义回访率或粘性指标混合群组留存率波动无规律新老用户混杂按注册日期拆同期群5.2 排查思路先回答五个问题在排查一个留存 SQL 时建议按顺序回答下面五个问题。第一初始队列是什么是全部注册用户还是某个城市、某个渠道、某个实验组这个队列必须在指标计算之前确定。第二活跃事件如何定义是打开 App、登录、触发关键行为还是完成订单同一批用户在不同活跃定义下留存率差异会很大。第三时间窗口的边界是什么第 0 天是注册当天还是注册后 24 小时自然日还是相对时间边界差一天结果就可能不一样。第四是否排除了观察期之后注册的新用户如果查询时不带注册时间条件很容易把后来注册的用户混进观察队列。第五分母是否恒定在指标计算的整个过程中分母有没有因为筛选条件而变小如果变小了一定存在幸存者偏差。回答完这五个问题绝大多数口径问题都能暴露出来。5.3 留存指标口径检查清单下面是一份可以直接在团队内部使用的留存指标口径检查清单。检查项检查方法通过标准分母来源查看 SQL 中 FROM 和 WHERE分母来自注册用户表或注册队列不包含活跃筛选条件初始队列时间检查注册日期过滤条件明确指定注册时间范围不混入观察期后的用户活跃定义确认活跃事件表含义有明确事件名称或行为定义观察窗口边界对比 day_offset 计算逻辑边界一致不跨自然日或相对时间混用分子与分母范围分别执行 COUNT 验证分子用户数不超过分母用户数同期群拆分查看分层字段至少按注册日期或渠道拆群绝对值展示查看看板配置图表同时展示留存用户数和留存率这份清单可以粘贴到数据需求文档里也可以作为评审 SQL 的检查标准。如果查询没有通过清单里任何一项都应该先和需求方确认口径再继续开发。6. 落地建议从指标定义开始防御幸存者偏差6.1 用同期群分析消除偏差同期群分析的核心是“固定队列”。所有用户按照注册日期分到不同的桶里比如按自然周分组。之后计算 7 日留存、30 日留存时分母始终是该周注册用户数分子是该周注册用户中在目标日活跃的人数。示例 SQL 如下SELECT DATE_TRUNC(week, u.registered_day) AS cohort_week, COUNT(DISTINCT u.user_id) AS registered_users, COUNT(DISTINCT d.user_id) AS active_users, COUNT(DISTINCT d.user_id) * 1.0 / COUNT(DISTINCT u.user_id) AS retention_rate FROM users u LEFT JOIN user_active_days d ON u.user_id d.user_id AND d.day_offset 30 GROUP BY DATE_TRUNC(week, u.registered_day) ORDER BY cohort_week;这样每个 cohort 的分母都是固定的注册人数不存在幸存者偏差。6.2 生产留存看板必须同时展示绝对值比率指标很容易骗人。如果留存率上升但留存用户数下降说明分母下降得更快用户总盘子正在缩小。这种时候留存率上升不代表产品变好。生产环境中的留存看板至少应该包括三个字段注册用户数分母反映进入产品的流量规模。留存用户数分子反映真正留下的人的规模。留存率比率方便不同周期和团队之间比较。三者缺一不可。如果看板只展示留存率就很容易被幸存者偏差掩盖。注意分母和绝对值最好按同期群展示不要只展示一个全局数否则不同批次用户混合在一起趋势分析会失真。6.3 还有哪些指标容易被幸存者偏差污染除了留存率其他比率型指标也可能遇到类似问题。复购率如果分母只取“已经购买过的人”而不是“一段时间内进入的新用户”就会高估复购水平。功能使用率如果只看“用过功能的用户中还有多少在继续用”也会忽略从未用过功能的用户。召回率只统计“已被召回过且回来的用户”分母不是整个目标人群结果会被高估。活跃率如果分母是“当前活跃用户”而分子是“活跃用户中完成关键行为的人”这就是活跃占比不是全体用户的活跃率。这些指标共同的特点是分母必须代表一个明确的目标人群而不是先经过了一次筛选的便利样本。6.4 一个可执行的数据质量检查流程指标上线前建议按以下流程做一次检查。第一步写清指标定义包括分母、分子、时间窗口、统计对象、活跃定义。定义必须写成文字不能只写在 SQL 注释里。第二步用模拟数据或小范围真实数据跑一遍观察结果是否符合预期。可以拿本文的 Python 实验作为最小样例。第三步分别统计分母和分子的绝对值确认分子永远不会大于分母。如果分子大于分母说明 SQL 连接或去重逻辑有问题。第四步让另一个同事独立看 SQL回答“分母是否恒定”这个问题。如果没有别人就自己做一次“从注册表出发”的重写再对比结果。第五步在正式看板里同时展示分母、分子、比率三个字段并保留历史版本便于后续口径变更时回溯。这个流程并不复杂但能拦住大部分指标口径问题。真正的关键还是同一句话先确认分母是谁再谈比率高低。留存率的计算并不需要高深的算法真正需要的是对“分母”始终保持警觉。一个能在一分钟内跑完的模拟实验已经足够证明幸存者偏差会让错误的留存指标放大四倍以上。以后在任何数据报表里看到留存率不妨先问一句这里的分母是那批一开始进入产品的用户还是已经被筛选过的活跃用户
返回列表