📖 本篇简介:本篇是案例系列的第十一篇(也是本批教程的收官篇),用一份模拟的同城配送订单表(5000 笔)走完物流运营分析全流程:配送时长分布探索 → 准时率口径与城市/网点对比 → 路线效率(距离 vs 时长、时效利用率)→ 异常件类型分布与异常率排行 → 延迟件根因初探(分组统计 + 相关系数)→ 区域与网点 Top/Bottom 榜。前置知识:第 13 章的 groupby、第 14 章的时间差、第 15 章的直方图与散点图。学完后你能独立产出一份"给站点运营看的配送质量日报"。
物流配送分析是 pandas 在 O2O / 零售行业最典型的落地场景。它的特殊性在于:"准时"是一个有明确业务口径的二分类问题——实际用时有没有超过承诺时效,直接决定用户投诉率。本篇会带你把"距离、天气、时段、网点"这四个因素和延迟的关系量化出来。
⚠️ 数据说明:本篇全部数据均为np.random模拟生成(固定种子20251007),模拟数据,结论仅供方法演示,不代表任何真实快递公司。
目录
1. 案例背景与目标
2. 数据准备:配送单表(模拟)
3. 数据清洗与字段衍生
4. 配送时长分布
5. 准时率:城市与网点对比
6. 路线效率:距离 vs 时长与时效利用率
7. 异常件分析:类型分布与异常率排行
8. 延迟件根因初探
9. 区域与网点排行(Top / Bottom)
10. 可视化报告
11. 结论与行动建议
12. 常见坑小结
13. 扩展练习
1. 案例背景与目标
背景:某同城即时配送平台在 6 个城市运营 13 个网点,30 天内产生 5000 笔配送订单。运营团队想知道:
1. 快不快:配送时长的分布长什么样?中位数多久?
2. 准不准:承诺时效内送达的比例(准时率)是多少?哪个城市/网点拖后腿?
3. 异常多不多:延迟、破损、取消、其他四类异常各占多少?
4. 为什么慢:延迟到底是距离远、天气差、还是高峰拥堵造成的?
分析目标:从配送单表出发,算出上述指标,输出网点调度和异常监控的改进建议。
先明确"准时"的口径:
- 实际用时 = 送达时间 − 接单时间(不包含用户下单后等接单的时间);
- 准时 = 实际用时 ≤ 承诺时效;
- 这个口径和"下单到送达总时长"不同——后者包含接单等待,会高估配送环节的责任。
2. 数据准备:配送单表(模拟)
构造 5000 笔订单,字段覆盖城市、网点、三个时间戳、承诺时效、距离、金额、天气、状态与异常原因。
import pandas as pd
import numpy as np
np.random.seed(20251007) # 固定种子,可复现
N = 5000
# 2.1 城市与网点(每城 2~3 个网点)
cities = ["北京", "上海", "广州", "深圳", "杭州", "成都"]
outlets = {
"北京": ["北京-朝阳站", "北京-海淀站", "北京-通州站"],
"上海": ["上海-浦东站", "上海-静安站"],
"广州": ["广州-天河站", "广州-越秀站", "广州-番禺站"],
"深圳": ["深圳-南山站", "深圳-福田站"],
"杭州": ["杭州-西湖站", "杭州-滨江站"],
"成都": ["成都-高新站", "成都-锦江站"],
}
city_w = [0.20, 0.18, 0.16, 0.16, 0.15, 0.15]
# 2.2 下单时间:30 天,早晚高峰时段更密
order_dates = pd.date_range("2025-04-01", periods=30, freq="D")
base_day = np.random.choice(order_dates, size=N)
hour_w = np.array([1,1,1,1,1,2,4,7,9,6,4,4,4,4,4,5,6,8,9,7,5,3,2,1])
hours = np.random.choice(np.arange(24), size=N, p=hour_w/hour_w.sum())
order_time = pd.to_datetime(base_day) + pd.to_timedelta(hours, unit="h") \
+ pd.to_timedelta(np.random.randint(0,60,N), unit="m")
# 2.3 城市、网点、距离、金额
city_arr = np.random.choice(cities, size=N, p=city_w)
outlet_arr = np.array([np.random.choice(outlets[c]) for c in city_arr])
distance = np.random.lognormal(mean=2.0, sigma=0.45, size=N).clip(1, 40)
order_amt = np.random.lognormal(mean=3.5, sigma=0.5, size=N).round(2)
# 2.4 天气(影响配送速度)
weather = np.random.choice(["晴","阴","小雨","大雨","雪"], size=N, p=[0.55,0.22,0.15,0.06,0.02])
w_factor = pd.Series(weather).map({"晴":1.0,"阴":1.05,"小雨":1.15,"大雨":1.4,"雪":1.55}).values
# 2.5 承诺时效(小时):1.2h 基础 + 每公里 0.075h,整体偏紧,让约 15% 超时
promise = np.clip(1.2 + distance*0.075 + np.random.normal(0,0.08,N), 0.8, None)
# 2.6 实际用时(接单->送达,小时):距离 + 固定装卸 + 高峰 + 天气 + 慢网点
peak = ((hours>=8)&(hours<=10)) | ((hours>=17)&(hours<=19))
slow = {"北京-通州站":1.25, "广州-番禺站":1.20, "深圳-福田站":1.10}
o_factor = np.array([slow.get(o, 1.0) for o in outlet_arr])
actual = (distance*0.085 + np.random.normal(0.35,0.15,N) + peak.astype(float)*0.25)
actual = np.clip(actual * w_factor * o_factor, 0.2, None)
# 2.7 接单/送达时间戳
accept_time = order_time + pd.to_timedelta(np.random.uniform(5,25,N)/60.0, unit="h")
deliver_time = accept_time + pd.to_timedelta(actual, unit="h")
# 2.8 状态与异常原因(延迟优先级最高)
on_time = actual <= promise
delay_flag = ~on_time
damage = np.random.random(N) < 0.015
cancel = np.random.random(N) < 0.010
other = np.random.random(N) < 0.008
reason = np.where(delay_flag,"延迟",
np.where(damage,"破损",
np.where(cancel,"取消",
np.where(other,"其他","正常"))))
df = pd.DataFrame({
"单号":[f"D{i:06d}" for i in range(N)],
"城市":city_arr, "网点":outlet_arr,
"下单时间":order_time, "接单时间":accept_time, "送达时间":deliver_time,
"承诺时效":promise.round(2), "距离":distance.round(2),
"订单金额":order_amt, "天气":weather,
"状态":np.where(reason=="正常","正常","异常"),
"异常原因":reason, "实际用时":actual.round(3),
})
df["小时"] = df["下单时间"].dt.hour
print("配送单表形状:", df.shape)
print(df.head(2).to_string(index=False))
配送单表形状: (5000, 14)
单号 城市 网点 下单时间 接单时间 送达时间 承诺时效 距离 订单金额 天气 状态 异常原因 实际用时 小时
D000000 北京 北京-海淀站 2025-04-24 16:15:00 2025-04-24 16:30:49.485132763 2025-04-24 17:46:21.350894473 1.85 8.79 17.79 晴 正常 正常 1.259 16
D000001 成都 成都-高新站 2025-04-21 08:01:00 2025-04-21 08:23:25.527553734 2025-04-21 09:58:02.559302107 2.00 10.61 56.44 小雨 正常 正常 1.577 8
字段说明:状态是粗粒度(正常/异常),异常原因是细粒度(延迟/破损/取消/其他/正常)。一笔单只要超时就算"延迟",哪怕它没破损没取消。
3. 数据清洗与字段衍生
# 3.1 三个时间戳必须是 datetime,否则后面减法报错
print("时间列 dtype:", df["下单时间"].dtype, df["送达时间"].dtype)
# 3.2 校验:实际用时是否 == 送达-接单(防止埋点错)
calc = (df["送达时间"] - df["接单时间"]).dt.total_seconds()/3600
print("埋点用时与重算用时最大偏差:", (calc - df["实际用时"]).abs().max().round(4))
# 3.3 准时标记(核心口径)
df["是否准时"] = (df["实际用时"] <= df["承诺时效"]).astype(int)
# 3.4 检查缺失与异常
print("缺失值合计:", df.isna().sum().sum())
print("距离<=0 的行:", (df["距离"]<=0).sum())
print("实际用时<=0 的行:", (df["实际用时"]<=0).sum())
时间列 dtype: datetime64[ns] datetime64[ns]
埋点用时与重算用时最大偏差: 0.0
缺失值合计: 0
距离<=0 的行: 0
实际用时<=0 的行: 0
⚠️ 常见坑:时间列如果读进来是字符串,送达-接单会报错或得到字符串相减。先pd.to_datetime()转类型,再做时间差。时间差用.dt.total_seconds()/3600转小时,不要直接取.seconds(它只取当天以内的秒数,跨天会错)。
4. 配送时长分布
先看"实际用时"这一列的整体分布——均值、中位数、分位数。
t = df["实际用时"]
dist = pd.DataFrame({
"指标": ["均值","中位数","P25","P75","P90","P95","最大值"],
"小时": [t.mean(), t.median(), t.quantile(.25), t.quantile(.75),
t.quantile(.90), t.quantile(.95), t.max()],
}).round(3)
print(dist.to_string(index=False))
指标 小时
均值 1.274
中位数 1.206
P25 0.953
P75 1.513
P90 1.869
P95 2.138
最大值 4.337
怎么读:中位数 1.21h < 均值 1.27h,说明分布右偏——大多数单子 1 小时多送到,但有少数长尾慢单(P95 2.14h、最大 4.34h)把均值拉高了。这种"长尾"正是物流优化要抓的对象:把最慢的那 5% 压下来,整体体验提升最明显。
⚠️ 常见坑:物流数据几乎总是右偏(长尾)。只看均值会被极端慢单误导,中位数和 P90/P95 才是更真实的体验指标。对外汇报建议同时给中位数和 P95。
5. 准时率:城市与网点对比
# 5.1 整体准时率
overall_ot = df["是否准时"].mean()
print(f"整体准时率: {overall_ot:.2%}")
# 5.2 按城市
city_ot = df.groupby("城市").agg(
单量=("单号","count"),
准时率=("是否准时","mean"),
平均用时=("实际用时","mean"),
).sort_values("准时率")
print("\n各城市准时率(升序):")
print(city_ot.round(3).to_string())
整体准时率: 95.32%
各城市准时率(升序):
单量 准时率 平均用时
城市
广州 772 0.927 1.328
北京 1039 0.929 1.309
深圳 795 0.950 1.297
杭州 777 0.967 1.244
上海 923 0.972 1.229
成都 694 0.983 1.230
城市准时率对比表:
| 城市 | 单量 | 准时率 | 平均用时(h) | 评价 |
|------|------|------|------|------|
| 广州 | 772 | 92.7% | 1.33 | 🔴 垫底 |
| 北京 | 1039 | 92.9% | 1.31 | 🔴 垫底 |
| 深圳 | 795 | 95.0% | 1.30 | 🟡 中等 |
| 杭州 | 777 | 96.7% | 1.24 | 🟢 良好 |
| 上海 | 923 | 97.2% | 1.23 | 🟢 良好 |
| 成都 | 694 | 98.3% | 1.23 | 🟢 最优 |
结论:广州、北京准时率明显低于其他城市(93% vs 97%+),且平均用时更长——这两个城市是优化重点。
# 5.3 按网点(城市+网点),找最差和最好
outlet_ot = df.groupby(["城市","网点"]).agg(
单量=("单号","count"),
准时率=("是否准时","mean"),
平均用时=("实际用时","mean"),
).sort_values("准时率")
print("最差 5 个网点:")
print(outlet_ot.head(5).round(3).to_string())
print("\n最好 5 个网点:")
print(outlet_ot.tail(5).round(3).to_string())
最差 5 个网点:
单量 准时率 平均用时
城市 网点
广州 广州-番禺站 230 0.826 1.514
北京 北京-通州站 324 0.830 1.507
深圳 深圳-福田站 393 0.921 1.363
杭州 杭州-西湖站 364 0.962 1.242
北京 北京-海淀站 369 0.967 1.229
最好 5 个网点:
单量 准时率 平均用时
城市 网点
上海 上海-静安站 454 0.974 1.245
深圳 深圳-南山站 402 0.978 1.232
成都 成都-高新站 332 0.979 1.265
北京 北京-朝阳站 346 0.980 1.208
成都 成都-锦江站 362 0.986 1.198
结论:广州-番禺站(82.6%)和北京-通州站(83.0%)是两个明显拖后腿的网点,平均用时 1.5h+,比最好的成都-锦江站(98.6%、1.20h)差出一大截。城市层面的差距,其实是被个别差网点拉低的。
6. 路线效率:距离 vs 时长与时效利用率
# 6.1 距离与实际用时的关系
corr_dist = df["距离"].corr(df["实际用时"])
print(f"距离 vs 实际用时 Pearson 相关系数: {corr_dist:.3f}")
# 6.2 时效利用率 = 承诺时效 / 实际用时(按题目指定口径)
# >1:实际用时比承诺窗口短,提前送达(效率高)
# <1:实际用时超过承诺窗口,超时(迟到)
df["时效利用率"] = df["承诺时效"] / df["实际用时"]
print(f"时效利用率 均值: {df['时效利用率'].mean():.3f} 中位数: {df['时效利用率'].median():.3f}")
print(f"超时(时效利用率<1)占比: {(df['时效利用率']<1).mean():.2%}")
距离 vs 实际用时 Pearson 相关系数: 0.802
时效利用率 均值: 1.555 中位数: 1.469
超时(时效利用率<1)占比: 4.68%
怎么读:
- 距离和实际用时相关系数 0.80,强正相关——距离确实是用时的主因;
- 时效利用率中位数 1.47,意味着"典型订单用了承诺时间的 1/1.47 ≈ 68%"就送到了,留有余量;
- 但仍有 4.68% 的单子时效利用率 < 1(超时),这些就是投诉高发单。
⚠️ 常见坑:题目指定的时效利用率是承诺时效/实际用时(>1 为好)。如果反过来定义成实际用时/承诺时效,结论方向就反了。写指标时务必把"大于 1 是好是坏"在标题里写清楚,否则报表读者会误读。
7. 异常件分析:类型分布与异常率排行
# 7.1 异常类型分布
reason_dist = df["异常原因"].value_counts()
reason_rate = (reason_dist / len(df) * 100).round(2)
tab = pd.DataFrame({"单数": reason_dist, "占比%": reason_rate})
print(tab.to_string())
print(f"\n异常件总数: {(df['状态']=='异常').sum()}, 异常率: {(df['状态']=='异常').mean():.2%}")
单数 占比%
异常原因
正常 4607 92.14
延迟 235 4.70
破损 68 1.36
取消 54 1.08
其他 36 0.72
异常件总数: 393, 异常率: 7.86%
异常类型分布表:
| 异常类型 | 单数 | 占全部订单 | 占异常件 |
|------|------|------|------|
| 延迟 | 235 | 4.70% | 59.8% |
| 破损 | 68 | 1.36% | 17.3% |
| 取消 | 54 | 1.08% | 13.7% |
| 其他 | 36 | 0.72% | 9.2% |
结论:异常件 393 单(异常率 7.86%),其中延迟一类就占了异常件的近 60%——抓异常主要就是抓延迟。
# 7.2 异常率按城市排序
city_abn = df.groupby("城市").apply(
lambda g: pd.Series({
"单量": len(g),
"异常率": (g["状态"]=="异常").mean(),
}), include_groups=False
).sort_values("异常率", ascending=False)
print(city_abn.round(3).to_string())
单量 异常率
城市
广州 772.0 0.101
北京 1039.0 0.099
深圳 795.0 0.078
杭州 777.0 0.068
上海 923.0 0.062
成都 694.0 0.058
结论:广州(10.1%)、北京(9.9%)异常率最高,和准时率垫底的城市完全重合——异常主要就是延迟,两件事互相印证。
💡 pandas 3.0 提示:groupby.apply在 3.0 默认会抛出关于"组列是否包含"的警告,上面加了include_groups=False显式关掉。等价写法也可以用groupby("城市").agg(异常率=("状态", lambda s:(s=="异常").mean()), 单量=("单号","count")),更简洁、性能更好。
8. 延迟件根因初探
把延迟件和正常件放在一起对比,看延迟到底和什么有关。
delay = df[df["异常原因"]=="延迟"]
normal = df[df["状态"]=="正常"]
print(f"平均距离: 延迟件 {delay['距离'].mean():.2f} km vs 正常件 {normal['距离'].mean():.2f} km")
print(f"平均用时: 延迟件 {delay['实际用时'].mean():.2f} h vs 正常件 {normal['实际用时'].mean():.2f} h")
print("\n延迟件天气分布(%):")
print((delay["天气"].value_counts(normalize=True)*100).round(1).to_string())
# 分时段准时率
print("\n分时段准时率:")
print(df.groupby("小时")["是否准时"].mean().round(3).to_string())
平均距离: 延迟件 11.13 km vs 正常件 8.04 km
平均用时: 延迟件 2.24 h vs 正常件 1.23 h
延迟件天气分布(%):
天气
大雨 41.7
小雨 19.6
雪 18.7
阴 10.6
晴 9.4
分时段准时率:
小时
0 0.981
8 0.907
9 0.920
10 0.903
17 0.941
18 0.922
19 0.932
20 0.988
延迟件 vs 正常件特征表:
| 特征 | 延迟件 | 正常件 | 差异 |
|------|------|------|------|
| 平均距离 | 11.13 km | 8.04 km | 远 39% |
| 平均用时 | 2.24 h | 1.23 h | 慢 82% |
| 坏天气占比(雨+雪) | 80.0% | — | 显著偏高 |
# 相关系数:量化各因素与延迟的关系
print("=== 相关系数 ===")
print(f"实际用时 ~ 距离: {df['实际用时'].corr(df['距离']):.3f}")
print(f"是否准时 ~ 距离: {df['是否准时'].corr(df['距离']):.3f}")
print(f"实际用时 ~ 下单小时: {df['实际用时'].corr(df['小时']):.3f}")
=== 相关系数 ===
实际用时 ~ 距离: 0.802
是否准时 ~ 距离: -0.168
实际用时 ~ 下单小时: 0.022
根因结论:
1. 距离是用时的最强解释变量(相关 0.80),距离越远越容易超时(准时率~距离 −0.17);
2. 坏天气是延迟的重要触发器——延迟件里 80% 发生在雨/雪天,而天气在全量里只占 ~23%;
3. 高峰时段(早 8~10 点准时率 90~92%、晚 17~19 点 92~94%)明显比深夜(98%+)差,但"下单小时"和用时的线性相关只有 0.02(因为高峰效应是脉冲式的,线性相关捕捉不到,要看分组对比)。
⚠️ 常见坑:线性相关系数低(0.02)不代表"时段不重要"——高峰效应是非线性脉冲,相关系数只能抓线性关系。遇到这种情况,要用分组均值(分时段准时率)而不是相关系数下结论。
9. 区域与网点排行(Top / Bottom)
把最差网点和最好网点放在一张表里,给运营一个直接的整改清单。
rank = df.groupby(["城市","网点"]).agg(
单量=("单号","count"),
准时率=("是否准时","mean"),
平均用时=("实际用时","mean"),
异常率=("状态", lambda s:(s=="异常").mean()),
).sort_values("准时率")
top_bottom = pd.concat([
rank.head(3).assign(梯队="🔴 待整改"),
rank.tail(3).assign(梯队="🟢 标杆"),
])
print(top_bottom.round(3).to_string())
单量 准时率 平均用时 异常率 梯队
城市 网点
广州 广州-番禺站 230 0.826 1.514 0.200 🔴 待整改
北京 北京-通州站 324 0.830 1.507 0.191 🔴 待整改
深圳 深圳-福田站 393 0.921 1.363 0.107 🔴 待整改
成都 成都-高新站 332 0.979 1.265 0.066 🟢 标杆
北京 北京-朝阳站 346 0.980 1.208 0.046 🟢 标杆
成都 成都-锦江站 362 0.986 1.198 0.039 🟢 标杆
一张表说清:同样是送单,成都-锦江站准时率 98.6%、异常率 3.9%;广州-番禺站准时率只有 82.6%、异常率高达 20.0%。两个站差出 16 个百分点——这就是调度优化的空间。
10. 可视化报告
import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"]
plt.rcParams["axes.unicode_minus"] = False
fig, axes = plt.subplots(2, 2, figsize=(14, 9))
# 1) 配送时长直方图
axes[0,0].hist(df["实际用时"], bins=50, color="#2E86AB")
axes[0,0].set_title("配送时长分布(小时)")
# 2) 距离 vs 实际用时散点
axes[0,1].scatter(df["距离"], df["实际用时"], s=5, alpha=0.3, color="#F18F01")
axes[0,1].set_xlabel("距离(km)"); axes[0,1].set_ylabel("实际用时(h)")
axes[0,1].set_title("距离 vs 实际用时")
# 3) 异常类型分布
reason_dist.drop("正常").plot(kind="bar", ax=axes[1,0], color="#C0392B")
axes[1,0].set_title("异常类型分布")
# 4) 各城市准时率
city_ot["准时率"].plot(kind="barh", ax=axes[1,1], color="#27AE60")
axes[1,1].set_title("各城市准时率")
plt.tight_layout()
plt.savefig("ch58_report.png", dpi=150) # 实际使用时存到临时目录
plt.show()
图上能看到:时长直方图明显右偏;距离-用时散点呈带状正相关;异常里延迟占绝对大头;城市准时率广州、北京明显落后。
11. 结论与行动建议
=== 结论(模拟数据,结论仅供方法演示) ===
1. 配送时长中位数 1.21h、均值 1.27h、P95 2.14h,右偏,长尾慢单是优化重点。
2. 整体准时率 95.32%;广州(92.7%)、北京(92.9%)垫底,被广州-番禺站(82.6%)、
北京-通州站(83.0%)两个差网点拉低。
3. 异常率 7.86%(393 单),其中延迟占异常件近 60%,是异常治理主战场。
4. 延迟根因:距离远(11.1 vs 8.0 km)、坏天气(雨雪占 80%)、高峰时段(8-10 点准时率<91%)。
5. 距离与实际用时强相关(0.80),是路线效率的第一解释变量。
=== 行动建议(面向运营) ===
1. 网点调度:重点整改广州-番禺站、北京-通州站——增配骑手、重划配送范围,
目标把准时率从 ~83% 拉回 95%;把成都-锦江站的排班/路线做法沉淀成 SOP 推广。
2. 异常监控:把"延迟率"按城市/网点做成日报看板,异常率周环比 >1pct 自动预警;
破损/取消占比小,单独做专项即可。
3. 高峰与天气预案:早高峰(8-10)、晚高峰(17-19)提前 30 分钟加派运力;
雨雪天对长距离单提前延长承诺时效或加收缓冲,降低超时投诉。
4. 长距离单:对 >10km 的订单单独评估时效承诺是否合理,必要时拆分或转次日达。
12. 常见坑小结
| 坑 | 错误做法 | 正确做法 |
|------|------|------|
| 时间差 | 直接 .seconds | .dt.total_seconds()/3600,跨天也对 |
| 时长分布 | 只看均值 | 看中位数 + P90/P95,右偏长尾要看尾 |
| 准时口径 | 用下单到送达总时长 | 用送达−接单(剔除接单等待) |
| 时效利用率 | 方向搞反 | 题目指定 承诺/实际,>1 为好,写清方向 |
| 时段效应 | 靠相关系数判断 | 相关抓不到脉冲,用分时段分组均值 |
| groupby.apply | 不管组列警告 | pandas 3.0 用 include_groups=False 或改 agg |
13. 扩展练习
1. 给"接单等待时长"(接单时间−下单时间)也做一个分布分析,看它对总时效投诉的贡献有多大。
2. 用 pd.cut 把距离分成 [0,5,10,20,40] 四档,算每档的准时率和平均用时,验证"距离越远越慢"。
3. 给天气做独热编码,用 df.groupby('天气')['是否准时'].mean() 量化每种天气的准时率损失。
4. 挑一个最差网点(广州-番禺站),拆解它的延迟到底来自距离、天气还是时段,和标杆网点对比。
5. 计算每个网点的"P95 配送时长"而非均值,用 P95 重新排一次网点榜,看排名是否变化。
6. 用 pivot_table 做"城市 × 时段"的准时率透视表,找出最差的交叉单元格。
下一篇预告:总目录 —— pandas 从入门到精通系列至此收官,回到总目录回顾全部章节与学习路径。
📌 再次提示:本篇全部为模拟数据,所有指标数字仅用于演示分析方法,真实业务请用自有数据重跑。
文章回复
0 条公开回复