
简介这份资源面向具备一定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_idstring订单唯一标识shop_idstring商家 IDrider_idstring骑手 IDorder_timedatetime下单时间accept_timedatetime骑手接单时间pickup_timedatetime取餐时间deliver_timedatetime送达时间distance_kmfloat配送距离shop_lat / shop_lngfloat商家经纬度user_lat / user_lngfloat用户经纬度标签建议定义为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(nameroute_ratio) df df.merge(route_ratio, on[shop_id, user_id], howleft) 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(level0, dropTrue) # 商家当前待处理订单数实时特征需要从订单表关联 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_splits5) for train_idx, val_idx in tscv.split(df): train, val df.iloc[train_idx], df.iloc[val_idx] model lgb.LGBMRegressor( n_estimators800, learning_rate0.05, max_depth7, num_leaves63, min_child_samples50, subsample0.8, colsample_bytree0.8, reg_alpha0.1, reg_lambda0.1, random_state42 ) model.fit( train[feature_cols], train[duration], eval_set[(val[feature_cols], val[duration])], eval_metricmae, 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 和 RMSEMAE 反映平均误差RMSE 对大误差更敏感。参数说明max_depth7和num_leaves63是我在几十万条数据上的常用起点。数据量更大时可以适当增加num_leaves但不要超过 127否则容易过拟合。min_child_samples50保证每个叶子节点至少有 50 个样本对外卖这种噪声较大的数据很重要。learning_rate0.05配合n_estimators800是一个比较稳的组合如果训练时间紧张可以调到 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( objectivequantile, alpha0.9, # 90 分位数 n_estimators800, learning_rate0.05, max_depth7, num_leaves63 ) 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)逻辑说明objectivequantile让模型预测条件分位数而不是均值。alpha0.9表示预测 90 分位数即「有 90% 的订单实际时长不超过这个值」。覆盖率应该接近 0.9如果明显偏低说明模型低估了尾部风险。参数说明alpha可以根据业务需求调整。如果平台想承诺「95% 订单准时送达」就用alpha0.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 分钟。模型能做的只是把系统性偏差消掉剩下的随机噪声是消不掉的。接受这个边界把精力放在特征质量和监控上比死磕模型结构划算得多。希望帮到你。本文还有配套的精品资源点击获取