写在前面
上一篇讲相关性,这一篇做实战:用户行为分析。
这是数据分析岗最高频的题型之一,面试会问、工作会用。好消息是它不需要复杂算法------留存、复购、活跃度这些指标,本质都是「按用户分组 + 按时间分组 + 数人头」。用 Pandas 的 groupby 和 pivot_table 就能全部算出来。
先说清楚:本文数据是模拟的(2000 个用户、5000 笔订单),但口径和真实项目一致。你把数据换成自己的,代码不用改。
先确认示例数据在手边:
python
import os
import mkdata
if not os.path.exists("data/sales.csv"):
mkdata.main()
print("示例数据就绪:", os.path.exists("data/sales.csv"))
这段会检查第 1 篇生成的 data/ 目录在不在,不在就自动补一次;已经有数据时只会打印一行 示例数据就绪: True,不影响后面的结果。
一、造一份用户订单数据
真实数据长这样:一行一笔订单,有用户 ID、下单时间、金额。这里用随机数模拟,注意要让一部分用户重复下单,否则复购率永远是 0。
python
import os
import numpy as np
import pandas as pd
rng = np.random.default_rng(11)
n_users, n_orders = 2000, 5000
# 让一部分用户多下单:按幂律权重抽用户
weights = 1 / (np.arange(1, n_users + 1) ** 0.8)
weights = weights / weights.sum()
users = rng.choice(np.arange(1, n_users + 1), n_orders, p=weights)
months = rng.choice(
pd.period_range("2024-01", "2024-06", freq="M"),
n_orders,
p=[0.28, 0.22, 0.18, 0.13, 0.11, 0.08],
)
df = pd.DataFrame(
{
"用户ID": [f"U{u:04d}" for u in users],
"月份": months.astype(str),
"金额": rng.gamma(2, 180, n_orders).round(2),
}
)
os.makedirs("data", exist_ok=True)
df.to_csv("data/user_orders.csv", index=False, encoding="utf-8-sig")
print("订单数:", len(df))
print("用户数:", df["用户ID"].nunique())
print("时间范围:", df["月份"].min(), "~", df["月份"].max())
print(df.head(5).to_string(index=False))
text
订单数: 5000
用户数: 1385
时间范围: 2024-01 ~ 2024-06
用户ID 月份 金额
U0005 2024-02 268.69
U0151 2024-01 452.95
U0288 2024-02 290.66
U0001 2024-04 1517.78
U0006 2024-01 245.99
这里用「幂律权重」抽用户,是为了让少数用户下很多单------真实业务里就是这样,头部用户贡献大头,这直接决定了后面「头部贡献」那个指标长什么样。
二、四个基础指标:先有人,再有钱
python
import pandas as pd
df = pd.read_csv("data/user_orders.csv", encoding="utf-8-sig")
total_orders = len(df)
total_users = df["用户ID"].nunique()
total_amount = df["金额"].sum()
per_user = df.groupby("用户ID").agg(订单数=("金额", "size"), 金额=("金额", "sum"))
repeat_users = int((per_user["订单数"] >= 2).sum())
print(f"总订单 {total_orders} 笔 | 总用户 {total_users} 人 | 总金额 {total_amount:,.2f} 元")
print(f"人均订单 {total_orders / total_users:.2f} 笔 | 客单价 {df['金额'].mean():.2f} 元")
print(f"复购用户 {repeat_users} 人,复购率 {repeat_users / total_users:.1%}")
print("----")
print("订单数分布(每个用户下了几单):")
print(per_user["订单数"].value_counts().sort_index().to_string())
text
总订单 5000 笔 | 总用户 1385 人 | 总金额 1,786,477.76 元
人均订单 3.61 笔 | 客单价 357.30 元
复购用户 806 人,复购率 58.2%
----
订单数分布(每个用户下了几单):
订单数
1 579
2 344
3 180
4 84
5 47
6 30
7 17
8 19
9 17
10 3
11 6
12 6
13 5
14 6
15 3
16 3
17 1
18 5
19 2
20 3
22 1
23 1
24 1
26 3
27 1
28 2
34 1
35 2
37 1
38 1
39 1
40 1
50 1
54 1
57 1
58 1
80 1
102 1
105 1
156 1
253 1
口径陷阱:复购率至少有两种算法------「下过 ≥2 单的用户数 / 总用户数」(上面用的)和「复购订单数 / 总订单数」。前者看用户质量,后者看订单结构,写报告时必须注明用的是哪种,否则两个人会拿不同数字吵架。
三、月活与新增:按时间看盘子大小
python
import pandas as pd
df = pd.read_csv("data/user_orders.csv", encoding="utf-8-sig")
# 每个用户的首单月份 = 新增月份
df["首单月"] = df.groupby("用户ID")["月份"].transform("min")
mau = df.groupby("月份")["用户ID"].nunique().rename("月活用户")
new_users = df.drop_duplicates("用户ID").groupby("首单月")["用户ID"].nunique().rename("新增用户")
summary = pd.concat([mau, new_users], axis=1).fillna(0).astype(int)
summary["新增占比(%)"] = (summary["新增用户"] / summary["月活用户"] * 100).round(1)
print(summary.to_string())
print("----")
print("月活峰值:", summary["月活用户"].idxmax(), summary["月活用户"].max(), "人")
text
月活用户 新增用户 新增占比(%)
2024-01 724 724 100.0
2024-02 563 266 47.2
2024-03 488 159 32.6
2024-04 382 102 26.7
2024-05 346 85 24.6
2024-06 258 49 19.0
----
月活峰值: 2024-01 724 人
transform("min") 是这里的关键:它把每个用户的首单月份「贴回」每一行,不打乱行数。换成 groupby().min() 会得到一张只有用户和月份的表,再 merge 就多一步。
四、留存矩阵:同批用户,后面还在不在
留存是用户分析的灵魂。定义:某月首单的用户,在第 N 个月还下过单的比例。
python
import numpy as np
import pandas as pd
df = pd.read_csv("data/user_orders.csv", encoding="utf-8-sig")
df["首单月"] = df.groupby("用户ID")["月份"].transform("min")
months = sorted(df["月份"].unique())
retention = pd.DataFrame(index=months, columns=months, dtype=float)
for first in months:
cohort = df[df["首单月"] == first]
base = cohort["用户ID"].nunique()
for m in months:
if months.index(m) < months.index(first):
continue # 首单之前的月份留空
alive = cohort[cohort["月份"] == m]["用户ID"].nunique()
retention.loc[first, m] = round(alive / base * 100, 1) if base else np.nan
print("留存矩阵(%,行=首单月,列=该月是否仍活跃):")
print(retention.round(1).to_string())
print("----")
# 次月留存:首单月的下一个月
second = [retention.loc[m, months[months.index(m) + 1]]
for m in months[:-1] if months.index(m) + 1 < len(months)]
print("平均次月留存:", round(np.nanmean([s for s in second if s == s]), 1), "%")
text
留存矩阵(%,行=首单月,列=该月是否仍活跃):
2024-01 2024-02 2024-03 2024-04 2024-05 2024-06
2024-01 100.0 41.0 34.8 27.9 22.7 19.9
2024-02 NaN 100.0 28.9 18.0 22.9 13.5
2024-03 NaN NaN 100.0 18.9 14.5 8.2
2024-04 NaN NaN NaN 100.0 12.7 11.8
2024-05 NaN NaN NaN NaN 100.0 4.7
2024-06 NaN NaN NaN NaN NaN 100.0
----
平均次月留存: 21.2 %

图 1:首单月分组留存矩阵。左下半边是空白(首单之前的月份没有意义),越往右越淡,就是留存随时间的自然衰减。
看矩阵的方式:每一行是一批用户,对角线附近最亮(首月 100%),往右越来越淡,说明留存随时间衰减。右上角是空白,因为「首单在 6 月的用户」没有 7 月数据------这是留存矩阵常见的「三角缺失」,别当成丢数据。
五、头部用户贡献:20% 的人贡献多少
python
import pandas as pd
df = pd.read_csv("data/user_orders.csv", encoding="utf-8-sig")
per_user = df.groupby("用户ID")["金额"].sum().sort_values(ascending=False)
total = per_user.sum()
for pct in [0.05, 0.10, 0.20]:
k = max(int(len(per_user) * pct), 1)
share = per_user.head(k).sum() / total * 100
print(f"Top {pct:.0%} 用户({k} 人)贡献 {share:.1f}% 的金额")
print("----")
print("金额分位数(元):")
print(per_user.quantile([0.5, 0.9, 0.99]).round(2).to_string())
text
Top 5% 用户(69 人)贡献 40.9% 的金额
Top 10% 用户(138 人)贡献 51.4% 的金额
Top 20% 用户(277 人)贡献 64.7% 的金额
----
金额分位数(元):
0.50 649.55
0.90 2237.11
0.99 12040.60
如果 Top 10% 贡献了 50% 以上,说明业务对头部依赖重,运营策略就该是「重点维护头部 + 拉新补长尾」;如果分布很均匀,则说明没有明显大客户,适合走规模化投放。
六、一手结论:把数字翻译成人话
python
import pandas as pd
df = pd.read_csv("data/user_orders.csv", encoding="utf-8-sig")
per_user = df.groupby("用户ID").agg(订单数=("金额", "size"), 金额=("金额", "sum"))
total, users = df["金额"].sum(), df["用户ID"].nunique()
repeat = int((per_user["订单数"] >= 2).sum())
top10_share = per_user["金额"].nlargest(max(int(len(per_user) * 0.1), 1)).sum() / total * 100
month_amt = df.groupby("月份")["金额"].sum()
print("=" * 50)
print("用户行为分析小结(2024-01 ~ 2024-06)")
print("=" * 50)
print(f"1. 平台累计 {users} 个用户、{len(df)} 笔订单,总金额 {total:,.2f} 元。")
print(f"2. 复购用户 {repeat} 人({repeat / users:.1%}),人均 {len(df) / users:.2f} 单、{total / users:,.2f} 元。")
print(f"3. 客单价 {df['金额'].mean():.2f} 元;金额最高的月份是 {month_amt.idxmax()}({month_amt.max():,.2f} 元)。")
print(f"4. 头部 10% 用户贡献了 {top10_share:.1f}% 的金额,运营应重点维护。")
print("=" * 50)
print("下一步建议:对下过 2 单以上的用户做二次触达,对首单未复购的用户发券召回。")
text
==================================================
用户行为分析小结(2024-01 ~ 2024-06)
==================================================
1. 平台累计 1385 个用户、5000 笔订单,总金额 1,786,477.76 元。
2. 复购用户 806 人(58.2%),人均 3.61 单、1,289.88 元。
3. 客单价 357.30 元;金额最高的月份是 2024-01(523,672.34 元)。
4. 头部 10% 用户贡献了 51.4% 的金额,运营应重点维护。
==================================================
下一步建议:对下过 2 单以上的用户做二次触达,对首单未复购的用户发券召回。
这段 f-string 就是交付物:所有数字都是算出来的,没有一个是手写的,数据一换重跑就对。
常见问题
Q1:复购率算出来是 0。
用户 ID 全是唯一值(一人一单),或者你按「订单 ID」分组建了表。复购必须按用户 聚合,先 groupby("用户ID").size() 看有没有人下单 ≥2 次。
Q2:留存矩阵里有一堆 NaN,是不是数据缺失?
不是。是「首单在最近月份的用户还没来得及观察后续月份」,属于结构性缺失,报告里注明样本区间即可。
Q3:月活和新增加起来对不上?
月活是「当月下过单的去重人数」,新增是「首单在当月的人数」,新增的人可能当月就重复下单,两者不是简单可加关系。别硬凑。
小结
用户行为分析的骨架是四步:按用户聚合 (groupby("用户ID"))拿到人均指标、按时间聚合 (月活、新增)、按批次交叉 (留存矩阵,用 transform("min") 打首单月)、看结构(头部贡献、分位数)。指标定义比代码重要:复购率、留存、客单价都有多种口径,报告里写清楚,比算得漂亮更有价值。
下一篇换工具:同样是把结果讲给人听,这次用 Seaborn 把统计图做得更好看。