ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python机器学习实战:外卖送餐时间预测模型从特征工程到模型融合

Python机器学习实战:外卖送餐时间预测模型从特征工程到模型融合 简介这份资源面向具备一定Python基础、希望入门机器学习实战的开发者与数据爱好者围绕外卖送餐时间预测这一典型回归问题展开。它基于Kaggle公开数据集涵盖地点、外卖员ID、评分等字段通过计算经纬度距离挖掘特征间关系并搭建LSTM神经网络完成送餐时长建模帮助读者理解时间预估背后的机理。压缩包共2个文件包含1个txt数据集与1个py代码脚本整体约942KB轻量易上手可直接运行调试。目前已有1055人学习下载适合作为神经网络回归任务的练手案例。读者可从中获得从数据清洗、特征工程到LSTM建模的完整流程参考掌握经纬度距离计算与特征关联分析思路并借助现成脚本快速复现实验、调整网络结构为后续更复杂的时空预测任务打下基础。1. 外卖送餐时间预测从下单到送达机器学习能算准几分钟你有没有遇到过这种情况中午点了一份外卖App 上显示“预计 35 分钟送达”结果等了快一个小时饭都凉了。顾客体验差骑手也委屈——路上堵车、商家出餐慢、小区不让进哪个环节都能把时间拖长。对外卖平台来说送餐时间预测不准直接影响用户满意度和骑手调度效率。这个项目就是用 Python 和机器学习基于历史订单数据预测一单外卖从下单到送达需要多少分钟。它适合有 Python 基础、想找一个完整机器学习实战项目练手的开发者也适合做配送调度、本地生活服务相关业务的技术人员参考。核心思路不复杂把订单特征距离、天气、时段、商家出餐速度等喂给模型让模型学出送餐时间的规律。但真要做到“算得准”里面有不少细节值得拆开讲。2. 数据准备与特征工程送餐时间预测的原料从哪来2.1 一份合格的外卖订单数据集应该长什么样送餐时间预测本质上是一个回归问题目标变量是连续值——送达时长分钟。要让模型学到东西特征必须覆盖影响配送的核心因素。常见的数据集字段包括字段名类型说明order_id字符串订单唯一标识pickup_lat / pickup_lon浮点数商家取餐点经纬度delivery_lat / delivery_lon浮点数顾客送达点经纬度order_time时间戳下单时间weather类别天气状况晴、雨、雪、大风等traffic_level类别路况等级畅通、缓行、拥堵rider_id字符串骑手标识estimated_prep_time整数商家预计出餐时间分钟actual_delivery_time浮点数实际送达时长分钟即目标变量如果手头没有现成数据集可以用模拟数据先跑通流程。我一般会写一个生成脚本按真实分布造几千条记录重点是把特征之间的关联做出来——比如雨天配送时长整体上浮 15%高峰期出餐时间翻倍。这样模型学出来的结果才有参考意义。import pandas as pd import numpy as np from datetime import datetime, timedelta np.random.seed(42) n 5000 # 模拟基础特征 base_time datetime(2024, 6, 1, 10, 0, 0) order_times [base_time timedelta(minutesnp.random.randint(0, 720)) for _ in range(n)] distances np.random.exponential(scale3.0, sizen) # 配送距离均值3公里 weather np.random.choice([晴, 雨, 雪, 大风], sizen, p[0.6, 0.25, 0.05, 0.1]) traffic np.random.choice([畅通, 缓行, 拥堵], sizen, p[0.5, 0.35, 0.15]) prep_time np.random.normal(loc12, scale4, sizen).clip(5, 40) # 构造目标变量基础时间 距离影响 天气影响 路况影响 出餐影响 噪声 base_delivery 8 distance_effect distances * 4.5 weather_effect np.where(weather 雨, 6, np.where(weather 雪, 12, np.where(weather 大风, 4, 0))) traffic_effect np.where(traffic 拥堵, 10, np.where(traffic 缓行, 4, 0)) noise np.random.normal(0, 3, n) delivery_time base_delivery distance_effect weather_effect traffic_effect prep_time * 0.6 noise delivery_time delivery_time.clip(15, 120) df pd.DataFrame({ order_time: order_times, distance_km: distances.round(2), weather: weather, traffic_level: traffic, prep_time_min: prep_time.round(1), delivery_time_min: delivery_time.round(1) }) df.to_csv(delivery_orders.csv, indexFalse) print(df.head()) print(f数据集大小: {df.shape})这段代码生成了一份包含 5000 条订单的模拟数据集。distances用指数分布模拟真实配送距离——大部分订单在 3 公里以内少数远距离订单拉长尾部。weather_effect和traffic_effect用条件赋值把业务逻辑写进目标变量雨天加 6 分钟、雪天加 12 分钟、拥堵加 10 分钟这些系数参考了常见配送场景的经验值。prep_time乘以 0.6 是因为出餐时间并不完全叠加到配送时长上骑手到达商家后通常还要等一会儿但不会等满全程。噪声项noise模拟不可观测的随机因素标准差设为 3 分钟避免模型学得过于“完美”而失去泛化意义。2.2 时间特征与距离特征的提取技巧原始数据里的order_time是时间戳直接扔给模型没用。需要拆成有意义的特征小时、星期几、是否周末、是否饭点。距离特征也不能只用直线距离实际配送路径往往绕路常见做法是用经纬度算出 Haversine 距离后再乘一个 1.2 到 1.4 的绕路系数。import pandas as pd import numpy as np df pd.read_csv(delivery_orders.csv) df[order_time] pd.to_datetime(df[order_time]) # 时间特征提取 df[hour] df[order_time].dt.hour df[weekday] df[order_time].dt.weekday df[is_weekend] (df[weekday] 5).astype(int) df[is_meal_time] df[hour].apply(lambda x: 1 if x in [11, 12, 13, 17, 18, 19] else 0) # 如果数据里有经纬度用 Haversine 公式算距离 def haversine(lat1, lon1, lat2, lon2): R 6371 # 地球半径公里 phi1, phi2 np.radians(lat1), np.radians(lat2) dphi np.radians(lat2 - lat1) dlambda np.radians(lon2 - lon1) a np.sin(dphi/2)**2 np.cos(phi1) * np.cos(phi2) * np.sin(dlambda/2)**2 return 2 * R * np.arcsin(np.sqrt(a)) # 假设有经纬度列这里用模拟数据演示 # df[distance_km] haversine(df[pickup_lat], df[pickup_lon], df[delivery_lat], df[delivery_lon]) * 1.3 # 对类别特征做独热编码 df pd.get_dummies(df, columns[weather, traffic_level], drop_firstTrue) print(df.columns.tolist()) print(df[[hour, is_weekend, is_meal_time]].describe())is_meal_time这个特征很关键。午高峰和晚高峰的配送时长普遍比平峰期长 20% 到 30%因为商家出餐排队、骑手同时接多单、电梯等待时间增加。pd.get_dummies把天气和路况转成数值型drop_firstTrue避免虚拟变量陷阱——比如天气有四个类别生成三个二元列就够了第四个类别可以由全零表示。Haversine 公式算的是球面最短距离乘以 1.3 的绕路系数是行业里比较常用的经验值城市路网密度越高这个系数越接近 1.2郊区或封闭园区可能到 1.5。注意独热编码后如果特征维度太高可以考虑用目标编码Target Encoding替代但目标编码容易过拟合必须配合交叉验证使用。3. 模型选型与训练从线性回归到神经网络的实战对比3.1 为什么先跑一个线性回归做基线很多新手一上来就想用神经网络觉得模型越复杂效果越好。实际项目里我习惯先跑一个线性回归或决策树做基线目的是搞清楚数据里到底有多少线性可分的信息以及特征工程做到什么程度了。如果线性回归的 MAE平均绝对误差已经能到 5 分钟以内那说明特征质量不错再上复杂模型提升空间有限如果 MAE 高达 15 分钟那问题多半出在特征上换模型也救不了。from sklearn.model_selection import train_test_split from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score import numpy as np # 准备特征和目标 feature_cols [c for c in df.columns if c not in [order_time, delivery_time_min]] X df[feature_cols] y df[delivery_time_min] X_train, X_test, y_train, y_test train_test_split(X, y, test_size0.2, random_state42) # 线性回归基线 lr LinearRegression() lr.fit(X_train, y_train) y_pred_lr lr.predict(X_test) mae_lr mean_absolute_error(y_test, y_pred_lr) rmse_lr np.sqrt(mean_squared_error(y_test, y_pred_lr)) r2_lr r2_score(y_test, y_pred_lr) print(f线性回归 — MAE: {mae_lr:.2f} 分钟, RMSE: {rmse_lr:.2f}, R²: {r2_lr:.4f})train_test_split的test_size0.2表示留出 20% 数据做测试random_state42保证每次划分结果一致方便复现。MAE 是最直观的指标——它表示预测值平均偏离真实值多少分钟。RMSE 对大误差更敏感如果 RMSE 远大于 MAE说明存在一些预测得特别离谱的样本需要单独排查。R² 衡量模型解释了目标变量多少方差越接近 1 越好但送餐时间这种受随机因素影响大的问题R² 能到 0.7 以上就算不错了。3.2 随机森林与梯度提升树的参数调优线性回归跑完后下一步通常上树模型。随机森林和梯度提升树比如 XGBoost、LightGBM在表格数据上表现稳定对特征缩放不敏感还能输出特征重要性。我一般先用默认参数跑一遍看看特征重要性排序是否符合业务直觉——如果“天气”排在前三说明数据里的天气影响确实被模型捕捉到了如果“订单 ID”排第一那肯定是数据泄露了。from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor from sklearn.model_selection import GridSearchCV # 随机森林 rf RandomForestRegressor(n_estimators200, max_depth12, min_samples_leaf5, random_state42, n_jobs-1) rf.fit(X_train, y_train) y_pred_rf rf.predict(X_test) mae_rf mean_absolute_error(y_test, y_pred_rf) rmse_rf np.sqrt(mean_squared_error(y_test, y_pred_rf)) r2_rf r2_score(y_test, y_pred_rf) print(f随机森林 — MAE: {mae_rf:.2f} 分钟, RMSE: {rmse_rf:.2f}, R²: {r2_rf:.4f}) # 梯度提升树 gbr GradientBoostingRegressor(n_estimators300, learning_rate0.05, max_depth5, random_state42) gbr.fit(X_train, y_train) y_pred_gbr gbr.predict(X_test) mae_gbr mean_absolute_error(y_test, y_pred_gbr) rmse_gbr np.sqrt(mean_squared_error(y_test, y_pred_gbr)) r2_gbr r2_score(y_test, y_pred_gbr) print(f梯度提升树 — MAE: {mae_gbr:.2f} 分钟, RMSE: {rmse_gbr:.2f}, R²: {r2_gbr:.4f}) # 特征重要性 importance pd.DataFrame({ feature: feature_cols, importance: rf.feature_importances_ }).sort_values(importance, ascendingFalse) print(importance.head(10))随机森林的n_estimators200表示 200 棵树树越多模型越稳定但训练时间线性增长。max_depth12限制树深防止过拟合min_samples_leaf5保证每个叶子节点至少有 5 个样本避免模型记住噪声。梯度提升树的learning_rate0.05配合n_estimators300是常见的组合——学习率低一些用更多树来补偿泛化能力通常更好。特征重要性输出后如果发现“距离”和“出餐时间”排在前两位说明特征工程方向是对的如果“小时”特征重要性很低可以考虑把它离散化成“早高峰、午高峰、晚高峰、深夜”几个桶而不是直接用连续值。3.3 用神经网络捕捉非线性交互树模型擅长处理表格数据但特征之间的高阶交互比如“雨天 晚高峰 远距离”三者叠加的影响不一定能充分学到。这时候可以上一个简单的全连接神经网络把特征标准化后输入用几层隐藏层去拟合非线性关系。PyTorch 和 TensorFlow 都可以我一般用 PyTorch调试起来更灵活。import torch import torch.nn as nn import torch.optim as optim from sklearn.preprocessing import StandardScaler from torch.utils.data import DataLoader, TensorDataset # 标准化特征 scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test) # 转成 PyTorch 张量 X_train_t torch.FloatTensor(X_train_scaled) y_train_t torch.FloatTensor(y_train.values).view(-1, 1) X_test_t torch.FloatTensor(X_test_scaled) y_test_t torch.FloatTensor(y_test.values).view(-1, 1) train_dataset TensorDataset(X_train_t, y_train_t) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) # 定义网络 class DeliveryNet(nn.Module): def __init__(self, input_dim): super().__init__() self.net nn.Sequential( nn.Linear(input_dim, 64), nn.ReLU(), nn.Dropout(0.2), nn.Linear(64, 32), nn.ReLU(), nn.Dropout(0.2), nn.Linear(32, 1) ) def forward(self, x): return self.net(x) model DeliveryNet(X_train_scaled.shape[1]) criterion nn.MSELoss() optimizer optim.Adam(model.parameters(), lr0.001) # 训练 epochs 50 for epoch in range(epochs): model.train() total_loss 0 for batch_x, batch_y in train_loader: optimizer.zero_grad() output model(batch_x) loss criterion(output, batch_y) loss.backward() optimizer.step() total_loss loss.item() if (epoch 1) % 10 0: print(fEpoch {epoch1}/{epochs}, Loss: {total_loss/len(train_loader):.4f}) # 评估 model.eval() with torch.no_grad(): y_pred_nn model(X_test_t).numpy().flatten() mae_nn mean_absolute_error(y_test, y_pred_nn) rmse_nn np.sqrt(mean_squared_error(y_test, y_pred_nn)) r2_nn r2_score(y_test, y_pred_nn) print(f神经网络 — MAE: {mae_nn:.2f} 分钟, RMSE: {rmse_nn:.2f}, R²: {r2_nn:.4f})网络结构是输入层 → 64 → 32 → 1两层隐藏层足够捕捉大多数表格数据里的非线性关系。Dropout(0.2)在训练时随机丢弃 20% 的神经元防止过拟合。MSELoss是回归任务的标准损失函数Adam优化器学习率设 0.001 是比较稳的起点。训练 50 轮每 10 轮打印一次损失观察损失是否稳定下降。如果损失震荡厉害把学习率降到 0.0005如果损失下降太慢可以适当增大学习率或增加隐藏层宽度。标准化特征这一步不能省——神经网络对输入尺度敏感距离特征在 0 到 10 之间出餐时间在 5 到 40 之间不标准化的话梯度更新会偏向数值大的特征。4. 避坑与排查送餐时间预测里最容易翻车的五个地方4.1 数据泄露用未来信息预测过去现象模型在测试集上 MAE 只有 2 分钟上线后误差飙到 15 分钟以上。原因特征里混入了预测时点拿不到的信息。比如用“实际出餐时间”预测“送达时间”但实际出餐时间在下单时根本不知道或者用“骑手最终配送时长”反推特征造成目标泄露。解决逐列检查特征问自己“这个字段在预测时点能拿到吗”。出餐时间只能用“预计出餐时间”不能用“实际出餐时间”。如果数据里有“订单完成时间”绝对不能拿来当特征。4.2 时间序列切分错误随机划分导致乐观偏差现象交叉验证分数很高但按时间顺序回测时效果差很多。原因用train_test_split随机划分训练集里可能包含测试集之后日期的订单。送餐时间受季节、天气、平台策略影响时间上相邻的订单更相似随机划分会让模型“偷看”未来。解决按时间排序后切分前 80% 做训练后 20% 做测试。如果数据量够做滚动窗口验证——用前 3 个月训练预测第 4 个月然后窗口后移。4.3 异常值未处理一单跑了 3 小时把模型带偏现象模型对正常订单预测还行但偶尔给出 100 分钟以上的离谱预测。原因训练数据里有极端异常值比如骑手中途车坏了、顾客地址填错导致反复沟通。这些样本的配送时长可能是正常值的 3 到 5 倍MSE 损失函数会放大这些样本的影响。解决对目标变量做截断比如把超过 120 分钟的订单标记为异常并剔除或者用 Huber Loss 替代 MSE降低异常值权重。也可以对目标变量做对数变换压缩长尾分布。4.4 类别特征编码不一致训练集和测试集列对不上现象训练时一切正常预测新订单时报错“特征维度不匹配”。原因用pd.get_dummies对训练集编码后测试集里出现了训练集没有的天气类别比如训练集只有晴、雨测试集来了“冰雹”导致列数不一致。解决用sklearn的OneHotEncoder(handle_unknownignore)或者在get_dummies后手动对齐列——X_test X_test.reindex(columnsX_train.columns, fill_value0)。更稳妥的做法是把编码器保存下来预测时复用同一个编码器。4.5 忽略业务周期周一和周六的送餐逻辑不一样现象模型整体 MAE 可接受但周末的预测误差明显大于工作日。原因周末的订单结构不同——家庭聚餐多、远距离订单比例高、骑手数量少。如果模型没有充分学习“是否周末”与其它特征的交互就会在周末表现差。解决把is_weekend和is_meal_time做交叉特征比如“周末午高峰”作为一个单独类别。或者分人群建模——工作日模型和周末模型分开训练各自捕捉不同的配送规律。5. 模型融合与线上验证把 MAE 再压下去 1.5 分钟的实操技巧单模型调参到一定程度后提升空间有限。我一般会把线性回归、随机森林、梯度提升树和神经网络的预测结果做加权融合。权重不是拍脑袋定的而是用测试集上的误差反推——误差小的模型权重大但也要考虑模型之间的相关性相关性高的模型融合收益小。from scipy.optimize import minimize import numpy as np # 四个模型的预测结果 preds np.column_stack([y_pred_lr, y_pred_rf, y_pred_gbr, y_pred_nn]) # 定义损失函数融合权重下的 MAE def loss_func(weights): weights np.abs(weights) weights weights / weights.sum() blended preds weights return mean_absolute_error(y_test, blended) # 初始权重均等 init_weights np.array([0.25, 0.25, 0.25, 0.25]) result minimize(loss_func, init_weights, methodNelder-Mead) best_weights np.abs(result.x) / np.abs(result.x).sum() print(最优融合权重) for name, w in zip([线性回归, 随机森林, 梯度提升树, 神经网络], best_weights): print(f {name}: {w:.4f}) # 融合后的预测 y_pred_blend preds best_weights mae_blend mean_absolute_error(y_test, y_pred_blend) rmse_blend np.sqrt(mean_squared_error(y_test, y_pred_blend)) r2_blend r2_score(y_test, y_pred_blend) print(f\n融合模型 — MAE: {mae_blend:.2f} 分钟, RMSE: {rmse_blend:.2f}, R²: {r2_blend:.4f})minimize用 Nelder-Mead 单纯形法搜索最优权重目标是最小化融合后的 MAE。np.abs(weights)保证权重非负再归一化使和为 1。融合后的 MAE 通常比最好的单模型低 0.5 到 1.5 分钟具体取决于模型之间的多样性——如果四个模型预测结果高度相关融合收益就小。我一般会先看模型间的相关系数矩阵如果某两个模型相关系数超过 0.95就只保留其中一个避免冗余。线上验证不能只看离线 MAE。真实场景里我习惯把预测结果按误差分桶统计误差在 5 分钟以内的订单占比多少、5 到 10 分钟的占比多少、超过 15 分钟的占比多少。业务方更关心“有多少订单预测偏差超过 10 分钟”而不是平均误差。如果长尾误差占比高说明模型对某些特定场景比如极端天气、新骑手学得不够需要针对性补充数据。从那以后我每次做完回归项目都会强制走一遍“分桶误差分析”——不只看 MAE还要看 P90 误差和最大误差。送餐时间预测这种业务用户对“偶尔迟到很久”的容忍度远低于“平均迟到 3 分钟”。希望帮到你。本文还有配套的精品资源点击获取
返回列表