简介:这份资源面向具备一定Python基础、希望入门机器学习实战的开发者与数据爱好者,围绕外卖送餐时间预测这一典型回归问题展开。它基于Kaggle公开数据集,涵盖地点、外卖员ID、评分等字段,通过计算经纬度距离挖掘特征间关系,并搭建LSTM神经网络完成送餐时长建模,帮助读者理解时间预估背后的机理。压缩包共2个文件,包含1个txt数据文件与1个py代码文件,整体约942KB,数据与实现脚本配套齐全,便于直接运行与调试。目前已有1055人学习下载,热度可观。读者可从中获得一套完整的特征工程与LSTM建模流程,包括经纬度距离计算、特征相关性分析、模型训练与预测评估等关键环节,适合作为课程设计、竞赛练手或自学项目的参考范例,快速掌握从数据到模型的落地思路。
1. 外卖送餐时间预测:从下单到送达,模型到底能算准几分钟
午高峰点一份三公里外的盖浇饭,App 显示 38 分钟送达,结果骑手 52 分钟才敲门。这个偏差背后,是平台调度系统里一个典型的回归预测问题:给定订单特征、商家特征、骑手特征和实时路况,预测从接单到送达的时长。用 Python 机器学习预测外卖送餐时间,本质是把这个问题拆成「特征工程 + 回归建模 + 误差分析」三件事,而不是套一个模型就完事。
这篇文章面向两类人:一是刚学完机器学习入门、想找一个有真实业务味道的数据集练手的开发者;二是已经在做本地生活、即时配送相关系统,想评估自建预测模块可行性的工程师。我会按「数据长什么样 → 特征怎么造 → 模型怎么选 → 误差怎么压」的顺序讲,中间给出可直接复现的 Python 代码和参数说明。读完之后,你应该能判断这个方向值不值得投入,以及第一版模型大概能做到什么水平。
2. 先搞清楚预测目标:送餐时间到底由哪几段构成
2.1 把「送餐时间」拆成可建模的三段
很多人一上来就把「送达时间 - 下单时间」当作标签,直接扔给模型。这样做不是不行,但误差会很大,因为这三段的时间消耗规律完全不同:
- 商家出餐段:从骑手到店到取餐完成。受菜品复杂度、午高峰排队、商家历史出餐速度影响。
- 骑手到店段:从接单到抵达商家。受骑手当前位置、接单时是否顺路、商家周边停车难度影响。
- 配送段:从取餐到送达用户。受距离、路况、楼层、是否放前台/快递柜影响。
常见做法是分别建模再相加,或者把三段特征全部喂给一个模型让它自己学。我一般会先做分段统计,看看哪一段的方差最大。经验上,午高峰的商家出餐段方差往往最大,能占到总时长波动的 40% 以上。如果只用一个模型硬拟合,模型会把出餐慢的锅甩给距离特征,导致特征重要性失真。
2.2 标签定义与数据字段的最小集合
假设你手头有一份订单流水数据,每条记录至少需要这些字段才能开始:
| 字段 | 类型 | 说明 |
|---|---|---|
| order_id | string | 订单唯一标识 |
| shop_id | string | 商家 ID |
| rider_id | string | 骑手 ID |
| order_time | datetime | 下单时间 |
| accept_time | datetime | 骑手接单时间 |
| pickup_time | datetime | 取餐时间 |
| deliver_time | datetime | 送达时间 |
| distance_km | float | 配送距离 |
| shop_lat / shop_lng | float | 商家经纬度 |
| user_lat / user_lng | float | 用户经纬度 |
标签建议定义为deliver_time - accept_time,单位分钟。为什么不从下单时间算起?因为下单到接单之间的等待时长主要由平台派单策略决定,跟骑手和商家关系不大,混进来会引入噪声。如果你的数据里没有 accept_time,那就只能用deliver_time - order_time,但要在特征里加入「派单等待时长」作为单独一列。
提示:标签单位统一用分钟,不要用秒。秒级精度对外卖场景没有意义,反而会让损失函数对异常值更敏感。
2.3 数据清洗的三个硬性检查
在写任何模型代码之前,先跑这三条检查,否则后面调参全是玄学:
import pandas as pd import numpy as np df = pd.read_csv("orders.csv", parse_dates=["order_time", "accept_time", "pickup_time", "deliver_time"]) # 检查1:时间顺序是否合理 bad_order = df[df["accept_time"] < df["order_time"]] print("接单早于下单的异常记录数:", len(bad_order)) # 检查2:标签分布是否有极端值 df["duration"] = (df["deliver_time"] - df["accept_time"]).dt.total_seconds() / 60 print(df["duration"].describe(percentiles=[0.01, 0.5, 0.99])) # 检查3:距离字段是否有缺失或零值 print("距离缺失数:", df["distance_km"].isna().sum()) print("距离为零的记录数:", (df["distance_km"] == 0).sum())逻辑说明:第一条检查时间戳的因果顺序,接单早于下单说明数据采集有 bug;第二条看标签的 1% 和 99% 分位数,超出这个范围的基本是异常单,建议截断或剔除;第三条检查距离字段,零距离通常意味着经纬度缺失或商家与用户地址相同,需要单独处理。
参数说明:percentiles里我习惯看 0.01 和 0.99,外卖场景下超过 120 分钟的订单大概率是异常单,可以直接过滤。如果你的数据量足够大,也可以保留但加一个is_outlier特征让模型自己判断。
3. 特征工程:把经纬度、时间和商家历史变成模型能吃的数字
3.1 时间特征:别只用一个「小时」字段
时间对送餐时长的影响是非线性的。中午 11:30 到 12:30 是一个尖峰,晚上 17:30 到 19:00 是另一个尖峰,但两个尖峰的形态不一样。如果只把「小时」作为数值特征,模型会认为 11 点和 12 点之间是线性过渡,这不符合实际。
我一般会构造这几列:
def build_time_features(df): df["hour"] = df["accept_time"].dt.hour df["minute_of_day"] = df["accept_time"].dt.hour * 60 + df["accept_time"].dt.minute df["weekday"] = df["accept_time"].dt.weekday df["is_weekend"] = (df["weekday"] >= 5).astype(int) # 用餐高峰标记 df["is_lunch_peak"] = ((df["hour"] >= 11) & (df["hour"] <= 13)).astype(int) df["is_dinner_peak"] = ((df["hour"] >= 17) & (df["hour"] <= 19)).astype(int) # 周期性编码:让 23 点和 0 点在特征空间里靠近 df["hour_sin"] = np.sin(2 * np.pi * df["minute_of_day"] / 1440) df["hour_cos"] = np.cos(2 * np.pi * df["minute_of_day"] / 1440) return df逻辑说明:minute_of_day把一天压成 0 到 1440 的连续值,比单独的 hour 更细;is_lunch_peak和is_dinner_peak是业务强特征,直接告诉模型这是高峰段;hour_sin和hour_cos是周期性编码,解决「23 点和 0 点实际很近但数值差很大」的问题。
参数说明:高峰时段的边界可以根据你所在城市的实际数据调整。我试过把午餐高峰放宽到 10:30 到 13:30,模型在 10:30 到 11:00 的样本上误差反而更小,因为很多商家 10:30 就开始备餐了。
3.2 空间特征:直线距离不够,要加路网系数
distance_km如果是直线距离,在跨江、跨铁路、单行道多的城市会严重低估实际骑行距离。一个实用的补救办法是加一个「路网绕行系数」特征:
# 基于历史订单统计每条路线的实际距离与直线距离之比 route_ratio = df.groupby(["shop_id", "user_id"]).apply( lambda g: g["actual_ride_km"].mean() / max(g["distance_km"].mean(), 0.1) ).reset_index(name="route_ratio") df = df.merge(route_ratio, on=["shop_id", "user_id"], how="left") df["route_ratio"] = df["route_ratio"].fillna(1.3) # 全局默认值 df["estimated_ride_km"] = df["distance_km"] * df["route_ratio"]逻辑说明:route_ratio是实际骑行距离与直线距离的比值,按「商家-用户」对统计。没有历史数据的对,用全局中位数或经验值 1.3 填充。estimated_ride_km比原始直线距离更接近真实骑行距离。
参数说明:fillna(1.3)里的 1.3 是我在几个二线城市数据上试出来的经验值,一线城市因为路网更密,这个值可能到 1.4 到 1.5。你可以先用全局中位数,等数据积累够了再按区域细化。
3.3 商家和骑手的历史统计特征
这是提升模型效果最明显的一类特征,但也是最容易踩「数据泄漏」坑的地方。核心原则:只能用「当前订单之前」的历史数据。
# 按时间排序后,用 expanding window 计算历史均值 df = df.sort_values("accept_time") # 商家历史平均出餐时长(只统计当前订单之前的) df["shop_avg_prep"] = df.groupby("shop_id")["prep_duration"].transform( lambda x: x.shift(1).expanding().mean() ) # 骑手历史平均配送速度(公里/分钟) df["rider_avg_speed"] = df.groupby("rider_id").apply( lambda g: (g["estimated_ride_km"] / g["ride_duration"]).shift(1).expanding().mean() ).reset_index(level=0, drop=True) # 商家当前待处理订单数(实时特征,需要从订单表关联) df["shop_pending_orders"] = df.groupby(["shop_id", "accept_time"]).cumcount()逻辑说明:shift(1)是关键,它保证当前订单不会用到自己的信息。expanding().mean()计算的是从第一条记录到当前记录之前的所有历史均值。shop_pending_orders用cumcount()模拟商家在接单时刻已经积压了多少单,这个特征对午高峰的预测非常有用。
参数说明:新商家和新骑手的历史特征会是 NaN,需要用全局均值填充。我一般会加一个is_new_shop和is_new_rider的标记特征,让模型知道这条记录的历史统计不可靠。
注意:如果你用
groupby().mean()而不是expanding().mean(),那就是用了全量数据算均值,属于典型的数据泄漏。离线评估指标会很好看,上线后直接翻车。
4. 模型选型与训练:从 LightGBM 基线到误差分层分析
4.1 为什么我首选 LightGBM 而不是深度学习
外卖送餐时间预测的数据结构是典型的「表格数据 + 混合类型特征 + 非线性关系」。在这个场景下,梯度提升树(GBDT)系列模型的表现通常优于神经网络,原因有三:第一,表格数据里特征交互往往是局部的、阈值型的,树模型天然擅长;第二,训练速度快,方便快速迭代特征;第三,特征重要性可解释,业务方容易接受。
LightGBM 相比 XGBoost 的优势在于直方图算法和 leaf-wise 生长策略,在几百万条订单数据上训练时间能控制在几分钟内。下面是一个可复现的训练脚本:
import lightgbm as lgb from sklearn.model_selection import TimeSeriesSplit from sklearn.metrics import mean_absolute_error, mean_squared_error feature_cols = [ "distance_km", "estimated_ride_km", "hour_sin", "hour_cos", "is_lunch_peak", "is_dinner_peak", "is_weekend", "shop_avg_prep", "rider_avg_speed", "shop_pending_orders", "is_new_shop", "is_new_rider" ] # 时间序列切分,不能用随机切分 tscv = TimeSeriesSplit(n_splits=5) for train_idx, val_idx in tscv.split(df): train, val = df.iloc[train_idx], df.iloc[val_idx] model = lgb.LGBMRegressor( n_estimators=800, learning_rate=0.05, max_depth=7, num_leaves=63, min_child_samples=50, subsample=0.8, colsample_bytree=0.8, reg_alpha=0.1, reg_lambda=0.1, random_state=42 ) model.fit( train[feature_cols], train["duration"], eval_set=[(val[feature_cols], val["duration"])], eval_metric="mae", callbacks=[lgb.early_stopping(50)] ) pred = model.predict(val[feature_cols]) print("MAE:", mean_absolute_error(val["duration"], pred)) print("RMSE:", np.sqrt(mean_squared_error(val["duration"], pred)))逻辑说明:TimeSeriesSplit保证训练集的时间早于验证集,模拟真实上线场景。early_stopping(50)在验证集 MAE 连续 50 轮不下降时停止训练,防止过拟合。评估指标同时看 MAE 和 RMSE,MAE 反映平均误差,RMSE 对大误差更敏感。
参数说明:max_depth=7和num_leaves=63是我在几十万条数据上的常用起点。数据量更大时可以适当增加num_leaves,但不要超过 127,否则容易过拟合。min_child_samples=50保证每个叶子节点至少有 50 个样本,对外卖这种噪声较大的数据很重要。learning_rate=0.05配合n_estimators=800是一个比较稳的组合,如果训练时间紧张可以调到 0.1 配 400 棵树。
4.2 误差分层:整体 MAE 好看不代表每个场景都准
整体 MAE 做到 6 分钟,不代表午高峰也能做到 6 分钟。我习惯按几个维度做误差分层:
| 分层维度 | 分组 | 典型 MAE 差异 |
|---|---|---|
| 时段 | 午高峰 vs 下午茶 | 高峰段 MAE 高 30%~50% |
| 距离 | 1km 内 vs 3km 以上 | 长距离 MAE 绝对值更大 |
| 商家类型 | 快餐 vs 正餐 | 正餐出餐波动大,MAE 更高 |
| 骑手经验 | 新手 vs 老手 | 新手配送段 MAE 高 20% 左右 |
val = val.copy() val["pred"] = pred val["abs_error"] = np.abs(val["duration"] - val["pred"]) # 按时段分层 print(val.groupby("is_lunch_peak")["abs_error"].mean()) # 按距离分层 val["dist_bucket"] = pd.cut(val["distance_km"], bins=[0, 1, 2, 3, 10]) print(val.groupby("dist_bucket")["abs_error"].mean())逻辑说明:分层之后你会发现,模型在午高峰的误差可能是平峰的两倍。这时候不要急着换模型,先看是特征不够还是数据本身噪声大。如果是商家出餐时间波动大,可以考虑单独给出餐段建一个模型,或者加入商家实时排队长度特征。
参数说明:pd.cut的分箱边界根据你的数据分布调整。我一般用 1km、2km、3km 作为分界,因为这三个距离对应的骑行时间大致是 5 分钟、10 分钟、15 分钟,业务上容易理解。
4.3 一个容易被忽略的评估细节:用「预测区间」代替点预测
业务方真正关心的不是「精确到 3 分钟」,而是「能不能在 40 分钟内送到」。所以除了点预测,还可以用分位数回归给出一个区间:
model_upper = lgb.LGBMRegressor( objective="quantile", alpha=0.9, # 90 分位数 n_estimators=800, learning_rate=0.05, max_depth=7, num_leaves=63 ) model_upper.fit(train[feature_cols], train["duration"]) upper_bound = model_upper.predict(val[feature_cols]) # 检查实际值落在预测上界内的比例 coverage = (val["duration"] <= upper_bound).mean() print("90% 预测区间覆盖率:", coverage)逻辑说明:objective="quantile"让模型预测条件分位数而不是均值。alpha=0.9表示预测 90 分位数,即「有 90% 的订单实际时长不超过这个值」。覆盖率应该接近 0.9,如果明显偏低说明模型低估了尾部风险。
参数说明:alpha可以根据业务需求调整。如果平台想承诺「95% 订单准时送达」,就用alpha=0.95。注意分位数回归的训练时间比普通回归略长,因为损失函数不同。
5. 避坑与排查:送餐时间预测里最容易翻车的五件事
5.1 用随机切分代替时间切分,离线指标虚高
现象:离线 MAE 做到 4.5 分钟,上线后实际误差 9 分钟以上。
原因:随机切分让训练集里包含了验证集之后的时间段,模型「偷看」了未来信息。尤其是商家历史均值和骑手历史速度这两个特征,随机切分下它们是用全量数据算的,严重泄漏。
解决:所有涉及历史统计的特征,必须用expanding().mean()配合shift(1);数据切分必须用TimeSeriesSplit或按时间点硬切。切分后检查训练集的最大时间是否小于验证集的最小时间。
5.2 把「下单到接单」的等待时间混进标签
现象:模型在派单慢的区域误差特别大,特征重要性里「距离」排第一但业务上说不通。
原因:标签用了deliver_time - order_time,包含了平台派单等待时间。这个时间跟骑手和商家无关,纯粹是调度策略的产物,模型学到的其实是「哪些区域派单慢」。
解决:标签改用deliver_time - accept_time。如果数据里没有 accept_time,就把「派单等待时长」作为单独一列特征,并在评估时单独看这部分误差。
5.3 新商家和新骑手的历史特征用全局均值填充后没有标记
现象:新商家订单的预测误差是老商家的两倍以上。
原因:新商家的shop_avg_prep是 NaN,填充了全局均值,但模型不知道这个值是「猜的」。模型会把新商家当成一个「出餐速度中等」的商家,而实际上新商家的出餐速度方差极大。
解决:加is_new_shop和is_new_rider两个二值特征。更进一步,可以对新商家单独建一个模型,或者用商家品类、菜品数量等静态特征做冷启动预测。
5.4 忽略了天气和节假日特征
现象:雨天和节假日的 MAE 比平时高 50% 以上。
原因:特征里只有时间和空间信息,没有天气和节假日。雨天骑行速度下降、节假日商家出餐变慢,这些是系统性偏差,模型无法从现有特征里学到。
解决:接入天气 API,至少加入「是否下雨」「温度」「风力」三个字段。节假日用一个is_holiday标记,或者用「距离最近节日的天数」做连续特征。如果拿不到实时天气,至少把「月份」和「是否周末」加进去做粗略代理。
5.5 模型上线后不做在线监控,误差漂移了才发现
现象:上线第一个月 MAE 6 分钟,第三个月变成 9 分钟,业务方投诉才知道。
原因:商家出餐速度会随季节变化,骑手队伍会流动,路网会施工。模型训练完就固定了,但数据分布一直在变。
解决:上线后按天计算 MAE 和预测偏差(预测值均值减实际值均值),设置告警阈值。我一般会监控两个指标:滚动 7 天 MAE 超过基线 20% 时告警;预测偏差连续 3 天为正或为负时告警。模型至少每月重训一次,大促前单独重训。
6. 把模型推到可用的最后一公里:分位数校准与在线学习节奏
第一版模型能做到整体 MAE 6 到 8 分钟,已经可以辅助调度了。但如果要直接给用户展示「预计送达时间」,还需要做一件事:分位数校准。因为用户看到的承诺时间应该是「大概率能送到」的时间,而不是平均时间。
具体做法是:用验证集计算不同分位数下的实际覆盖率,然后对预测值做一个单调映射。比如模型预测的 80 分位数实际只覆盖了 70% 的订单,那就把预测值整体上调一点。这个校准表可以按城市、按时段分别做,粒度越细越准,但要注意样本量不能太少。
# 分位数校准示例:按小时计算校准系数 val["hour"] = val["accept_time"].dt.hour calibration = [] for hour, group in val.groupby("hour"): if len(group) < 200: continue # 找到使覆盖率接近 0.8 的缩放系数 for scale in np.arange(0.8, 1.5, 0.02): coverage = (group["duration"] <= group["pred_upper"] * scale).mean() if coverage >= 0.8: calibration.append({"hour": hour, "scale": scale}) break cal_df = pd.DataFrame(calibration) print(cal_df)逻辑说明:对每个小时,找到一个缩放系数,使得「实际时长不超过预测上界乘以系数」的比例达到 80%。这个系数通常大于 1,因为分位数回归在尾部容易低估。校准后的预测上界更接近真实承诺时间。
参数说明:len(group) < 200是样本量保护,样本太少的小时不做单独校准,直接用全局系数。np.arange(0.8, 1.5, 0.02)是搜索范围,如果你的模型尾部低估严重,可以把上限调到 2.0。
在线学习的节奏上,我的习惯是:每天增量收集新订单,每周做一次小规模重训(只更新最近一个月数据),每月做一次全量重训。重训后先在影子模式跑三天,对比新旧模型的 MAE 和覆盖率,确认没有退化再切流量。这套流程跑顺之后,模型误差能稳定控制在 5 到 7 分钟,午高峰不超过 10 分钟。
最后说一个我踩过的坑:不要试图把 MAE 压到 3 分钟以内。外卖场景里商家出餐时间的随机性太强,同一个商家同一道菜,不同厨师做出来的时间能差 5 分钟。模型能做的只是把系统性偏差消掉,剩下的随机噪声是消不掉的。接受这个边界,把精力放在特征质量和监控上,比死磕模型结构划算得多。希望帮到你。
本文还有配套的精品资源,点击获取