
先看一个很反常的留存率用户总量每天在掉但后台的“活跃用户留存率”却稳定在 60% 以上。这不是产品变好了而是统计口径把已经流失的人踢出了计算范围。这种现象在留存指标体系里非常常见名字叫幸存者偏差Survivorship Bias。今天要拆解的是一个 Show HN 项目Survivorship bias in retention metrics, reproducible in 12 seconds。它不涉及复杂模型也不挑显卡核心就一件事在 12 秒量级的轻量实验里把留存指标中的幸存者偏差完整复现出来。你不需要 GPU、不需要大模型只需要一个 Python 环境和几个常用的数据处理库跑完后你会对“留存率为什么追不回来”有一个更直接的认知。这篇文章我会从项目价值、环境准备、复现代码、指标口径、常见坑位几个维度展开。先把结论放在前面这个项目的价值不在算法而在统计学直觉的工程化演示。它适合数据分析师、增长产品经理、数据工程师和任何写留存 SQL 的同学看完后你可以顺手检查一下自己的留存报表是否犯了同样的口径错误。1. 核心能力速览标题里三个关键词值得拆开看。第一个是 retention metrics留存指标第二个是 survivorship bias幸存者偏差第三个是 reproducible in 12 seconds12 秒可复现。这个项目不解决“怎么把留存做高”而是帮你想清楚“看到的留存率是不是假的”。下面用一张表把它的能力边界列出来。能力项说明项目类型统计模拟与数据可视化演示核心演示内容对比全量用户留存率与“幸存者留存率”的差异复现时间秒级标题称 12 秒可复现实际取决于脚本规模和机器配置运行环境Python 3.8需要 pandas、numpy、matplotlib是否需要 GPU否纯 CPU 脚本即可输入模拟生成的用户注册事件和活跃事件输出留存矩阵、偏差对比曲线、汇总表格接口 API无演示项目通常不提供批量任务无可通过参数循环扩展适合场景数据分析教学、指标口径审查、报表设计前验证从能力项可以看出这个项目不是一个生产级数据分析系统而是一个“统计直觉校准工具”。它的门槛极低凡是能跑 Python 的地方都能跑。这也是为什么标题敢强调“12 秒”——因为整套模拟实验的数据量很小计算量也很小一次运行就能看到非常明显的偏差现象。2. 这个项目适合谁不适合谁这个项目最适合三类人。第一类是数据分析师尤其是日常要写留存 SQL、做留存报表的人。你每天看的数据里如果分母口径不对结论会被系统性放大这个项目能帮你快速识别问题。第二类是增长产品经理做活动复盘和渠道分析时如果只统计“活跃用户里的留存”很容易高估产品黏性这个项目能给你一个直观的反例。第三类是数据仓库工程师你在设计留存指标表时需要明确分母是全量用户还是活跃用户这个演示可以当作口径评审前的反面教材。它不适合什么场景呢如果你的目标是搭建一套生产级留存分析系统这个项目帮助不大。它没有复杂的分群、没有多维下钻、没有权限管理也不适合直接对接线上行为日志。它也不适合用来证明“某个产品留存变好”因为模拟数据是人为设定的真实业务还有新增质量、外部投放、季节因素、版本更新等一堆协变量这些不是一个小演示能覆盖的。还需要说清楚边界模拟数据不涉及真实用户隐私但如果你把同样的思路用到真实业务数据上必须注意脱敏和合规。尤其是用户标识、设备信息、地理位置这些字段在分析环境里要按公司数据安全规范处理。留存分析本身是一门敏感度很高的数据工作口径错了影响决策权限松了影响用户隐私两者都不能忽视。3. 幸存者偏差在留存指标里到底怎么出现很多人以为留存率天然是一个“会波动但整体向下”的曲线但如果你的代码里不小心用了幸存者样本留存率就会变得异常平稳甚至一天比一天高。核心原因在于分母的定义。先看一个经典例子。假设某天新增 1000 个用户第 1 天有 600 人回来第 2 天这 600 人里有 360 人回来。如果按全量用户算第 1 天留存率是 60%第 2 天留存率是 36%。这是真实的留存曲线它遵循“衰减”规律。但如果有人改了一下方案第 2 天留存率不再除以 1000而是除以第 1 天还活跃的 600 人那第 2 天的留存率就变成 360 / 600 60%。第 3 天继续用第 2 天活跃的人群做分母也会是一个相对稳定的数值。这个按“活下来的人”算出来的留存率就是幸存者留存率。问题在于我们每天关注的产品实际状态是活跃用户池一直在收缩留下来的用户在变少。可如果你只看幸存者群体内部的继续活跃比例你会发现这个比例很高好像产品黏性很足。这就是幸存者偏差在留存指标中最常见的表现形式分母被悄悄替换成了“前一天还活跃的用户”而分母本应该是“该同期群的全部新增用户”。这种偏差还会出现在很多衍生指标里。比如付费用户留存、高活跃用户留存、创作者次周留存。只要分组本身已经是“活下来的用户”留存率就会被系统性抬高。你看到的高留存也许只是幸存者群体内部的黏性并不代表整个新增群体都留住了。4. 复现思路与数据模拟设计这个项目的复现逻辑并不复杂核心只有四步生成模拟数据、计算全量留存率、计算幸存者留存率、对比并画图。为了让结果在 12 秒量级内稳定出现数据规模不需要很大参数也需要固定。我们模拟 60 个同期群cohort每个同期群代表一天的 100 个新增用户。每个用户每天有 60% 的概率继续活跃最多持续 30 天。这个设定意味着真实留存率会随时间指数衰减但如果你只看“前一天活跃的用户里今天还有多少人活跃”留存率会稳定在 60% 左右。下面用一个固定随机种子来保证每次运行结果一致。如果你把这个脚本放进 Jupyter Notebook 里从上到下跑一遍通常也就是几秒钟的事情。标题里的“12 秒”更多是在强调这个实验极轻量你可以随时在自己电脑上验证一遍而不用搭一套完整的数据平台。5. 本地环境准备与前置条件先确认你的机器上有 Python 环境。建议用 Python 3.8 及以上版本太低版本对 pandas 和 matplotlib 的支持会差一些。打开终端或命令行执行下面这行命令看 Python 版本python --version如果看到 Python 3.8 的输出继续检查依赖库python -c import pandas, numpy, matplotlib; print(ok)如果这行命令报错说明缺少依赖。先用 pip 安装pip install pandas numpy matplotlib建议在虚拟环境里安装避免污染系统 Python。如果是 Anaconda 用户可以创建一个新环境conda create -n retention-demo python3.10 -y conda activate retention-demo pip install pandas numpy matplotlib不需要 CUDA不需要 GPU也不需要预留几十 GB 的模型文件。磁盘占用很小内存消耗基本可以忽略。整个实验是纯 CPU 计算pandas 的 groupby 和 matplotlib 绘图就够了。如果你不想在本地折腾也可以把脚本直接贴到 Google Colab 或任意 Jupyter Notebook 平台。只要运行时能安装 pandas 和 matplotlib效果是一样的。想用 12 秒复现的话本地终端运行脚本是最快的路径。6. 安装部署与启动方式由于这是一个 Show HN 类型的演示项目通常会有两种使用入口在线演示和本地复现。在线演示一般是直接打开网页点几个按钮就能看到结果本地复现则需要拉取代码或复制脚本。我这里给出一套通用的本地跑法如果你已经拿到了项目仓库以 README 里的命令为准。假设你拿到了一个 Git 仓库地址打开终端执行git clone repo-url cd repo-directory pip install -r requirements.txt如果项目没有 requirements.txt就手动安装 pandas、numpy、matplotlib 三个依赖。然后启动方式有两种一种是直接运行脚本另一种是在 Jupyter 里逐格执行。直接运行脚本的通用格式python retention_survivorship_bias_demo.py如果你用的是 Jupyter只需要新建一个 notebook把下面一节的代码按单元格复制进去然后依次运行。由于没有统一的项目入口这里不写死启动脚本名。你只要保证代码里的数据生成逻辑不变就能复现出同样的现象。7. 功能测试与效果验证这一节是全文核心。我用一台普通 CPU 机器能运行的逻辑给出一份完整可复现的模拟脚本。你可以把整个代码块保存为一个 Python 文件也可以放到 Jupyter 里运行。下面这段代码不是原始项目的源码而是按照“复现幸存者偏差”的思路写的一个最小可运行版本。import numpy as np import pandas as pd import matplotlib.pyplot as plt # 固定随机种子保证结果可复现 rng np.random.default_rng(42) # 参数设定 COHORTS 60 # 模拟 60 个同期群 USERS_PER_COHORT 100 STAY_PROB 0.6 # 用户每天继续活跃的概率 MAX_AGE 30 # 一个用户最多活跃 30 天 records [] for cohort_day in range(COHORTS): for user_id in range(USERS_PER_COHORT): user fc{cohort_day}_u{user_id} day cohort_day while day cohort_day MAX_AGE and rng.random() STAY_PROB: records.append({ cohort: cohort_day, user: user, day: day, }) day 1 events pd.DataFrame(records) print(f模拟事件记录数: {len(events)}) # 计算每个用户相对于同期群成立日期的偏移 events[offset] events[day] - events[cohort] # 每个同期群每天活跃用户数 active_by_offset events.groupby([cohort, offset]).size().unstack(fill_value0) # 全量用户留存率分母是同期群全部新增用户 full_retention active_by_offset.div(USERS_PER_COHORT, axis0) # 幸存者留存率分母是前一天还活跃的用户 previous_active active_by_offset.shift(1, axis1).replace(0, np.nan) survivor_retention active_by_offset.div(previous_active) # 按 offset 取平均值 mean_full full_retention.mean(axis0) mean_survivor survivor_retention.mean(axis0) summary pd.DataFrame({ offset: mean_full.index, full_retention: mean_full.values, survivor_retention: mean_survivor.values, }) print(留存率对比前 12 天) print(summary.head(12)) # 画图 plt.figure(figsize(8, 5)) plt.plot(mean_full.index, mean_full.values, labelFull-cohort retention, markero) plt.plot(mean_survivor.index, mean_survivor.values, labelSurvivor-only retention, markerx) plt.xlabel(Days after acquisition) plt.ylabel(Retention rate) plt.title(Survivorship bias in retention metrics) plt.legend() plt.grid(True) plt.show()运行这段代码后你会看到两条完全不同的曲线。全量用户留存率从第 0 天的 1.0 快速衰减到第 7 天左右就已经处于一个很低的水平。而幸存者留存率会在 0.6 附近来回波动几乎不随天数快速下降。判断实验是否成功的标准很简单两条曲线明显分开并且幸存者留存率明显高于全量留存率。如果两条曲线重合多半是代码中的分母计算出了问题。比如active_by_offset.div(USERS_PER_COHORT)写成了除以每日活跃数就会让全量留存率被高估从而掩盖偏差。这里还可以做几个扩展测试。第一个是把STAY_PROB从 0.6 改成 0.8你会发现全量留存率的衰减变慢了但幸存者留存率会提高到 0.8 附近偏差依然存在。第二个是把COHORTS增大到 200曲线会更平滑但运行时间仍然很短。第三个是调整MAX_AGE观察更长周期的留存曲线形态。这些参数调整可以帮助你建立更稳固的直觉幸存者偏差不是某个参数导致的而是逻辑上天然存在的。8. 指标口径修正与 SQL 实现如果你看懂了上面的模拟回到真实业务中第一件事就是检查留存指标的口径。正确做法是分母必须是“同期群里的全部新增用户”分子是“该同期群中在目标日期活跃的去重用户”。下面是一段通用 SQL 示例用于计算正确的同期群留存率。WITH cohort AS ( SELECT user_id, MIN(event_date) AS cohort_date FROM user_events GROUP BY user_id ), daily_active AS ( SELECT user_id, event_date FROM user_events GROUP BY user_id, event_date ) SELECT c.cohort_date, DATEDIFF(d.event_date, c.cohort_date) AS retention_day, COUNT(DISTINCT d.user_id) AS retained_users, COUNT(DISTINCT c.user_id) AS cohort_users, COUNT(DISTINCT d.user_id) / COUNT(DISTINCT c.user_id) AS retention_rate FROM cohort c LEFT JOIN daily_active d ON c.user_id d.user_id GROUP BY 1, 2这段 SQL 是伪 SQL具体函数名需要根据你的数据仓库方言调整。比如DATEDIFF在 ClickHouse、Snowflake、Hive 里的写法略有不同但核心逻辑是一样的先确定每个用户的新增日期再关联每个用户的活跃日期最后用留存用户数除以同期群用户总数。容易出错的地方是“活跃”的定义。如果user_events表里只保存了某种特定行为事件比如只有“登录”事件那么MIN(event_date)只能代表第一次登录不代表真正的注册日。更稳妥的做法是在用户表里找注册时间或者在事件表里单独记录注册事件。错误写法也很常见。比如有人会在子查询里先筛选出“最近 7 天活跃用户”再用这个活跃用户集合去做留存分析这会让分母变成幸存者样本最终得到高估的留存率。如果你发现报表里的留存率长期稳定在某个较高水平且无论你怎么优化产品都不下降那大概率就是口径问题。9. 接口 API 与批量复现这个演示项目通常不会提供 API因为它本身是一段教学性质极强的脚本核心价值在于帮你建立直觉而不是对外提供一个可调用的留存计算服务。如果你确实需要把这种模拟能力封装成工具可以自己写一个函数方便批量跑参数。下面给出一个批量复现的示例函数。它接收留存概率、同期群数量、最大活跃天数等参数返回两条留存曲线的平均值import numpy as np import pandas as pd def simulate_retention( cohorts: int 60, users_per_cohort: int 100, stay_prob: float 0.6, max_age: int 30, seed: int 42, ): rng np.random.default_rng(seed) records [] for cohort_day in range(cohorts): for user_id in range(users_per_cohort): user fc{cohort_day}_u{user_id} day cohort_day while day cohort_day max_age and rng.random() stay_prob: records.append({ cohort: cohort_day, user: user, day: day, }) day 1 events pd.DataFrame(records) events[offset] events[day] - events[cohort] active events.groupby([cohort, offset]).size().unstack(fill_value0) full active.div(users_per_cohort, axis0) previous active.shift(1, axis1).replace(0, np.nan) survivor active.div(previous) return { full: full.mean(axis0), survivor: survivor.mean(axis0), } result simulate_retention(stay_prob0.7, seed1) print(result[survivor].head(10))这样的函数很适合做参数灵敏度分析。你可以把stay_prob从 0.5 调到 0.9观察两条曲线的差距。也可以把cohorts调大观察稳定性。批量跑参数时要注意数据量虽然小但如果循环几千次也会产生不小的冗余计算建议只在中等规模范围内做实验。如果未来要把它接到生产批量任务里更推荐的做法是把参数化后的模拟函数写成独立 Python 模块用命令行参数控制再用 shell 循环批量执行。例如for p in 0.4 0.5 0.6 0.7 0.8; do python simulate_retention.py --stay_prob $p --output result_$p.csv done这种方式不依赖 API也没有额外服务适合本地研究和报表口径验证。10. 资源占用与性能观察这个项目对资源的要求非常低。运行环境是纯 CPU没有 GPU 参与内存占用主要取决于你模拟的事件记录数。上面脚本里 60 个同期群、每个同期群 100 个用户、最大活跃 30 天产生的事件记录数在几千到一万左右这个体量对现代计算机来说几乎感觉不到压力。如果你想观察具体耗时可以在终端里用 time 命令time python retention_survivorship_bias_demo.py运行结束后终端会输出 real、user、sys 三个时间。由于随机数生成和 pandas groupby 计算很快real 时间通常只有几秒。如果你把数据规模放大到 1000 个同期群、每个同期群 1 万用户pandas 的内存占用会明显上升但依然不会到需要 GPU 的程度。真正影响性能的因素有两个。第一个是MAX_AGE用户最多活跃天数越长每个用户产生的事件记录数越大pandas 计算开销也会变大。第二个是USERS_PER_COHORT用户量直接影响记录总数。保留随机种子不变时这两个参数越大内存占用越高运行时间越长。观察显存没有意义这个项目不需要 GPU。如果你用的是内存较小的云主机建议把数据规模控制在可接受范围避免 groupby 时发生内存溢出。也可以把 pandas 换成 DuckDB 或 Polars但没必要因为这是一个教学演示不是大数据处理系统。11. 常见问题与排查方法在运行复现脚本时最容易遇到下面这些问题。我把常见现象、可能原因和解决办法整理成表方便你快速排查。问题现象可能原因排查方式解决方案运行报错 ModuleNotFoundError缺少 pandas/numpy/matplotlib执行pip list查依赖安装对应依赖图能显示但两条曲线重合分母写错使用活跃用户做分母检查active_by_offset.div的分母确保全量留存率除以同期群总人数幸存者留存率出现 NaN 或 inf某天活跃用户数为 0打印previous_active查看空值用replace(0, np.nan)处理分母曲线太锯齿同期群数量少或用户量低增大COHORTS或USERS_PER_COHORT增加数据规模后重跑中文标签乱码matplotlib 默认字体不支持中文查看绘图像素使用英文标签或设置中文字体运行时间超过 12 秒数据规模过大或机器性能低用 time 命令分段测量缩小参数重试脚本无法 import文件命名冲突检查是否和库名重名改脚本文件名这里最需要关注的是第三行幸存者留存率出现 inf。原因是某一天没有活跃用户而第二天的活跃数除以 0就会得到无穷大。上面代码里用replace(0, np.nan)规避了这个问题。如果你在真实业务数据里看到留存率超过 100%也基本可以断定分母出现了类似问题。另一个常见问题是结果不符合直觉。有人把STAY_PROB设得很大比如 0.99然后发现全量留存率下降得非常慢幸存者留存率几乎等于 1。这个现象是正常的只不过会让偏差看起来不明显。要看出偏差最好让STAY_PROB保持在一个中低水平比如 0.5 到 0.7。12. 最佳实践与使用建议第一个建议是先在留存指标定义上统一口径。无论是“新增用户次日留存”“7 日留存”还是“30 日留存”分母都必须写清楚是“同期群全部用户”而不是“前一天活跃用户”。你可以把指标口径文档放到数据字典里让每次报表评审都先过一遍公式。第二个建议是把模拟实验引入到指标评审流程中。当你的留存数据出现异常波动时不要急着归因于产品功能先跑一遍上面的模拟脚本对照看看是不是口径变化导致的。很多时候变动不是产品引起的而是 SQL 里某个 where 条件把已流失用户过滤掉了。第三个建议是用同期群报表替代总留存率。总留存率会受新增结构影响比如某天投了大量低质量用户整体留存会被拉低但每个同期群内部可能没有变化。同期群报表可以保留每个批次的新增质量差异是发现幸存者偏差更有效的工具。第四个建议是增加一个“校验指标”。比如同时计算全量新增用户的“人均活跃天数”和“活跃用户中继续活跃比例”。如果两个指标方向相反说明保留在体系里的用户黏性越来越强但新增用户整体流失越来越快。这比单一留存率更能反映问题。第五个建议是涉及真实业务数据时严格遵守隐私和合规要求。用户行为数据、设备信息、渠道投放数据都属于敏感数据分析时要用脱敏后的 ID不要导出到个人电脑。留存分析虽然看起来只是算比例但背后是海量用户行为轨迹安全边界不能松懈。13. 总结这个 Show HN 项目最值得尝试的地方不是算法多难而是它用极低的成本把统计学偏差还原成一个可以亲手运行的实验。你先跑通上面的 Python 脚本看到全量留存率和幸存者留存率的分叉再回看自己的留存报表基本就能判断是否存在同样的口径问题。最先要验证的地方是两条曲线的分离程度。幸存者留存率越稳定、全量留存率衰减越快说明偏差越严重。最容易踩的坑是把分母写成前一天活跃用户导致留存率虚高其次是处理分母为 0 时产生 inf导致图表异常。后续可以继续扩展的方向包括用真实业务数据做同期群留存报表、把模拟脚本参数化到批量任务里、在 BI 工具里增加口径校验字段。每一个方向都值得单独写一篇实践记录。建议先把这段代码收藏起来下次分析留存指标时拿出来对照一下能少踩不少坑。