大家好,我是“蒋点数分”,多年以来一直从事数据分析工作。从今天开始,与大家持续分享关于数据分析的学习内容。
本文是第 4 篇,也是【数分基本功】系列的第 1 篇。该系列会讲一些数据分析的基本问题,必要时增加拓展和深入。对 SQL 感兴趣的同学,可以看看我的【SQL 周周练】系列(已发布 3 篇),保证都是有挑战性有意思的 SQL 题目。后续创作的内容,初步规划的方向包括:
后续内容规划
1.利用 Streamlit 实现 Hive 元数据展示
、SQL 编辑器
、 结合Docker 沙箱实现数据分析 Agent
2.时间序列异常识别、异动归因算法
3.留存率拟合、预测、建模
4.学习 AB 实验
、复杂实验设计等
5.自动化机器学习
、自动化特征工程
6.因果推断
学习
7. ……
留存率的不足
一、留存率的基本概念
1. 留存率如何定义与计算
这里谈谈关于留存率的定义,如果你已经非常熟悉,可以跳过(直接要看留存率曲线的缺陷,请跳转到第三节)
留存率曲线是用的“某一日留存”,这个“留存”是根据你想要分析的内容来定的。如果用户某一日做出了我们想要的行为,那就可以认定用户该日留存。这就是留存率的分子,而分母就是第一天满足我们要求的用户数或设备数。
比如大家经常关心的日活跃用户 DAU
,这个活跃的标准一般比较低,你打开/进入/登录等,只要你进来了,就算上你这个用户或设备。实际执行的时候,必然牵扯到启动接口上报(冷启动、热启动、切桌面、杀后台…),也有人会结合启动接口和几个覆盖面广的埋点来统计,避免遗漏数据。
如果你关心的是某个功能或者某个业务,你还可以将统计条件设置为用户要使用 App 的某个功能或者进入某个业务的页面,这也是一种留存。
本文谈的是最常见的留存,每日新增用户 DNU
后续的留存。用户从某一天首次进入这个 App,并且被我们统计上了(设备维度或账号维度);留存率曲线用的是“第 x 日留存”,比如“第 7 日留存”,也就是第 7 天用户还来这个 App,才算数。否则用户就是第 3~6 天,每天都来,那也跟“第 7 日留存”无关。
2. 为什么用“第 7 日留存”而不是“7 日内留存”
为什么有些人更愿意选择“7 日内留存”?我一直在互联网工作,有些部门扛着这些指标。甚至我第一次用 SQL 求留存率的时候,我也觉得——“哎,要是用户第 3~6 天来了,第 7 天没有来,这个第 7 日留存率的指标不把人家算上,不是太可惜了。”
但如果计算留存用了“7 日内留存”,那么“30 天”、“365 天“ 怎么办?比如用户第二天出现了一次,但是后面一年都不再出现了,“365 天”的留存我们也把这个用户算入。周期越长,这个指标就越虚增,这怎么行。
3. 留存率用来分析做什么
最关键的,“留存率曲线”后面都跟着几个话题:第一是 DAU 预测,DAU 就需要当天的“点”数据,来个“7 日内”这咋办。第二就是 LT 用户生命周期,它其实就是留存曲线下面的面积/积分(说累加也行,感觉积分拽一点),同样要求留存率是“点”计算。第三就是 LTV 用户生命周期价值:不限制天数,就是用户全生命周期价值;限制天数,就是某个时段内的用户生命周期价值。LT
都依赖于点计算,而 LTV
更是如此了。
我们本质上更关注那三个指标(DAU
、LT
、LTV
;特别是 LTV
和 CAC
的比值,可以视为投放推广、营销运营活动的 ROI
),这就反向限制了新增用户留存率的计算逻辑。
二、留存率曲线的拟合方法
1. 到底是用指数函数还是幂函数
指数函数的表达式为 a ⋅ e − b x a \cdot e^{-bx} a⋅e−bx
幂函数的表达式为 a ⋅ x − b a \cdot x^{-b} a⋅x−b
这两个函数的差异在于:衰减的速度不一样。把留存率 retention rate
简写为
R
R
R,指数函数是
R
e
(
t
)
=
a
⋅
e
−
b
t
R_e(t) = a \cdot e^{-bt}
Re(t)=a⋅e−bt;幂函数是
R
p
(
t
)
=
a
⋅
t
−
b
R_p(t) = a \cdot t^{-b}
Rp(t)=a⋅t−b。咱不用微分也不用差分,就直接来个
t
0
t_0
t0 和
t
0
+
Δ
t
t_0 + \Delta{t}
t0+Δt,代入公式除除看:
R e ( t 0 + Δ t ) R e ( t 0 ) = a ⋅ e − b ( t 0 + Δ t ) a ⋅ e − b t 0 = e − b Δ t \frac{R_e(t_0 + \Delta{t})}{R_e(t_0)} = \frac{a \cdot e^{-b(t_0 + \Delta{t})}}{a \cdot e^{-bt_0}} = e^{-b \Delta{t} } Re(t0)Re(t0+Δt)=a⋅e−bt0a⋅e−b(t0+Δt)=e−bΔt
可以看出指数函数的“衰减”,即后面某一天 t 0 + Δ t t_0 + \Delta{t} t0+Δt 相对于前面某一天 t 0 t_0 t0 的比值,与 t 0 t_0 t0 的取值无关,只和日期的间隔 Δ t \Delta{t} Δt 有关。
这也就意味着。第 2 天到第 8 天流失的比例,和第 302 天到第 308 天流失的比例是一样的。直觉上怀疑,因为后者三百多天还来了;如果不是因为老用户召回等情况,那么这个用户感觉挺稳定的(也需要看您公司的业务类型)如果是低频业务:C 端用户搬家、汽车保养、旅游、买车买房,那这种流失情况有可能出现。对于中高频的 App 应该不会如此。
再来看看幂函数:
R p ( t 0 + Δ t ) R p ( t 0 ) = a ⋅ ( t 0 + Δ t ) − b a ⋅ t 0 − b = ( 1 + Δ t t 0 ) − b \frac{R_p(t_0 + \Delta{t})}{R_p(t_0)} = \frac{a \cdot (t_0 + \Delta{t})^{-b}}{a \cdot {t_0}^{-b}} = (1 + \frac{\Delta{t}}{t_0})^{-b} Rp(t0)Rp(t0+Δt)=a⋅t0−ba⋅(t0+Δt)−b=(1+t0Δt)−b
可以看出幂函数的“衰减”,与日期间隔 Δ t \Delta{t} Δt 和起始的天数 t 0 t_0 t0 都有关;如果 t 0 t_0 t0 越大,这个衰减越小。越往后流失速度越慢,这个感觉好一些。
2. 用 Python 来验证拟合效果
最核心的函数是 scipy
库的 optimize
下面的 curve_fit
;具体计算原理,感兴趣的同学请自行搜索。
a.我们先定义指数函数和幂函数的 Python 函数,然后使用 curve_fit
来获取参数,scipy
的文档链接,我已经在代码中给出。因为我手里缺乏实际可靠的留存率数据,我们就用 “40-20-10” 来拟合,都说它 Facebook/Meta 给的留存率标准 —— 次日留存 40%,七日留存 20%,30 日留存 10%。
请注意:实际拟合留存率时,有很多细节需要考虑。包括长期拟合的情况,以及喂给模型多少天的数据,这部分细节可以见我给出的参考链接 2 。
import numpy as np
from scipy.optimize import curve_fit
# 定义指数函数形式留存率函数
def exponential_ret_rate_func(t, a, b):
return a * np.exp(-b * t)
# 定义幂函数形式留存率函数
def power_ret_rate_func(t, a, b):
return a * np.power(t, -b)
# facebook 提出那个 40-20-10 留存率
days = [2, 7, 30]
actual_ret_rate = [0.4, 0.2, 0.1]
# 不加范围,会提示 warning;虽然不影响结果
# https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.curve_fit.html#scipy.optimize.curve_fit
exp_ret_arg, _ = curve_fit(
exponential_ret_rate_func,
days,
actual_ret_rate,
bounds=([-np.inf, 0], [np.inf, np.inf]),
)
# 幂函数参数
pow_ret_arg, _ = curve_fit(power_ret_rate_func, days, actual_ret_rate)
all_days = np.arange(2, 31)
exp_ret_rate_arr = exponential_ret_rate_func(all_days, *exp_ret_arg)
pow_ret_rate_arr = power_ret_rate_func(all_days, *pow_ret_arg)
b.采用 RMSE
来对比一下,拟合的结果与我们给出的三个留存率的差异:
# 求求拟合的函数,与之前给出的 40-20-10 留存率差异
# 取 RMSE,np.array(days) - 2 注意起始点的序号映射关系
rmse_exponential = np.sqrt(np.mean((exp_ret_rate_arr[np.array(days) - 2] - actual_ret_rate) ** 2))
rmse_power = np.sqrt(np.mean((pow_ret_rate_arr[np.array(days) - 2] - actual_ret_rate) ** 2))
print(f"指数函数拟合的 RMSE:{rmse_exponential:.4f}")
print(f"幂函数拟合的 RMSE:{rmse_power:.4f}")
输出的结果为:
指数函数拟合的 RMSE:0.0477
幂函数拟合的 RMSE:0.0043
(幂函数比指数函数好一个量级)
c.用 pyvchart
将两条留存率曲线绘制出来,它是字节跳动开源的 vchart
的 Python 包。你也可以使用 pyecharts
来绘制,我一般更喜欢这种动态图表
from pyvchart import render_chart
retent_data = [
{"days": int(d), "retent_rate": float(round(r, 4)), "retent_func_type": "指数函数"}
for d, r in zip(all_days, exp_ret_rate_arr)
]
retent_data.extend([
{ "days": int(d),
"retent_rate": float(round(r, 4)),
"retent_func_type": "幂函数",
}
for d, r in zip(all_days, pow_ret_rate_arr)
])
spec = {
"type": "line",
"data": [{"id": "lineData", "values": retent_data}],
"xField": "days",
"yField": "retent_rate",
"seriesField": "retent_func_type",
"title": {"visible": True, "text": "指数函数和幂函数拟合留存率效果"},
}
# 在 jupyter 环境,使用 display 显示
display(render_chart(spec))
看一下拟合的曲线,的确是幂函数形式更加适合。后续我们采用幂函数
d.我们来绘制出一张特别经典的图,每日的 DAU
其实是由历史上每一天的新增用户的每日留存构成的。这里为了代码模拟简化,假定从 2025-05-01 开始每天的新增用户都是 10000,留存率曲线每一天都用上面拟合的幂函数留存率。
pow_ret_people_num = np.round(10000 * pow_ret_rate_arr)
import datetime
start_date = datetime.date(2025, 5, 1)
all_days_formatted = [start_date+datetime.timedelta(days=int(d)-1) for d in all_days]
dau_detail = []
dau_list = []
for idx, d in enumerate(all_days_formatted):
dau = 0
d = d.strftime('%y-%m-%d')
for i in range(idx+1):
dau += int(pow_ret_people_num[idx-i])
dau_detail.append({
'date': d,
'group': int(all_days[i]),
'people_num': int(pow_ret_people_num[idx-i])
})
dau_list.append({'date': d, 'dau_num': dau})
spec = {
"type": "line",
"data": [{"id": "dau_detail_data", "values": dau_detail},
{"id": "dau_data", "values": dau_list}],
"series": [
{
"type": "area",
"dataId": "dau_detail_data",
"xField": "date",
"yField": "people_num",
"seriesField": "group",
"stack": True,
"point": {"visible": False},
},
{
"type": "line",
"dataId": "dau_data",
"xField": "date",
"yField": "dau_num",
}
],
"title": {"visible": True, "text": "DAU 是由历史的每日新增用户每一日留存构成的"},
}
# 在 jupyter 环境,使用 display 显示
display(render_chart(spec))
可能很多同学觉得这图太乱了,但是我想这幅图表达的意思还是很清楚的。而且这幅图有实际意义的,比如分析时,可以把新老用户拆开。针对老用户,不一定按照新增日期;完全可以根据其他不会改变的维度值,并且把这些维度值归为少数几类。这样再画出这幅图,用来分析老用户留存,这就是一个不错的展示工具。
三、留存率曲线的不足
我用上面的同样的一条幂函数留存率,绘制出两种不一样的用户活跃情况(用 50 个用户做可视化模拟)。
a.第一种用户活跃情况,注意数据集结果写入剪贴板(方便我贴到 WPS 中,我使用的 Ubuntu 没有微软 Office)。要分开运行,后面代码也有写入剪贴板
import pandas as pd
user_retention_num = np.round(50*exp_ret_rate_arr,0)
first_user_retention_user_tag = np.zeros((29,50))
for i, num in enumerate(user_retention_num):
first_user_retention_user_tag[i][:int(num)] = 1
# to_clioboard 函数是写入剪贴板,注意要分开运行
# 后续的写入剪贴板会覆盖这部分
pd.DataFrame(first_user_retention_user_tag.T).to_clipboard(index=False, header=False)
第一种活跃情况,看上去是非常极端。
b.第二种用户活跃情况
np.random.seed(2025)
second_user_retention_user_tag = np.zeros((29,50))
for i, num in enumerate(user_retention_num):
idx = np.random.choice(50, size=int(num), replace=False)
second_user_retention_user_tag[i][idx] = 1
df = pd.DataFrame(second_user_retention_user_tag.T)
df.to_clipboard(index=False, header=False)
参考这两张图,大家应该都能看出来问题。可能有人觉得第二张图有点乱,是我故意混淆大家的视觉吧。那么我借鉴 RFM
的思路,根据用户最后一次活跃距今多少天以及总活跃天数来排序。
c.实现根据用户最后一次活跃距今多少天以及总活跃天数来排序的逻辑:
df_sort = pd.DataFrame()
df_sort['last_retent'] = df.apply(lambda row: row[row==1].index[-1] if any(row) else -1, axis=1)
df_sort['retent_days'] = df.apply(lambda row: sum(row), axis=1)
sort_order = df_sort.sort_values(by=['last_retent', 'retent_days'], ascending=[False, False]).index
df.loc[sort_order,:].to_clipboard(index=False, header=False)
此处再对比,应该明显多了。如果我们把最后一次活跃。当成一种“包络线”来看,后者的情况要比第一种极端情况好得多。但是两者的留存率曲线是一样。图片表格最上面的红色数字,就是公式,对表格里整列的 1 求和;可以佐证每日留存用户数是一样的。
也就是说留存率一致其实只能说明每日新增的用户后续“活跃的人*天”是一致的,用户的活跃分布,甚至是真正留下的用户数量并不一致。第一种活跃情况,现实情况不会这么极端,但是我最开始用 SQL 计算留存率时,的确隐隐感觉一种不对劲。
一般情况下,不需要什么调整。如果需要新的指标辅助,可以增加新增用户平均活跃天数或流失用户比例等。
四、参考资料
本文关于指数函数和幂函数的启发来自于青十五
1.青十五——《LTV预估与留存曲线拟合:指数函数还是幂函数?》
该文章提到了拟合留存率的一些细节
2.黎湘艳——《Python数据分析实战(四):收入、活跃预测》
😃😃😃
我现在正在求职数据类工作(主要是数据分析或数据科学);如果您有合适的机会,即时到岗,不限城市。