📖 本篇简介:本篇用一份模拟的电商订单数据,完整演示经营分析的标准流程:模拟两表数据(orders + customers)→ 缺失/异常/类型清洗 → RFM 用户分层 → 月/周几/一天中的时段分析 → 品类×区域透视 → 复购率与首次回购间隔 → 高价值用户画像对比。前置知识:第 15 章分组聚合、第 17 章透视表、第 21 章时间序列重采样。学完后你能独立给一份订单数据做经营复盘并输出可落地的运营动作。
上一篇我们用真实的泰坦尼克号数据练了"缺失值处理 + 统计检验"。本篇换一个业务场景——电商经营分析,重点是多表关联、RFM 分层口径、时间维度拆解、复购分析。所有数字都由固定随机种子的模拟代码实跑得到。
目录
1. 案例背景与分析目标
2. 数据准备(模拟)
3. 数据清洗
4. 经营全景指标
5. RFM 用户分层
6. 时段分析:月 / 周几 / 一天中的时段
7. 品类 × 区域分析
8. 复购分析
9. 高价值用户画像对比
10. 可视化报告
11. 结论与行动建议
12. 常见坑
13. 扩展练习
1. 案例背景与分析目标
背景:某电商平台 2025 年全年运营数据。运营经理想知道:钱从哪来、什么时候来、谁在买、谁值得重点投入。
数据为模拟数据(如实说明):本篇为演示方便,用 np.random 固定种子构造数据,结构参考 Kaggle 电商订单类数据集(orders 订单表 + customers 用户表)。所有结论数字均由该模拟代码实跑得到,仅供方法演示,不代表任何真实公司。
分析目标:
1. 全年 GMV、订单量、客单价是多少?
2. 用 RFM 把用户分层,谁是重要价值客户、谁在流失?
3. 销售在月/周几/一天中的时段上有什么规律?
4. 哪个品类、哪个区域贡献最大?
5. 复购率多少?首购到复购要多久?高价值用户长什么样?
2. 数据准备(模拟)
我们构造两张表:orders(订单表,一行一笔订单)和 customers(用户维表,一行一个用户),用 customer_id 关联。
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"]
plt.rcParams["axes.unicode_minus"] = False
rng = np.random.default_rng(2025) # 固定种子,保证每次运行数字一致
N_CUST = 2000
# ---- 用户维表 ----
customers = pd.DataFrame({
"customer_id": np.arange(1, N_CUST + 1),
"gender": rng.choice(["男", "女"], N_CUST, p=[0.45, 0.55]),
"city_tier": rng.choice(["一线", "新一线", "二线", "三线及以下"], N_CUST,
p=[0.25, 0.30, 0.28, 0.17]),
"signup_date": rng.choice(pd.date_range("2023-01-01", "2024-12-31"), N_CUST),
})
# ---- 订单表:每个用户下单次数呈长尾(多数买1-3单,少数高频)----
counts = rng.choice([1, 2, 3, 4, 5, 8, 12, 20, 40], N_CUST,
p=[0.45, 0.18, 0.12, 0.08, 0.06, 0.04, 0.03, 0.02, 0.02])
cust_col = np.repeat(np.arange(1, N_CUST + 1), counts)
N_ORD = len(cust_col)
# 下单时间:2025 全年;时段加权(午间12点、晚间20-22点为高峰)
day_offsets = rng.integers(0, 365, N_ORD)
hour_w = np.array([1, .6, .4, .3, .3, .4, .6, .9, 1.1, 1.2, 1.3, 1.6,
2.0, 1.3, 1.2, 1.2, 1.1, 1.3, 1.6, 1.8, 2.2, 2.4, 2.0, 1.4])
hours = rng.choice(np.arange(24), N_ORD, p=hour_w / hour_w.sum())
minutes = rng.integers(0, 60, N_ORD)
base = pd.Timestamp("2025-01-01")
ord_time = (base + pd.to_timedelta(day_offsets, unit="D")
+ pd.to_timedelta(hours, unit="h")
+ pd.to_timedelta(minutes, unit="m"))
orders = pd.DataFrame({
"order_id": np.arange(1, N_ORD + 1),
"customer_id": cust_col,
"order_time": ord_time,
"category": rng.choice(["数码", "服装", "食品", "家居", "美妆"], N_ORD,
p=[0.28, 0.27, 0.20, 0.13, 0.12]),
"region": rng.choice(["华东", "华南", "华北", "西南", "华中"], N_ORD,
p=[0.32, 0.24, 0.20, 0.12, 0.12]),
"quantity": rng.integers(1, 5, N_ORD),
"unit_price": rng.choice([59, 99, 159, 259, 399, 699, 999], N_ORD),
})
# 故意制造脏数据:少量缺失、负单价异常
dirty = rng.choice(N_ORD, 40, replace=False)
orders.loc[dirty[:20], "region"] = np.nan
orders.loc[dirty[20:30], "category"] = np.nan
orders.loc[dirty[30:35], "quantity"] = np.nan
orders.loc[dirty[35:], "unit_price"] = -50
orders["amount"] = orders["quantity"] * orders["unit_price"]
print("orders:", orders.shape, " customers:", customers.shape)
print(orders.head(3).to_string())
orders: (7915, 8) customers: (2000, 4)
order_id customer_id order_time category region quantity unit_price amount
0 1 1 2025-10-30 08:08:00 数码 华中 4.0 259 1036.0
1 2 1 2025-07-29 13:11:00 数码 华北 4.0 259 1036.0
2 3 1 2025-07-11 16:29:00 服装 华东 3.0 699 2097.0
字段说明:
| 表 | 字段 | 含义 |
|----|------|------|
| orders | order_id | 订单号(主键) |
| orders | customer_id | 下单用户(关联 customers) |
| orders | order_time | 下单时间(精确到分钟) |
| orders | category / region | 品类 / 区域 |
| orders | quantity / unit_price | 数量 / 单价 |
| orders | amount | 金额 = 数量 × 单价 |
| customers | gender / city_tier / signup_date | 用户性别 / 城市线级 / 注册日期 |
3. 数据清洗
先看脏数据长什么样,再对症下药。
print(orders.isna().sum())
print("金额<=0 的异常订单数:", (orders["amount"] <= 0).sum())
order_id 0
customer_id 0
order_time 0
category 10
region 20
quantity 5
unit_price 0
amount 5
dtype: int64
金额<=0 的异常订单数: 5
before = len(orders)
# 1) 时间转 datetime、数量转数值
orders["order_time"] = pd.to_datetime(orders["order_time"])
orders["quantity"] = pd.to_numeric(orders["quantity"], errors="coerce")
# 2) 异常值:单价必须为正,负单价(-50)直接整行剔除
orders = orders[orders["unit_price"] > 0]
# 3) 缺失值:类别列填"未知",数量填中位数
orders["region"] = orders["region"].fillna("未知")
orders["category"] = orders["category"].fillna("未知品类")
orders["quantity"] = orders["quantity"].fillna(orders["quantity"].median())
# 4) 清洗后重算金额(数量/单价都变了)
orders["amount"] = orders["quantity"] * orders["unit_price"]
# 5) 按订单号去重
orders = orders.drop_duplicates(subset=["order_id"]).reset_index(drop=True)
print(f"清洗 {before} 行 -> {len(orders)} 行")
# 关联用户维表,得到一张宽表
df = orders.merge(customers, on="customer_id", how="left")
print("合并后宽表:", df.shape)
清洗 7915 行 -> 7910 行
合并后宽表: (7910, 11)
⚠️ 常见坑:清洗顺序很关键。先剔异常(负单价)再重算金额——如果先填缺失再剔除,行数对不上;amount 必须在数量/单价最终确定后最后重算一遍,否则金额列还是脏的。4. 经营全景指标
gmv = df["amount"].sum()
n_orders = df["order_id"].nunique()
n_users = df["customer_id"].nunique()
aov = gmv / n_orders # 客单价 = GMV / 订单数
print(f"全年 GMV : {gmv:,.0f} 元")
print(f"订单数 : {n_orders:,}")
print(f"下单用户数 : {n_users:,}")
print(f"客单价 : {aov:.1f} 元")
全年 GMV : 7,479,834 元
订单数 : 7,910
下单用户数 : 2,000
客单价 : 945.6 元
一句话:全年约 748 万 GMV、7910 单、2000 人下单、客单价约 946 元。
5. RFM 用户分层
RFM 是经典客户价值模型,三个维度:
| 维度 | 含义 | 好坏方向 |
|------|------|----------|
| R(Recency) | 最近一次购买距基准日多少天 | 越小越好(刚买过) |
| F(Frequency) | 统计期内下单次数 | 越大越好 |
| M(Monetary) | 统计期内累计消费金额 | 越大越好 |
口径要先讲清:基准日取数据里最大订单日 +1 天(模拟"分析当天");F 按订单数计;M 按累计金额计。
ref = df["order_time"].max() + pd.Timedelta(days=1)
rfm = df.groupby("customer_id").agg(
R=("order_time", lambda x: (ref - x.max()).days), # 最近购买距今天数
F=("order_id", "count"), # 下单次数
M=("amount", "sum"), # 累计金额
)
print(rfm.describe().round(1))
R F M
count 2000.0 2000.0 2000.0
mean 127.6 4.0 3740.0
std 102.1 6.7 6606.8
min 1.0 1.0 59.0
25% 37.8 1.0 577.0
50% 104.0 2.0 1663.5
75% 200.0 4.0 3996.0
max 365.0 40.0 51970.0
中位数 F 只有 2 单、M 只有 1663 元,但最大值到 51970 元——典型长尾。下面用五分位给 R/F/M 各打 1~5 分:
# R 越小越好:按 R 从小到大,前 20%(最近购买)给 5 分
rfm["R分"] = pd.qcut(rfm["R"], 5, labels=[5, 4, 3, 2, 1]).astype(int)
# F、M 越大越好;用 rank(method="first") 避免并列值导致 qcut 分箱报错
rfm["F分"] = pd.qcut(rfm["F"].rank(method="first"), 5, labels=[1,2,3,4,5]).astype(int)
rfm["M分"] = pd.qcut(rfm["M"].rank(method="first"), 5, labels=[1,2,3,4,5]).astype(int)
# 简化分层规则
def seg(r):
if r["R分"] >= 4 and r["F分"] >= 4 and r["M分"] >= 4: return "重要价值"
if r["R分"] >= 4 and r["F分"] <= 2: return "新客/重要发展"
if r["R分"] <= 2 and r["F分"] >= 4 and r["M分"] >= 4: return "重要挽留"
if r["R分"] <= 2 and r["F分"] <= 2 and r["M分"] <= 2: return "流失"
return "一般"
rfm["segment"] = rfm.apply(seg, axis=1)
ss = rfm.groupby("segment").agg(人数=("M", "count"), 总金额=("M", "sum"))
ss["人数占比%"] = (ss["人数"] / ss["人数"].sum() * 100).round(1)
ss["金额占比%"] = (ss["总金额"] / ss["总金额"].sum() * 100).round(1)
print(ss.sort_values("总金额", ascending=False).to_string())
人数 总金额 人数占比% 金额占比%
segment
重要价值 408 4744301.0 20.4 63.4
一般 987 2095085.0 49.4 28.0
重要挽留 74 324272.0 3.7 4.3
流失 362 161313.0 18.1 2.2
新客/重要发展 169 154863.0 8.5 2.1
解读:重要价值客户只占 20.4%,却贡献了 63.4% 的金额——非常符合二八定律;流失客户占 18.1% 却只贡献 2.2%。
⚠️ 常见坑:F(下单次数)经常出现大量并列值(很多人都买 1 次、2 次),直接pd.qcut(rfm["F"], 5)会因为"分位数边界重叠"报错。解决办法是先.rank(method="first")打散并列再分箱。
6. 时段分析:月 / 周几 / 一天中的时段
从时间戳里拆出月、周几、小时三个维度。
df["month"] = df["order_time"].dt.month
df["weekday"] = df["order_time"].dt.dayofweek # 0=周一
df["hour"] = df["order_time"].dt.hour
6.1 月度趋势
mon = df.groupby("month").agg(GMV=("amount", "sum"), 订单数=("order_id", "count"))
print(mon.to_string())
GMV 订单数
month
1 605694 684
2 626750 600
3 613288 668
4 639396 687
5 611181 670
6 652102 681
7 644968 688
8 625530 660
9 620170 645
10 611205 681
11 608168 593
12 621382 653
全年各月 GMV 在 60 万~65 万之间,非常平稳、没有明显大促峰(因为模拟数据是均匀采样的)。真实数据里这里通常会看到 618、双11 的尖峰。
6.2 周几效应
wd = df.groupby("weekday")["amount"].sum()
wd.index = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
print(wd.round(0).to_string())
周一 1087923.0
周二 1060933.0
周三 1090348.0
周四 1072216.0
周五 974282.0
周六 1125694.0
周日 1068438.0
周六 GMV 最高(约 112.6 万),周五最低(约 97.4 万)——周末下单更活跃,可把投放/push 排到周四晚、周五,为周六高峰蓄水。
6.3 一天中的时段
hr = df.groupby("hour")["order_id"].count()
print(hr.to_string())
hour
0 265
1 169
2 112
3 85
4 74
5 128
6 194
7 266
8 309
9 330
10 366
11 408
12 531
13 358
14 331
15 306
16 306
17 346
18 437
19 489
20 546
21 666
22 541
23 347
双峰明显:午间 12 点(531 单)和晚间 20~22 点(546/666/541 单),其中 21 点是全天最高峰(666 单);凌晨 3~4 点最低(70~85 单)。推送、直播、优惠券投放应集中在这两个高峰前。
7. 品类 × 区域分析
用透视表看"哪个品类在哪个区域卖得最好"。
pt = df.pivot_table(index="category", columns="region", values="amount",
aggfunc="sum", margins=True, margins_name="合计")
print(pt.round(0).to_string())
region 华东 华中 华北 华南 未知 西南 合计
category
家居 336949.0 120497.0 205873.0 236266.0 3996.0 96832.0 1000413.0
数码 670027.0 214288.0 375842.0 513346.0 10320.0 259112.0 2042935.0
服装 664186.0 231431.0 437944.0 515890.0 1989.0 196142.0 2047582.0
未知品类 118.0 4095.0 7989.0 518.0 NaN 2097.0 14817.0
美妆 269162.0 103361.0 152813.0 234488.0 1449.0 107370.0 868643.0
食品 491930.0 173322.0 281982.0 381853.0 2632.0 173725.0 1505444.0
合计 2432372.0 846994.0 1462443.0 1882361.0 20386.0 835278.0 7479834.0
按合计列看:
| 品类 | GMV(元) | 区域 | GMV(元) |
|------|-----------|------|-----------|
| 服装 | 2,047,582 | 华东 | 2,432,372 |
| 数码 | 2,042,935 | 华南 | 1,882,361 |
| 食品 | 1,505,444 | 华北 | 1,462,443 |
| 家居 | 1,000,413 | 华中 | 846,994 |
| 美妆 | 868,643 | 西南 | 835,278 |
服装与数码并列第一梯队(各约 204 万),华东是最大区域(约 243 万,占 32.5%)。注意"未知/未知品类"列就是清洗时填的缺失值,占比极小(约 0.3%),可忽略。
8. 复购分析
复购是电商生命力的核心。这里回答三个问题:多少人买了第二次?第一次到第二次隔多久?
# 8.1 复购率:下单 >=2 次的用户占比
uo = df.groupby("customer_id")["order_id"].count()
print(f"复购率(>=2单): {(uo >= 2).mean():.1%}")
print(f"人均下单次数: {uo.mean():.2f},中位数: {uo.median()}")
复购率(>=2单): 55.7%
人均下单次数: 3.96,中位数: 2
# 8.2 相邻两次订单的间隔(天)
g = df.sort_values(["customer_id", "order_time"]).copy()
g["prev_time"] = g.groupby("customer_id")["order_time"].shift(1)
g["gap_days"] = (g["order_time"] - g["prev_time"]).dt.total_seconds() / 86400
# 8.3 每个有复购用户的【首购→次购】间隔
first_rebuy = g.groupby("customer_id")["gap_days"].first().dropna()
print(f"有复购行为的用户数: {len(first_rebuy)}")
print(f"首购→次购间隔: 均值 {first_rebuy.mean():.1f} 天,中位数 {first_rebuy.median():.1f} 天")
有复购行为的用户数: 1115
首购→次购间隔: 均值 80.8 天,中位数 61.2 天
解读:复购率 55.7%,超过一半用户买过第二次;但首购到次购中位数要 61 天——意味着新客在首次购买后 2 个月左右才回来,这个窗口期(首购后 0~30 天)正是push/券召回的黄金时机。
9. 高价值用户画像对比
把消费 Top 20% 的用户和其余用户对比,看他们在性别、城市线级上有什么不同。
ms = rfm["M"].sort_values(ascending=False)
top_n = int(len(ms) * 0.2)
top_ids = set(ms.index[:top_n])
rfm["is_top"] = rfm.index.isin(top_ids)
rc = rfm.join(customers.set_index("customer_id")[["gender", "city_tier"]])
print(rc.groupby("is_top")["M"].agg(["count", "mean", "sum"]).round(0))
count mean sum
is_top
False 1600 1509.0 2414256.0
True 400 12664.0 5065578.0
Top 20% 高价值用户仅 400 人,人均消费 12664 元,贡献 506 万 GMV(占总额 67.7%);其余 1600 人人均仅 1509 元。
再看他们的属性构成:
| 维度 | Top 20% 高价值用户 | 全部用户 |
|------|--------------------|----------|
| 女 / 男 | 57% / 43% | 55.8% / 44.2% |
| 新一线 | 29.0% | 31.4% |
| 二线 | 28.2% | 28.8% |
| 一线 | 24.2% | 23.4% |
| 三线及以下 | 18.5% | 16.4% |
解读:高价值用户的性别分布与整体几乎一致(女性略多),但三线及以下城市占比(18.5%)高于整体(16.4%),一线占比也略高——高价值用户在高线级和下沉市场两端略集中。(注:模拟数据,差异不大,真实数据中往往更显著。)
10. 可视化报告
图保存到系统临时目录,不污染教程工程目录。
import tempfile, os
outdir = tempfile.gettempdir()
fig, axes = plt.subplots(2, 2, figsize=(13, 9))
# 1) 月度 GMV
mon["GMV"].plot(marker="o", ax=axes[0,0], color="#2E86AB", title="月度 GMV")
# 2) 时段订单数(一天24小时)
hr.plot(kind="bar", ax=axes[0,1], color="#F18F01", title="一天中各小时订单量")
# 3) RFM 分层人数
ss["人数"].sort_values().plot(kind="barh", ax=axes[1,0], color="coral",
title="RFM 用户分层人数")
# 4) 品类 GMV
cat_gmv = df.groupby("category")["amount"].sum().sort_values()
cat_gmv.plot(kind="barh", ax=axes[1,1], color="#3CB371", title="品类 GMV")
plt.tight_layout()
plt.savefig(os.path.join(outdir, "ecom_report.png"), dpi=120)
plt.show()
print("图已保存到:", os.path.join(outdir, "ecom_report.png"))
11. 结论与行动建议
=== 结论(基于模拟数据,仅供方法演示)===
1. 全年 GMV 约 748 万,订单 7910、用户 2000、客单价约 946 元;
2. RFM 分层:重要价值客户占 20.4% 却贡献 63.4% 金额,流失客户占 18.1% 仅贡献 2.2%;
3. 时段:周六 GMV 最高;一天中 12 点与 20-22 点双峰,21 点为全天最高峰;
4. 品类:服装、数码并列第一梯队;区域:华东贡献最大(约 32.5%);
5. 复购率 55.7%,首购→次购中位数间隔约 61 天;
6. Top20% 用户贡献 67.7% GMV,高价值用户在一线与下沉两端略集中。
=== 行动建议 ===
1. 对"重要价值客户"(408 人)做 VIP 权益、专属客服,稳住 63% 的基本盘;
2. 对"流失客户"(362 人)做召回券 + 上新推送,这是最大的可挽回池子;
3. 抓住 21 点晚高峰与周六,把 push/直播投放排在周四晚、周五;
4. 新客首购后 0~30 天是召回黄金窗口,自动发"第二单立减"缩短 61 天的复购间隔;
5. 华东 + 服装/数码是基本盘,可向西南、美妆做增量测试。
注意:本篇全部为模拟数据,结论仅供方法演示。真实项目中请用真实订单数据替换第 2 节的模拟部分,并对关键差异补显著性检验。
12. 常见坑
⚠️ 坑 1:清洗顺序错导致金额对不上。 必须是"剔异常 → 填缺失 → 最后重算 amount"。先算金额再填数量,金额列就是脏的。
⚠️ 坑 2:`pd.qcut` 给 F 打分时报"Bin edges must be unique"。 下单次数大量并列(很多人都是 1 单)。解法:先 series.rank(method="first") 打散并列再 qcut。⚠️ 坑 3:R 的打分方向搞反。 R 是"距今天数",越小越好,所以分箱标签要[5,4,3,2,1](最近购买=5 分);F/M 越大越好,标签是[1,2,3,4,5]。
⚠️ 坑 4:把"复购率"口径说错。 本篇复购率 = 下单≥2 次的用户数 / 总下单用户数,是用户口径;别和"复购订单占比"(订单口径)混为一谈。
⚠️ 坑 5:模拟数据忘了固定种子。 不写 np.random.default_rng(seed),每次跑数字都变,文章里的结论表就对不上了。13. 扩展练习
1. 把 RFM 的五分位打分改成业务规则(如 R<30 天直接 5 分、F≥5 直接 5 分),对比分层人数分布的差异。
2. 计算各品类的品类复购率(同一用户在同一品类买过 2 次及以上的比例),找出最"粘"的品类。
3. 做"月份 × 品类"的 GMV 热力图,观察有没有季节性。
4. 用 scipy 检验"周六 GMV 是否显著高于工作日"(可对每天的 GMV 做两组 t 检验)。
5. 给"新客/重要发展"和"流失"两类用户各写一条具体的召回策略文案与触发条件。
6. 把本篇的模拟 + 清洗 + RFM 封装成函数,改种子后重跑,观察分层人数是否稳定。
下一篇预告:第 55 章 pandas 2.x 新特性与 PyArrow 后端 —— 认识 pandas 2.x 起的 PyArrow 后端、字符串 dtype 与 Copy-on-Write 等影响日常写法的重要变化。
文章回复
0 条公开回复