Python 数据处理进阶(二十二)用 Pandas 做一次完整的用户行为分析

写在前面

上一篇讲相关性,这一篇做实战:用户行为分析。

这是数据分析岗最高频的题型之一,面试会问、工作会用。好消息是它不需要复杂算法------留存、复购、活跃度这些指标,本质都是「按用户分组 + 按时间分组 + 数人头」。用 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 把统计图做得更好看。

相关推荐
云和数据.ChenGuang1 小时前
再论深拷贝和浅拷贝
开发语言·人工智能·python·1024程序员节
第九序言T1 小时前
Python 数据处理进阶(二十一)相关性与分组对比:数据里藏着什么关系
大数据·人工智能·python·数据分析·pandas
Gent_倪1 小时前
Python 语句梳理
python
晚风予卿云月2 小时前
【C++高并发内存池】 2.ThreadCache设计
开发语言·c++·高并发内存池·tcmalloc
m0_738120722 小时前
JWT 安全从入门到实战:六种典型攻击场景解析
前端·网络·python·安全·网络安全
用户298698530142 小时前
Python 实现 HTML 转纯文本:两种场景下的文档模型方案
python·html·api
Dovis(誓平步青云)3 小时前
拖动关键帧角色就抽动,动画编辑器怎样补齐中间状态
开发语言·前端·javascript·机器学习·编辑器
小小张说故事3 小时前
Python 的赋值为什么不会复制对象?拆开引用、浅拷贝与深拷贝
后端·python
行走的陀螺仪3 小时前
Claude Code 从入门到实战(二):Bash工具、Plan模式与后台任务
开发语言·bash