订单需求预测:特征工程与时间切分比模型选型更关键

发布时间:2026/10/26 1:34:26
订单需求预测:特征工程与时间切分比模型选型更关键 简介围绕Swiggy Hackathon 2018订单需求预测赛题构建的机器学习项目包面向具备Python与回归建模基础、希望学习外卖订单量预测完整流程的开发者与参赛者重点解决基于历史订单数据预测未来需求的问题。压缩包共75个文件约4.63MB包含Python脚本、CSV订单数据、JSON预测结果与前端配置、JavaScript及可视化页面素材并附PDF说明与Markdown文档覆盖数据处理、KNN与随机森林回归、预测生成及结果展示等环节。已有414人学习下载说明该赛题方案在需求预测学习中具有一定参考度。读者可从中获得较完整的赛题实现思路数据准备与特征构造脚本、两种回归模型的训练与评估代码、批量预测输出以及基于地理编码和Web前端的可视化展示素材便于对照复现实验、理解模型选型与结果呈现并据此拓展到更复杂的订单需求预测场景。1. 订单需求预测误差的大头往往不在模型选型上订单需求预测这件事最反直觉的一点是把 KNN 换成随机森林、把随机森林换成梯度提升误差通常只掉几个百分点而把特征的时间对齐方向改一改误差能掉三成。用历史订单数据训练一个机器学习模型去预测未来某个时间片的订单量模型本身占的工作量其实很小剩下的大头全在「什么样的特征才算没有偷看未来」这件事上。这也是为什么值得把机制拆开看预测不准的时候多数人第一反应是换模型真正的根因常常藏在时间切分和特征构造里。一份基于历史订单数据的订单需求预测机器学习模型恰好是个足够小的样本能把 KNN 回归和随机森林回归这两条路线的取舍一次看清楚。2. 特征工程把订单流水变成可学习的时间片样本原始数据几乎永远是一行一条订单包含订单标识、下单时间、配送区域、金额、餐品类别这些字段。而预测目标不是「这一单要多久送到」是「下一个时间片、这个区域会有多少单」。中间隔着一层聚合和重构这层做错了后面换什么模型都是白搭。2.1 从一条订单到一个时间片的聚合口径聚合粒度不是拍脑袋定的它由预测结果的消费方倒推。外卖场景里预测结果一般给排班和备餐用提前量通常在 15 分钟到 1 小时之间粒度太细单个时间片内样本稀疏噪声压过信号粒度太粗预测出来也没法指导具体调度。半小时或一小时是常见的起点具体取值要看你需要提前多久做出决策。聚合的另一个维度是空间。直接用经纬度做键不行因为坐标是连续的几乎不会有两个订单落在完全相同的点上。常见做法是映射成网格或者取 geohash 的前缀。前缀长度的选择本质上是在控制区域大小前缀太短会把整个商圈混成一个点太长又会让每个区域样本不足。import pandas as pd def build_time_slices(orders, freq30min, geohash_prefix5): df orders.copy() df[order_time] pd.to_datetime(df[order_time]) # 时间片对齐floor 到固定边界否则每条订单各占一个时间戳无法聚合 df[slice] df[order_time].dt.floor(freq) # 区域键用前缀而不是完整 geohash保证区域数量可控、样本不被打散 df[grid] df[geohash].str[:geohash_prefix] agg (df.groupby([slice, grid]) .agg(order_cnt(order_id, nunique), # unique 防止重复上报的订单被算两次 avg_amount(amount, mean)) .reset_index()) # 补零没有订单的时间片是「需求为 0」不是「这条记录不存在」 full_idx pd.MultiIndex.from_product( [pd.date_range(agg[slice].min(), agg[slice].max(), freqfreq), sorted(agg[grid].unique())], names[slice, grid]) return agg.set_index([slice, grid]).reindex(full_idx, fill_value0).reset_index()这段代码里最容易被跳过、后果也最严重的是补零那一步。如果只保留有订单的时间片模型看到的样本全部是「有需求」的时刻它会天然学出一个偏高的基线而现实中大量时间片的需求就是零。补零之后样本量会涨好几倍训练变慢但这是必要的代价。order_id用nunique而不是count是因为订单流水里重复记录的情况比想象中常见尤其是在多次导出拼接之后。2.2 滞后特征与滑动窗口让模型看到自己过去的样子聚合出来的表本身信息量很低一行只有时间片、区域和当片订单数。模型要预测未来就必须能从「过去」里读出模式所以要把历史值搬到当前行来这就是滞后特征。lag_1是上一个时间片lag_48是 24 小时前半小时粒度下lag_336是一周前同一时段。后面这两个是需求预测里信息量最大的因为它们分别对应日周期和周周期。滑动窗口统计补的是另一类信息近期趋势和波动幅度。均值告诉你「最近大概什么水平」标准差告诉你「最近稳不稳」。一个区域如果最近几个时间片方差很大那预测值的不确定性也大这两类信息对后面的模型都很关键。def add_lag_features(df, lags(1, 2, 48, 336), windows(6, 48)): df df.sort_values([grid, slice]).copy() g df.groupby(grid, group_keysFalse) for lag in lags: # 按区域分组 shift避免相邻区域的数据互相串味 df[flag_{lag}] g[order_cnt].shift(lag) for w in windows: # 关键先 shift(1) 再 rolling。不 shift 的话当前时刻的真实值会混进特征 rolled g[order_cnt].shift(1).rolling(w, min_periodsw // 2) df[froll_mean_{w}] rolled.mean().reset_index(level0, dropTrue) df[froll_std_{w}] rolled.std().reset_index(level0, dropTrue) return dfshift(1)这一步是整段代码里唯一的「不能错」的地方。rolling默认是包含当前行的如果不先移一位roll_mean_6里就包含了当前时间片的真实订单量模型会学得极好上线后立刻崩。这种错误在离线评估里几乎不可能被发现因为离线指标只会好看。min_periodsw // 2是为了在序列开头不产生大段空值代价是前几个窗口的统计量不够稳。可以接受因为这部分样本本来就少。2.3 时间特征与周期编码为什么 23 点和 0 点在编码上相邻时间片天然带有小时、星期几、是否节假日这些属性。最直接的做法是 one-hot但小时这个变量有个特殊之处23 点和 0 点在语义上紧挨着one-hot 之后它们成了距离最远的两列。对树模型影响不大因为树可以靠多次切分绕过去对 KNN 是致命的因为它的全部判断都建立在距离上。把周期变量映射到单位圆上是标准解法小时映射成sin(2πh/24)和cos(2πh/24)两维23 点和 0 点在圆上就是相邻的。星期几同理周期取 7。import numpy as np def cyclical(series, period): 把周期变量映射到单位圆保留首尾相邻的几何关系 angle 2 * np.pi * series / period return np.sin(angle), np.cos(angle) df[hour_sin], df[hour_cos] cyclical(df[slice].dt.hour, 24) df[dow_sin], df[dow_cos] cyclical(df[slice].dt.dayofweek, 7)要注意的是这种编码方式会让特征维度增加而增加的这两维本身是强相关的sin² cos² 1。对于 KNN这意味着它们在距离计算里被算了两次对于随机森林意味着特征重要性会被这两个共线特征稀释。所以周期编码不是无脑加要看下游模型。这一点在后面两章的对比里会反复出现。3. KNN 回归没有训练阶段代价全压在距离度量上KNN 在需求预测里出人意料地好用因为它的假设非常贴合业务直觉历史上最像当前这个时刻的若干个时间片它们的订单量平均一下就是我的预测。这个「像」由特征空间里的距离定义。3.1 KNN 到底在什么空间里找邻居KNN 没有显式的训练过程它把整个训练集当作索引存下来预测时计算待预测样本到所有训练样本的距离取最近的 k 个然后平均。所谓「学习」全部发生在特征构造阶段——你给什么特征它就在什么空间里找邻居。这意味着两件事。第一KNN 的预测成本随训练集大小线性增长样本量到十万级以上时线上实时预测就很吃力通常要退化成离线批量预测或者用近似最近邻索引。第二它的表现对特征的表达方式极度敏感同一个任务换一套特征编码效果可能差一倍。from sklearn.neighbors import KNeighborsRegressor from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline knn Pipeline([ (scaler, StandardScaler()), # 距离度量对量纲敏感缩放不是可选项 (model, KNeighborsRegressor( n_neighbors15, weightsdistance, # 越近的邻居权重越大缓解 k 取值过硬的问题 metricmanhattan, # 高维下 L1 通常比 L2 稳不会被单维拉爆 n_jobs-1, )), ])weightsdistance的效果是距离为 d 的邻居权重是 1/dk 个邻居加权平均。这比等权平均更合理因为「最像的那几个」本来就该有更大话语权。副作用是如果某个邻居距离接近 0权重会爆炸实际使用时通常要在分母上加一个极小的常数或者设一个距离下限。metricmanhattan的选择值得多说一句。L2 距离对大差异的维度惩罚更重平方关系在特征维度高、且某些维度噪声大的时候单个异常维度会主导整个距离。L1 是线性的对离群维度更宽容。外卖订单数据里avg_amount这类字段的异常值不少用 L1 更稳。3.2 特征缩放一个量级能毁掉整个距离度量标准化在 KNN 里不是调优项是前提。设想order_cnt的取值范围是 0 到 200而hour_cos在 -1 到 1 之间。不做缩放的话两个样本之间哪怕只差 5 单在距离上的贡献也远大于「一个是午高峰、一个是凌晨」这种本质差异。模型实际上退化成了「只按订单量找邻居」周期特征完全失效。# 反例不做缩放时各维度对距离的贡献完全失衡 # 样本 A: order_cnt100, hour_cos1.0 # 样本 B: order_cnt105, hour_cos-1.0 # L2 距离 ≈ sqrt(25 4) ≈ 5.4其中订单量维度贡献了 25周期维度只贡献了 4标准化之后每个维度的方差被拉到 1贡献才大致均衡。但这里有个隐藏问题标准化假设各维度同等重要而实际上lag_1的预测能力远强于avg_amount。等权标准化等于人为地把弱特征抬到了和强特征一样的地位。KNN 里没有内置的机制给你学特征权重所以通常要么手工给不同特征组乘系数要么用带权重的距离度量。另一个相关的问题是滞后特征之间的共线。lag_1、lag_2、roll_mean_6这几个量高度相关在距离计算里相当于把「近期订单水平」这个维度重复加了三次。结果是距离空间被这一族特征主导其他维度被淹没。常见的缓解办法是只保留少数几个有代表性的窗口或者先对滞后特征做一次降维再喂给 KNN。3.3 k 值与维度灾难偏差和方差的另一张脸k 小的时候预测只参考最近的几个样本方差大训练集里某个时间片恰好有一场活动导致订单暴涨附近的预测点全被带偏。k 大的时候更多样本参与平均方差小但偏差上来了凌晨的样本会被平均进午高峰的预测里。调 k 本身没什么玄学在按时间切分的验证集上扫一遍就行。真正需要警惕的是维度灾难它会让「调 k」这件事失去意义。import numpy as np from sklearn.neighbors import NearestNeighbors def distance_contrast(X, sample200, seed0): 最近邻距离 / 最远邻距离。越接近 1说明距离度量越退化 rng np.random.default_rng(seed) idx rng.choice(len(X), sizemin(sample, len(X)), replaceFalse) nn NearestNeighbors(n_neighborslen(X)).fit(X) dist, _ nn.kneighbors(X[idx]) return float((dist[:, 1] / dist[:, -1]).mean())这个比值在低维时通常很小说明样本之间远近分明随着维度上升它会趋近 1意思是「所有样本离你差不多远」。此时最近邻已经不再「最近」KNN 的预测结果趋近于全体样本的均值。这就是为什么在 KNN 上盲目堆特征会翻车每多一个弱相关维度距离的区分度就被稀释一点。实践上的判断标准很简单特征数控制在十几个以内且每个特征都要能说清「为什么它会让两个时间片的需求更接近」。说不清的维度就该砍掉。4. 随机森林回归用 Bagging 换掉方差这笔账KNN 的问题在于它把全部希望寄托在距离度量上随机森林换了一条路它不关心样本之间的远近只关心「用哪个特征、在哪个阈值上切一刀能让两边的订单量差异最大」。4.1 为什么单棵树会过拟合而一片森林不会单棵决策树的生长过程是贪心的每次选一个特征和阈值让切分后两侧的残差平方和下降最多。深度不加限制时它能把训练集切到每个叶子只剩一个样本训练误差接近 0。但订单量这个目标本身噪声很大同一个区域同一个时段这周 87 单下周 92 单这里面有相当一部分是不可预测的随机波动。树把这些波动也记住了就是典型的过拟合。Bagging 的思路是训练 B 棵树每棵用有放回抽样得到的不同子集最后把 B 个预测平均。在树之间近似独立的假设下平均后的方差降到原来的 1/B而偏差基本不变因为每棵树的期望输出是一样的。数学上更准确的表达是Var(平均) ρ·σ² (1-ρ)·σ²/Bρ 是树之间的相关系数σ² 是单棵树的方差。B 增大时第二项趋于 0但第一项不动。所以光靠增加树的数量有上限真正决定性能的是能不能把 ρ 压下去。这就是随机森林在 Bagging 之外多做一步「每次分裂只在随机特征子集里选」的原因——它强行让不同的树看到不同的特征从而降低相关性。from sklearn.ensemble import RandomForestRegressor rf RandomForestRegressor( n_estimators500, max_features0.5, # 每次分裂只看一半特征压低树间相关性 rho min_samples_leaf5, # 叶子太小等于记住噪声时间序列任务里这个值要放大 bootstrapTrue, oob_scoreTrue, # 用袋外样本估一个泛化误差但别太当真 n_jobs-1, random_state42, )min_samples_leaf是这份配置里最需要针对业务调整的参数。默认值 1 意味着允许每个叶子只有一个样本而单条订单记录噪声极大叶子越纯越是在拟合噪声。设成 5 到 20 之间的值相当于对每个叶子做了最小样本量的平滑通常能带来明显的验证集收益。max_features取 0.5 是偏保守的。取值越小树之间越不相关但单棵树的表达能力也越弱。在特征数量不多十几个的情况下0.5 到 0.8 是个合理区间特征多了可以往下压。4.2 OOB 与时间序列的错配一个免费的验证集但不能全信oob_scoreTrue的意思是对每一棵树用那些没有被抽到的样本袋外样本来计算误差最后对所有树取平均。在样本独立同分布的假设下这个估计近似无偏等于白送一个验证集。但订单数据不是独立同分布的。相邻时间片的 lag 特征直接把前后样本绑在了一起一棵树的袋外样本时间点很可能就是某个袋内样本的下一个时间片。这时候用袋内样本训练出来的树去预测袋外样本误差会被严重低估。我一般把这个指标当成「粗筛工具」比较不同max_features或min_samples_leaf的相对高低可以但它和真实线上误差的绝对值基本没有可比性。最终的模型选择必须落在按时间切分出来的 holdout 上。import numpy as np # 按时间切分后面 20% 作为验证集绝不随机打乱 cut int(len(df) * 0.8) train, valid df.iloc[:cut], df.iloc[cut:] X_train, y_train train[FEATURES], train[order_cnt] X_valid, y_valid valid[FEATURES], valid[order_cnt] rf.fit(X_train, y_train) pred rf.predict(X_valid) rmse float(np.sqrt(((pred - y_valid) ** 2).mean()))按时间切分这件事看着简单但它是整篇文章里最重要的一行实践。随机切分会让模型在同一天的相邻时间片上同时「训练」和「验证」lag 特征把未来信息直接递到了模型手里离线指标能漂亮到 0.3 以内上线直接打回原形。4.3 特征重要性与可解释性impurity 的偏心和 permutation 的陷阱随机森林自带feature_importances_但它基于不纯度下降有两个已知偏差偏好取值多的连续特征以及偏好高基数特征。在订单特征里avg_amount这种连续量天然占便宜而「是否节假日」这种二值特征经常被低估。更可靠的是置换重要性把某一列打乱看验证误差涨多少涨得越多说明这个特征越重要。但对时间序列直接 shuffle 会破坏时间结构打乱之后模型面对的是「不可能出现的时间组合」得到的重要性会系统性地偏向 lag 特征。from sklearn.inspection import permutation_importance result permutation_importance( rf, X_valid, y_valid, n_repeats10, scoringneg_root_mean_squared_error, random_state42, ) importance dict(zip(X_valid.columns, result.importances_mean))更稳的做法有两种一是按grid整组打乱保持每个区域内部的时间顺序二是遮挡法把某一列整体置为训练集均值让模型在「这个特征没信息」的状态下预测。两种方法都比直接 shuffle 更贴近真实场景。不过要提醒一句这两种做法算出来的重要性更保守别指望它给出漂亮的排序它的价值在于找「哪些特征其实是噪声」而不是证明「哪些特征最重要」。5. 避坑与排查需求预测任务里最常见的五个翻车现场这类任务的坑有个共同特征离线指标越好越可能有问题。下面五条基本覆盖了从数据处理到上线的大部分事故。5.1 数据切分与特征构造阶段的三个坑坑一随机 KFold 造成时间泄漏。现象是线下 RMSE 在 0.3 上下上线后直接翻到接近 1。原因是随机切分让模型在训练时见过某个时间片的下一个时间片而lag_1恰恰就是那个值。解决方式很明确所有验证都必须按时间切分训练集在前、验证集在后中间留出一段 gap比如一个预测窗口的长度防止边界处仍有重叠。坑二补零后滞后特征跨区域串味。现象是某些区域开头几行的lag_1有值甚至带着完全不相干的量级。原因是groupby之后没有按grid分组shift或者reindex之后的 MultiIndex 顺序和groupby的顺序不一致导致某些行的滞后值来自另一个区域。解决先sort_values([grid, slice])再分组reindex之后逐区域检查前几行是不是 NaN。这个检查可以写成一个断言跑一次就永久生效。坑三目标编码在时间维度上的泄漏。现象是训练集拟合得极好验证集崩掉。原因是用整段时间的均值做类别编码编码值里包含了未来信息。在订单数据里按区域算历史平均订单量是常见操作但如果用的是全量数据就等于把答案提前告诉了模型。解决只用当前时间点之前的数据做扩张窗口均值窗口至少留出一个预测周期的 gap。5.2 模型训练与线上效果的两个坑坑四KNN 的 scaler 过期。现象是模型上线一段时间后预测整体偏移但代码没改过。原因是StandardScaler是在训练集上 fit 的而线上数据的分布会随时间漂移新商圈开张、季节性变化过时的缩放参数会让距离度量整体失准。解决把 scaler 和邻居库绑定成一条 pipeline按固定周期整体重训而不是只往里追加新样本。坑五树模型对高峰时段系统性低估。现象是平峰时段预测挺准午晚高峰普遍偏低 10% 到 20%。原因有两层一是树模型的分裂是分段常数叶子节点的输出是平均值而订单量是右偏分布平均值天然低于右偏分布的高分位二是 RMSE 虽然对大误差敏感但它同时也会把极端高的样本往中间拉。解决对目标做log1p变换评估用 RMSLE预测时再expm1还原。如果业务上「少排骑手」比「多排骑手」更贵那还应该用非对称损失或者分位数回归直接预测一个偏高的分位数。6. 评估与取舍指标选择本身就是一次业务建模6.1 为什么这个任务该看 RMSLE 而不是 RMSE订单量是个右偏、量级跨度极大的目标凌晨某些区域 1 单午高峰能到 200 单。RMSE 在平方项上放大误差会被大值样本主导模型会为了压低高峰的绝对误差而牺牲低峰时段的相对精度结果就是低峰时段的预测完全不可用。RMSLE 在对数空间上计算等价于看相对误差各量级样本的贡献被拉平。它还有个隐含效果对低估的惩罚比对高估更重。直接从公式就能看出来当实际值小时log(1pred) - log(1actual)的相对变化更剧烈。import numpy as np def rmsle(y_true, y_pred): # 必须先裁剪掉负值否则 log 无定义树模型和 KNN 都可能输出负数 y_pred np.clip(y_pred, 0, None) return float(np.sqrt(np.mean((np.log1p(y_pred) - np.log1p(y_true)) ** 2)))收紧负值是必须的。KNN 在weightsdistance下不会产生负值但随机森林在极端外推时偶尔会给出负数直接送进 log 会变成 NaN 并污染整个评估结果。6.2 KNN 和随机森林各自适合什么怎么选把两条路线放在同一张表里比一下取舍就很清楚了。维度KNN 回归随机森林回归训练成本几乎为零只存索引高500 棵树在小数据集上也要分钟级预测成本随样本量线性增长固定O(树数 × 深度)增量更新直接追加邻居即可需要重训或增量树特征工程敏感度极高依赖缩放和距离度量中等对量纲不敏感外推能力无预测值不会超出历史范围同样受限树是分段常数可解释性弱只能回溯邻居较强有特征重要性一个很实际的组合方式是把 KNN 的预测值当成随机森林的一个输入特征。KNN 擅长捕捉局部相似性「上周同一时段、类似天气、类似区域的订单量」随机森林擅长在全局上组合多维度信息。两者叠加在验证集上通常比任何单一模型稳尤其是新开区域这种历史样本少的场景KNN 的局部邻居能补上全局模型的冷启动缺口。from sklearn.model_selection import KFold import numpy as np # 用 K 折给训练集生成 out-of-fold 的 KNN 预测避免这列特征本身泄漏 oof_knn np.zeros(len(X_train)) kf KFold(n_splits5, shuffleFalse) # 注意fold 之间仍要保持时间顺序 for tr_idx, va_idx in kf.split(X_train): knn.fit(X_train.iloc[tr_idx], y_train.iloc[tr_idx]) oof_knn[va_idx] knn.predict(X_train.iloc[va_idx]) X_train_aug X_train.assign(knn_predoof_knn) rf.fit(X_train_aug, y_train)shuffleFalse是关键。如果这里用随机 KFold 生成 out-of-fold 预测KNN 的邻居里仍然会有未来样本这列特征就带上了泄漏而它又是随机森林最重要的输入之一整条链路会一起失效。6.3 落到取舍选模型其实是在选时间的表示方式回头看这几章其实在讲同一件事需求预测的精度上限由特征决定模型只是在既定特征空间里找一个尽量好的解。KNN 把全部赌注押在距离度量上所以它对缩放、编码、共线极其敏感稍微一个量级失衡就整体失效随机森林对量纲不敏感但它对叶子大小、特征相关性这些结构参数敏感而且它对右偏目标的系统性低估需要专门处理。我一般会用三个判据来做最终决定。第一看分时段误差而不是总体指标把验证集按高峰、平峰、凌晨切开如果某个模型在总体 RMSLE 上领先但凌晨段崩了那它在排班场景里就是不可用的。第二看冷启动表现新开区域没有历史 lagKNN 靠邻居还能给出一个粗估随机森林只能回退到全局均值。第三看更新频率如果业务要求小时级更新KNN 的增量特性更有优势如果是一天重训一次随机森林的稳定性更划算。验证方法上我会固定一份按时间切分的 holdout任何改动——加特征、调参、换模型——都必须在这份 holdout 上过一遍并且要求改进幅度超过噪声水平才算数。这份 holdout 一旦确定就不再动用它来防止自己在验证集上反复调参调到过拟合。从那以后我每做一次时间序列的特征改造都强制先跑一遍时间切分的 holdout再去看总体指标这个习惯救回来的上线事故不止一次。希望帮到你。这份资源把时间片聚合、滞后与滑动窗口构造、KNN 和随机森林两套回归实现、以及按时间切分的评估脚本放在了一起可以拿去对着改自己的特征口径也可以直接把两套模型跑在同一份数据上做对比。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询