订单需求预测实战:数据清洗、特征工程与LightGBM建模全流程 简介一份 Swiggy 黑客松 2018 订单需求预测竞赛方案面向机器学习入门者与外卖平台数据从业者解决基于历史订单数据的需求回归预测问题。压缩包共75个文件以 Python 建模脚本KNN、随机森林、数据预处理为主同时包含 22 个 JavaScript 文件、12 张 PNG 图表、6 个 SVG 图标以及 JSON 预测结果、Node.js 与 Vue 构建的可视化前端、赛题 PDF 和 Readme 说明整体约 4.63MB目录结构清晰便于快速定位与改造。已有413人学习下载。资源提供了从数据清洗、特征工程到模型训练与预测生成的完整 Python 链路涵盖时间序列特征构造、天气与商圈等属性处理、KNN 与随机森林回归、交叉验证及网格搜索调参等关键技术配合 processed_order_data.csv 和图表界面可直观对比真实订单量与预测值并查看不同特征对预测结果的影响。不仅适合复现竞赛思路、学习回归建模也可参考其前端展示层的代码组织与 API 写法。1. 订单需求预测不是玄学黑客松题目到底在考什么拿历史订单数据训练机器学习模型预测下一时段、下一区域的订单需求这是 2018 年某外卖平台黑客松的经典题目也是订单需求预测项目的常见起点。这类题表面考建模实际考两件事能不能把脏订单表变成干净的监督学习样本以及会不会在特征和验证上踩数据泄露的坑。我刚开始做这类题时以为把订单量按小时聚合扔进 XGBoost 就行结果验证集分数漂亮换一个时间段直接翻车。问题不在模型在特征的时间对齐和验证集切分。这篇文章把完整方案拆开讲订单数据清洗、特征工程、LightGBM 调参、时间序列交叉验证、误差分析和预测提速。适合正在做订单需求预测、参加黑客松或者第一次碰时序预测的工程师照着复现。2. 把历史订单表变成监督学习样本数据清洗与特征工程的四个关键动作订单需求预测的建模单元不是单条订单而是「区域 × 时间」的聚合量。黑客松给的历史订单数据通常是订单明细表里面有下单时间、餐厅、区域这些字段但没有现成的「每小时每区域有多少单」这个标签。所以第一步不是调模型而是先把明细表聚合成带标签的样本表整个特征工程都建在这一层之上。2.1 订单数据建模前先回答三个问题粒度、时间窗、区域第一个问题是粒度。15 分钟、半小时、小时到底用哪个订单需求预测通常服务运力调度调度排班的最小单位是小时所以小时级粒度最常用。粒度越细样本量越大但订单在这个时间片里会大量为 0模型学到的都是零和一之间的切换反而更难。我一般先看数据时间跨度跨度只有一两周就用小时级跨度超过一个月再尝试半小时级。第二个问题是时间窗。用过去多少天训练、预测未来多少小时黑客松通常要求预测接下来 24 小时或一周的逐小时需求。这里有个原则预测步长决定你能用什么样的滞后特征。如果预测未来第 1 小时可以用前一小时真实值当滞后如果预测未来第 24 小时lag_1 就取不到了只能用「预测起点之前」的值。所以开始建模前先确认题目的预测起点和步长这直接影响特征列表越早确认越省事。第三个问题是区域划分。按餐厅还是按区域聚合按餐厅聚合订单稀疏的餐厅大多数小时都是 0模型退化成一个二分类问题按区域聚合样本平滑、可预测性更高也更贴近运力调度的实际场景。黑客松一般会给 area_id 这类字段直接用就行。如果只给餐厅我会按商圈或者地理网格做一层聚类归并。2.2 时间特征、滞后特征和滑动统计特征怎么造才不泄露特征工程的目标是让模型「看见」周期性。订单需求有明显的日内双高峰午餐和晚餐有周周期工作日和周末形态完全不同。所以时间特征至少要包含小时、星期几、是否周末三元组。滞后特征是把历史需求搬到当前时刻。lag_1、lag_2 捕捉最近的趋势惯性lag_24 捕捉昨天同一时刻的需求lag_168 捕捉上周同一时刻的需求。这里最容易犯的错是忘记排序。shift 是按行顺序移动的如果数据没有按「区域 时间」排好序shift 出来的滞后值就是错位的模型在验证集上就会「偷看」邻居区域或者错位时间的信息。滑动统计特征比如过去 3 小时均值必须在 shift(1) 之后再做否则当前时刻的真实值会参与计算这就是最典型的数据泄露。泄露的后果是验证集分数虚高一旦真正上线预测特征取不到未来值模型立刻失效。2.3 外部特征天气、促销加不加、怎么加很多人在这个点上纠结。常见做法是题目数据里给了就加没给就不要试图从外部找一个天气接口拼上去。原因是预测时点的天气不是真实天气而是预报值预报和实测之间的误差会让特征分布不一致线上和线下对不上。如果题目确实给了天气字段我一般把天气转成两列一列是数值化的降雨强度一列是气温促销活动给一个 is_promo 的 0/1 标志。这些外部特征有一个共同点在预测起点就能拿到不会引入未来信息。拿不到的数据宁可不用。2.4 用 pandas 把订单明细转成小时级特征矩阵下面这段代码完成「订单明细 → 区域×小时的需求量」这一步。这是整个流程里第一个必须跑通的环节代码不复杂但少了任何一行后面所有特征都是错的。import pandas as pd import numpy as np # 订单明细表至少包含order_id、restaurant_id、area_id、order_time orders pd.read_csv(orders.csv, parse_dates[order_time]) # 把下单时间对齐到小时作为聚合键 orders[hour] orders[order_time].dt.floor(h) # 按 区域 小时 统计订单数得到需求量 vol orders.groupby([area_id, hour], as_indexFalse).size() vol.columns [area_id, hour, demand]这段的逻辑是先把时间戳向下取整到小时然后 groupby 计数。size() 返回每个分组有多少行正好就是该区域该小时的订单数。注意 as_indexFalse 保证 area_id 和 hour 作为普通列保留下来方便后续合并特征。做完聚合之后要做一次「补全」。某个区域凌晨两点可能一单都没有groupby 的结果里这一行直接不存在而不是 demand0。如果不补全时间序列是不连续的shift 滞后特征会错位。# 为每个区域补全连续的小时序列缺失小时补 0 hour_range pd.date_range(vol[hour].min(), vol[hour].max(), freqh) full_idx pd.MultiIndex.from_product( [sorted(vol[area_id].unique()), hour_range], names[area_id, hour] ) vol vol.set_index([area_id, hour]).reindex(full_idx, fill_value0) vol vol.reset_index().sort_values([area_id, hour]) # 时间特征 vol[hour_of_day] vol[hour].dt.hour vol[day_of_week] vol[hour].dt.dayofweek vol[is_weekend] (vol[day_of_week] 5).astype(int) # 滞后特征滞后 1、2、24、48 小时 for lag in [1, 2, 24, 48]: vol[flag_{lag}] vol.groupby(area_id)[demand].shift(lag) # 滑动均值过去 3 小时均值shift(1) 排除当前时刻 vol[roll_mean_3h] ( vol.groupby(area_id)[demand] .transform(lambda s: s.shift(1).rolling(3, min_periods1).mean()) )参数说明里几个关键点。hour_range 用了 pd.date_rangefreqh 生成连续小时序列MultiIndex.from_product 是笛卡尔积保证每个区域都有同样的完整时间轴。补 0 之后 demand 从整数变成可能有小数不会fill_value0 保持整数。滞后特征的 shift(lag) 是按组内顺序移动的前提是前面已经 sort_values([area_id, hour])。lag_24 捕捉日周期lag_48 捕捉双日对比lag_1 和 lag_2 捕捉短时惯性。roll_mean_3h 里的 shift(1) 先去掉当前时刻rolling(3, min_periods1) 取窗口均值min_periods1 表示窗口不足 3 小时时用已有数据算避免开局全是 NaN。代价是早期样本特征偏弱这是可接受的折中。提示如果订单表里有 cancel 状态字段先确认题目口径是「下单量」还是「完单量」。黑客松一般以下单量为准但口径不同标签定义就不同影响非常大。3. 模型选型与训练梯度提升树为什么是黑客松的稳妥答案订单需求预测理论上可以用 ARIMA、LSTM、Prophet但黑客松场景下我几乎只用梯度提升树理由是不需要对数据做平稳性检验能直接吃小时、星期这种类别特征对异常值和缺失值容忍度高训练速度快到可以反复试特征。LSTM 调参成本高数据量不够时还打不过树模型属于投入产出比不划算的选项。3.1 先定评价指标再谈模型RMSE 和 MAPE 怎么选建模型之前先定指标不然调参没有方向。这类题目最常用 RMSE因为它对高峰时段的误差惩罚大而订单需求预测最怕的就是高峰期预测不准。MAPE 的问题在于分母是真实值订单量为 0 时直接除零就算加平滑也对低峰时段过敏。我一般两个都算主指标用 RMSE 排序打分MAPE 只用来辅助观察。评价指标一旦定了就不能换。因为调参本质是在优化这个指标换指标等于把之前的调参全部作废完全没有后悔药。3.2 LightGBM 还是 XGBoost为什么梯度提升树是稳妥答案LightGBM 和 XGBoost 在这个问题上差别不大但 LightGBM 训练更快内存占用更低黑客松动辄几十万行样本迭代速度就是优势。XGBoost 的精度在部分数据锐度高一点但差距通常在 1% 以内不值得用训练时间换。先跑通 LightGBM时间富余再对比一版 XGBoost 即可。核心参数我习惯这样设起点参数作用我的起点调参方向learning_rate每棵树步长0.05先定这个再谈树数量num_leaves树复杂度31过拟合就降到 15max_depth树深度限制6与 num_leaves 配合使用subsample行采样比例0.8数据少时回到 0.9colsample_bytree列采样比例0.8特征多时可降到 0.6min_child_samples叶节点最少样本20数据量大可降到 10n_estimators最多树数500靠 early stopping 截断调参顺序是固定的先定 learning_rate它决定后续所有参数的意义然后调 num_leaves 和 max_depth 控制复杂度再调采样比例最后动 min_child_samples。不要上来就 GridSearch黑客松时间不够而且粗调的提升远大于精调。3.3 时间序列交叉验证切分代码与参数说明时间序列数据不能随机切分。随机 K 折会把未来几天的样本放进训练集模型等于提前看到了答案验证分数虚高换时间段就翻车。正确做法是严格按时间先后切分训练集永远在验证集之前。import lightgbm as lgb from sklearn.metrics import mean_squared_error # 按时间切分最后 168 小时7 天做验证 cutoff vol[hour].max() - pd.Timedelta(hours168) train_df vol[vol[hour] cutoff].copy() valid_df vol[vol[hour] cutoff].copy() feats [hour_of_day, day_of_week, is_weekend, lag_1, lag_2, lag_24, lag_48, roll_mean_3h] model lgb.LGBMRegressor( objectiveregression, learning_rate0.05, num_leaves31, max_depth6, subsample0.8, subsample_freq1, colsample_bytree0.8, min_child_samples20, random_state42, n_estimators500, ) model.fit( train_df[feats], train_df[demand], eval_set[(valid_df[feats], valid_df[demand])], callbacks[lgb.early_stopping(50, verboseTrue)], )逻辑说明cutoff 取数据最大小时往前推 168 小时在此之前是训练集之后是验证集。验证集覆盖完整的一周包含工作日和周末这样误差分析才有意义。early_stopping(50) 在验证集上连续 50 轮没有提升就停避免过拟合n_estimators500 只是上限。参数说明subsample_freq1 表示每轮都做行采样配合 subsample0.8 使用random_state 固定是为了对比实验时结果可复现。这里有一个容易忽略的细节eval_set 用的是未来的验证集所以 early stopping 判断的是「未来表现」而不是「训练集表现」这正是时间序列切分的好处。如果要多折验证用 sklearn 的 TimeSeriesSplit但要注意它在切分时不会留间隔。from sklearn.model_selection import TimeSeriesSplit tscv TimeSeriesSplit(n_splits5) for fold, (tr_idx, va_idx) in enumerate(tscv.split(vol)): tr_fold vol.iloc[tr_idx] va_fold vol.iloc[va_idx] # 训练之前建议手动丢掉 tr_fold 末尾的 48 行 # 因为 lag_48 特征会让训练集尾部信息渗透到验证集 print(ffold {fold}: train {tr_fold[hour].min()} ~ {tr_fold[hour].max()}, fvalid {va_fold[hour].min()} ~ {va_fold[hour].max()})这里说明为什么要留间隔用了 lag_48 特征后验证集第一天的预测依赖训练集最后 48 小时的数据如果这些数据恰好也在验证集覆盖范围内就产生重叠。常见做法是训练和验证之间留出 max(lag) 小时的间隙保证严格无重叠。TimeSeriesSplit 本身不处理这个问题需要手动裁剪。3.4 特征重要性与残差分析模型训练完之后第一件事是看 feature_importances_它能告诉你模型到底在用什么信息做预测。我见过很多次 lag_24 排第一、lag_1 排第二的情况这很正常说明需求主要靠历史惯性驱动。但如果 lag_1 和 lag_2 占比超过 60%模型本质上就是一个「拿上一小时推这一小时」的持久化模型预测起点稍微一变就失效这时候要赶紧加特征而不是继续调参。残差分析也很关键。把验证集的预测值和真实值相减按小时聚合看误差分布能直接暴露问题。如果午餐和晚餐时段的残差显著大于其他时段说明模型在高峰时段系统性低估这就引出下一章的坑。4. 订单需求预测避坑指南五个最容易翻车的地方与排查方法这一章全是实操里踩过的坑每个都按「现象 → 原因 → 解决」来写。黑客松和真实项目唯一的区别是时间更紧所以这些问题一旦发生代价是半天起步提前知道能省很多白工。4.1 数据泄露验证集分数虚高一换时间段就崩现象时间切分的验证集 RMSE 非常低但把训练周期换一个月份重跑分数大幅变差模型不稳定。原因特征构造时用了「未来信息」。最常见的三种用全量数据的均值填充缺失值标准化时用全量数据的均值和方差或者构建了「当天总订单量」这种在预测起点拿不到的字段。解决建立一条硬规则——任何统计值只能用截止到当前时刻的数据计算。检查方法是把一个特征改成滞后 1 小时再训练如果验证分数不降反升说明原来那个特征确实在偷看未来。这条规则我在每次特征工程后都会跑一遍成本很低但能救命。4.2 高峰时段预测整体偏低模型被均值拉偏现象分时段误差分析显示午餐和晚餐时段真实 300 单预测只有 230 单凌晨低峰时段反而预测偏高。原因RMSE 对高峰误差惩罚大模型为了让整体损失最小会把预测值往均值方向压缩。高峰样本在总量里占比小模型宁愿把它们低估一点换取低峰时段更准。这是损失函数和样本分布共同作用的结果。解决先做误差分解确认形态然后加「小时 × 是否周末」交互特征让模型有能力区分高峰和非高峰如果还不够就把 11:00-14:00、17:00-20:00 单独抽出来训练一个高峰模型低峰时段用主模型。高峰模型可以用更高的学习率和更多树因为样本量小。4.3 滞后特征在预测起点取不到线下正常、线上全错现象线下验证准确但换到「用过去数据预测未来」时预测结果前几个时刻全是 0 或异常大。原因线下验证时训练集里的 lag_1 是用训练集自己的上一行算的但真正预测时预测起点的 lag_1 应该是「预测起点之前最后一个真实值」很多人在预测脚本里没有构造这部分历史直接用训练集最后一行去 shift导致错位。解决预测脚本里单独构造「历史末段」。先把截止到预测起点的真实订单量按区域取最后 48 小时拼到待预测样本前面再做 shift。测试方法很简单把验证集的第一个小时当作预测起点跑一遍完整预测流程和线下结果对比一致才算通过。4.4 用随机 K 折评估时序模型结果乐观得不像话现象随机 K 折交叉验证 RMSE 比时间切分低 30% 以上第一次遇到还以为是模型很强。原因随机切分把未来样本混进训练集模型在训练时见过验证集的「答案」。时序数据有自相关性相邻时间点的订单量高度相似随机切分等于把相似样本同时放进训练和验证分数天然虚高。解决一律用时间序列切分并且训练和验证之间留 gap。gap 长度取最大滞后值我用 48 小时。还要注意验证集要完整覆盖一个周期至少 7 天否则只验证了某几天的局部规律。4.5 指标好看但业务不可用RMSE 低不等于调度够用现象整体 RMSE 不错但放到运力调度场景里高峰期的骑手排班仍然不够单子积压。原因RMSE 是全局平均高峰期误差被大量低峰时段「平均」掉。调度关心的是「高峰期预测到底准不准」而不是一天的平均误差。解决额外报告高峰时段11:00-14:00、17:00-20:00的 MAE 和 P90 误差。P90 误差指 90% 样本的误差都小于这个值它比均值更能反映极端情况。如果高峰 P90 误差超过均值 2 倍说明模型在峰值场景没有抓手需要回到 4.2 的高峰建模方案。5. 预测结果怎么验证误差分解和朴素基线对比模型训练完不是终点验证才是。黑客松提交前我会做三轮验证按小时拆误差、按区域拆误差、和朴素基线对比。这三轮下来模型还能不能往上走、往哪个方向走基本就清楚了。5.1 误差按小时拆开看高峰期到底差多少整体一个 RMSE 数字看不出问题必须拆开。from sklearn.metrics import mean_squared_error, mean_absolute_error valid_df[pred] model.predict(valid_df[feats]) rmse mean_squared_error(valid_df[demand], valid_df[pred]) ** 0.5 mae mean_absolute_error(valid_df[demand], valid_df[pred]) print(f整体 RMSE: {rmse:.2f}, MAE: {mae:.2f}) # 按小时拆误差 hourly_rmse valid_df.groupby(hour_of_day).apply( lambda g: mean_squared_error(g[demand], g[pred]) ** 0.5, include_groupsFalse ) print(hourly_rmse.sort_values(ascendingFalse))逻辑说明先算整体 RMSE 和 MAE再按 hour_of_day 分组逐个算 RMSE。sort_values 后排在前面的一定是高峰时段如果午餐晚餐的 RMSE 是凌晨的 3 倍以上就需要按 4.2 的方案处理。这里有个参数细节include_groupsFalse 是 pandas 2.x 的写法它让 groupby.apply 里不把分组键当列传入。如果你的 pandas 版本较老去掉这个参数直接写 lambda g: mean_squared_error(g[demand], g[pred]) 即可不影响结果。5.2 区域维度与冷启动新区域没有历史订单怎么办按区域拆误差常常会发现一个现象误差最大的不是订单量最大的区域而是订单量中等的区域。大区域样本多、规律稳定小区域样本稀疏一天就几单预测稍偏一点 RMSE 就爆表。对这部分区域与其强求模型预测精确不如接受一个粗糙但有界的估计。新区域冷启动是另一个常见场景。一个区域刚开张没有 lag_24、lag_48 这些历史特征模型给出的预测就是垃圾。常见做法是找一个相似区域做迁移按区域的平均小时需求、工作日周末比例算相似度取 top 10 相似区域的需求均值做冷启动预测。具体实现就是算每个区域的时段系数乘上目标区域的基数参数上相似区域取 10 个min_history_hours 设 168 小时历史不足一周的区域都走冷启动路径。5.3 模型与朴素基线对比特征和模型是否真的有效最后一个验证是把模型和「上一周同一时刻」这个朴素基线比。# 持久化基线用上周同一小时的真实需求当预测值 prev vol.assign(prev_weekvol.groupby(area_id)[demand].shift(24 * 7)) prev prev[[area_id, hour, prev_week]] baseline_df valid_df.merge(prev, on[area_id, hour], howleft) baseline_rmse mean_squared_error( baseline_df[demand], baseline_df[prev_week] ) ** 0.5 improve (baseline_rmse - rmse) / baseline_rmse * 100 print(f基线 RMSE: {baseline_rmse:.2f}, 模型 RMSE: {rmse:.2f}, 提升: {improve:.1f}%)逻辑说明shift(24 * 7) 表示滞后 168 小时即上周同一小时的真实需求。merge 之后baseline_df 每一行的 prev_week 就是持久化基线预测。用这个基线和模型对比能判断特征工程和模型到底贡献了多少。按我的经验订单需求预测里模型比持久化基线提升 15% 以上算正常30% 以上算优秀。如果提升不到 10%说明你的滞后特征已经包含了大部分信息模型只是在重复基线这时候应该回头优化特征而不是继续调参。6. 时间预算紧张时的一个提速技巧把滚动统计换成累计和差分黑客松最后阶段常常要做特征迭代加一个 168 小时滑动窗口特征上去一看运行时间直接劝退。滚动窗口的计算复杂度是 O(N × 窗口)窗口越大越慢而且 groupby 加 transform 的写法会让每个区域重复计算。这里有一个提速技巧用累计和加差分把复杂度降到 O(N)。# 慢版本每个区域滚动 24 小时求和 vol[roll_sum_24h] ( vol.groupby(area_id)[demand] .transform(lambda s: s.shift(1).rolling(24, min_periods1).sum()) ) # 快版本累计和 错位差分 vol[prev_demand] vol.groupby(area_id)[demand].shift(1).fillna(0) vol[cum_prev] vol.groupby(area_id)[prev_demand].cumsum() vol[roll_sum_24h_fast] ( vol[cum_prev] - vol.groupby(area_id)[cum_prev].shift(24).fillna(0) ) # 一致性校验跳过每个区域前 24 行后两个版本结果应当完全一致 mask vol.groupby(area_id).cumcount() 24 pd.testing.assert_series_equal( vol.loc[mask, roll_sum_24h], vol.loc[mask, roll_sum_24h_fast], )逻辑说明cum_prev 是「截至前一小时的需求累计和」拿它减去 24 小时前的累计和正好得到过去 24 小时的总需求。shift(1) 排除了当前时刻fillna(0) 处理每个区域第一行shift(24) 后的 fillna(0) 处理窗口不足 24 行的情况。最后用 assert_series_equal 校验两个版本在窗口完整区域完全一致。注意这个技巧有两个前提数据必须按「区域 时间」排好序它返回的是窗口内和值行为与 min_periods1 的 rolling 略有差异窗口不足时慢版本会返回部分和快版本会偏小。所以校验代码里我用 cumcount() 24 只比对了窗口完整区域。我现在做这类时序题目会本能地先算一遍特征计算成本再决定要不要加一个长窗口特征。累计和差分这个套路适合任何一个「按组累计然后差窗口」的特征不只是滑动和滑动均值除以窗口宽度就行。这是我从订单需求预测里沉淀下来的习惯。希望帮到你。本文还有配套的精品资源点击获取