跳到正文

餐饮配送备货损耗控制的需求预测建模

餐饮配送备货损耗控制的需求预测建模

数据来自公开的食品需求预测数据集,覆盖 77 个履约中心、51 种餐品,包含 145 周历史订单,共 456,548 行;测试集覆盖未来 10 周,共 32,573 行。每一行对应一个履约中心、一个餐品和一周的订单量,特征包括结算价、基准价、邮件促销、首页推荐等。

预测单元是履约中心与餐品的组合,而不是单个中心或单个餐品的总量。该粒度更贴近按履约中心备货的实际场景:同一餐品在不同中心的需求水平可能差异悬殊,仅预测总量无法指导各履约中心的具体备货量。任务采用直接多步预测,一次输出未来 10 周,避免递归预测中误差逐步累积。

数据探查

训练集和测试集均没有缺失值,也没有重复的中心餐品周记录。目标变量 num_orders 右偏明显:最小值 13,25 分位为 54,中位数 136,均值 262,75 分位为 324,99 分位为 1,796,最大值 24,299;偏度 6.93,执行 log1p 变换后降至 -0.02。右偏会使平方误差被少数大单量主导,因此在对数空间进行训练与评估,使模型更多关注相对差异。

目标变量分布

左:原始订单量,横轴截至 2000;右:log1p 变换后的分布。原始目标右偏明显,变换后接近对称。

促销是数据中最强的行级信号。出现邮件促销的记录占 8.1%,其平均订单量为 631,为无邮件促销时的 2.75 倍;出现首页推荐的记录占 10.9%,其平均订单量为 595,为无首页推荐时的 2.69 倍。两种促销存在叠加效应:按组合分别统计,无促销为 211,仅邮件为 431,仅首页为 456,双促销为 816。

促销状态与平均订单量

按促销组合拆分的平均订单量,柱下标注记录占比。

价格方面,结算价范围为 2.97 至 866.27,基准价范围为 55.35 至 866.27,两者分布均呈明显长尾;25.4% 的结算价高于基准价,即负折扣,折扣中位数为 0.001。结算价与对数订单量的相关系数约为 -0.39,表明价格是有效信号,但折扣结构并不规则,不宜简单按折扣率线性处理。上述信息仅反映预测意义上的关联。

数据包含 14 个餐品类别、4 个菜系。不同餐品的需求量级差异显著:Rice Bowl 的平均订单量约为 625,Biryani 仅为 31;按中心与餐品的组合统计,组合均值为 13.7 至 2,305.8,相差约 168 倍。由此可见,相较于全局价格或促销特征,中心与餐品的标识以及组合自身的历史统计更能解释需求差异。

餐品类别平均订单量

餐品类别的平均订单量差异很大,Rice Bowl 约 625,Biryani 约 31。

此外,需求还会随时间缓慢下降:最初 10 周的周均订单量为 290.5,最后 10 周降至 227.3,降幅约 22%。因此验证不宜采用随机划分,而须保证训练段在前、验证段在后,以捕捉这种漂移。

周均订单量

每周平均订单量;虚线为最初 10 周与最后 10 周的均值,分别为 290.5 和 227.3。

组合覆盖也不完整。训练集有 3,597 个中心餐品组合,历史长度中位数为 140 周,但只有约 35% 覆盖完整 145 周;2,129 个组合有中间断档,部分组合中途上线或停售。测试集有 3,548 个组合,其中 3 个从未在训练中出现。滞后特征因此保留缺失值,树模型可直接处理,无需为填补缺失引入额外假设。

特征与防泄漏

最终特征集共 36 列,分为六组:

特征组 列数 内容 作用
原始数值与促销 4 checkout_price、base_price、emailer_for_promotion、homepage_featured 当期价格与促销状态
价格派生 6 base_log、checkout_log、discount、neg_discount、price_prev_diff、price_up 价格水平、折扣与周环比
日历 3 week、week_sin、week_cos 周序位置与季节形状
目标滞后 9 滞后 10 至 12、EWM(指数加权均值)平移 10 至 15 组合自身近期需求
滑动统计 6 10/26/52 周均值与标准差,平移 10 组合中期水平与波动
元信息 8 center_id、meal_id、category、cuisine、center_type、city_code、region_code、op_area 中心与餐品的固定差异

核心特征的计算逻辑如下,其关键在于先按训练截止周将目标置空,再在组内做平移:

# 目标派生特征 核心逻辑
y = np.log1p(out["num_orders"].astype(float))
if train_end is not None:
    # 预测起点之后的目标不参与特征
    y = y.where(out["week"] <= train_end)

g1, g2 = out["center_id"], out["meal_id"]
# _group_ewm / _group_roll 为组内 EWM 与滚动统计的辅助函数

for L in (10, 11, 12):
    out[f"y_lag{L}"] = y.groupby([g1, g2], sort=False).shift(L)

for sh in (10, 11, 12, 13, 14, 15):
    s = y.groupby([g1, g2], sort=False).shift(sh)
    out[f"y_ewm0.5_s{sh}"] = _group_ewm(s, g1, g2, 0.5)

for w in (10, 26, 52):
    s = y.groupby([g1, g2], sort=False).shift(10)
    out[f"y_rmean{w}_s10"] = _group_roll(s, g1, g2, w, "mean")
    out[f"y_rstd{w}_s10"] = _group_roll(s, g1, g2, w, "std")

目标派生特征是本方案的核心。由于需一次性预测未来 10 周,所有滞后与滑动统计的起点均设在 10 周之前:滞后仅取 10、11、12 周,EWM 采用平移(shift)10 至 15,滚动统计采用平移 10。如此,每个预测点所用的历史窗口均不会越过预测起点,既无需以模型预测值回填,也不会将验证段或测试段的目标信息带入特征。

价格与促销采用预测时点已确定的信息;类别特征采用固定整数编码,不采用目标编码;目标派生特征按组合分组计算。滞后与滚动统计在早期产生的缺失值予以保留,交由 LightGBM 处理。组合中途上线或停售本属常见现象,是数据结构的一部分,不宜作为异常数据删除。

评估方法

数据不含官方测试标签,所有模型选择均在本地回测上完成。采用两个前后错开的滚动窗口替代随机划分:

窗口 训练段 训练行数 验证段 验证行数
A / 较早 第 1 至 115 周 358,073 第 116 至 125 周 32,834
B / 较近 第 1 至 135 周 423,727 第 136 至 145 周 32,821

每个窗口均以训练段在前、验证段紧随其后的方式划分,以模拟真实预测时仅能利用过去数据的情形。随机划分会使同一组合的不同周同时进入训练与验证,导致验证分数偏乐观,且无法反映时间漂移。

指标使用 RMSLE,即对 log1p 目标计算均方根误差(RMSE),衡量相对误差。为区分真实改进与随机波动,先测定噪声地板:同一配置更换不同随机种子时,RMSLE 的标准差约为 0.0008。因此以 0.002 作为有意义的改进阈值,并要求两个窗口同向改善方视为通过。关键结论采用 5 组随机种子重复确认,一般探索至少采用 3 组。

窗口和指标的实现:

# 评估协议 核心逻辑
WINDOWS = {"A": (115, 116, 125), "B": (135, 136, 145)}

def rmsle_log(y_log_true, y_log_pred):
    y_log_pred = np.clip(np.asarray(y_log_pred, dtype=float), 0.0, None)
    y_log_true = np.asarray(y_log_true, dtype=float)
    return float(np.sqrt(np.mean((y_log_true - y_log_pred) ** 2)))

模型与方案

在 36 列特征与同一套回测协议下,比较了三种梯度提升模型:

模型 窗口 A 窗口 B 单次训练耗时 种子数
LightGBM 0.49425 0.49520 约 20 秒 3
XGBoost 0.49716 0.49605 约 30 秒 3
CatBoost 0.49704 0.49907 5 至 8.5 分钟 2

分数为对应种子数的均值。LightGBM 在两个窗口上均优于另外两者,训练速度也明显更快,因此确定为主力模型。调参采用小规模网格搜索:学习率 0.03 与 0.05,叶子数 31、63、127,共 6 组配置,每组使用 2 组随机种子;其中学习率 0.03、800 棵树、127 个叶子的配置在两个窗口均略优,经 5 组随机种子确认后确定为基准配置。完整参数为 800 棵树、学习率 0.03、127 个叶子、min_child_samples 40、subsample 0.9、colsample 0.8、L2 正则 1.0。

最终方案为对两个配置取简单平均:其一为上述 LightGBM 基准,其二在相同参数下开启 extra_trees,使分裂阈值更具随机性。extra_trees 在窗口 B 上提升明显,但在窗口 A 上变差;两者在对数空间等权平均后,窗口 A 基本持平,窗口 B 明显改善。最终方案的窗口 A 为 0.49008,窗口 B 为 0.48434。

方案 窗口 A 窗口 B 说明
LightGBM 基准 0.49038 0.49465 5 组种子
extra_trees 配置 0.49536 0.48358 5 组种子
两成员平均 0.49008 0.48434 采纳
5 成员等权 0.48994 0.48633 未采纳
跨窗权重 0.49186 0.49036 未采纳

5 成员池与权重寻优来自较早实验,其种子数与最终两成员方案不完全一致;数字仅用于指示方向。5 成员池在窗口 A 略优,但在窗口 B 受较弱成员影响而变差;跨窗权重在两个窗口均更差。三个引擎的预测相关系数介于 0.989 至 0.996 之间:按 A/B 窗口顺序,LightGBM 与 XGBoost 为 0.996/0.995,LightGBM 与 CatBoost 为 0.993/0.991,XGBoost 与 CatBoost 为 0.993/0.989。

最终集成包含两步:先分别得到两个成员各 5 组种子的预测,再在对数空间取平均。

# 最终集成 核心逻辑
LGB_SEL = dict(
    n_estimators=800, learning_rate=0.03, num_leaves=127,
    min_child_samples=40, subsample=0.9, subsample_freq=1,
    colsample_bytree=0.8, reg_lambda=1.0, extra_trees=True,
)

# p_lgb:LightGBM 基准的 5 组种子预测;p_et:extra_trees 配置的 5 组种子预测
v3_log = (p_lgb + p_et) / 2
orders = np.clip(np.expm1(v3_log), 0, None)

结果

将关键方案汇总对比,可见一条较为清晰的改进路径:

方案 窗口 A 窗口 B 说明
基础特征 + 原尺度目标 0.5867 0.5731 CatBoost,300 轮
基础特征 + 对数目标 0.5190 0.5219 无时序特征
滞后与 EWM 特征 + LGBM 400 0.4989 0.4979 加入滞后与 EWM
最终方案 0.4901 0.4843 两配置平均,各 5 组种子

上述各行所使用的主力模型不同,数字仅用于指示改进方向,并非严格消融实验;特征与模型的单独贡献在后文消融结果中分别确认。

目标对数化的收益最大:改用对数目标后,分数降至 0.519 / 0.522,两个窗口均改善约 0.05 至 0.07。加入滞后与 EWM 后,分数降至 0.499 / 0.498;再加入滑动统计与 extra_trees 平均,降至 0.490 / 0.484。相较于无时序特征的对数基线,最终方案在两个窗口分别降低 5.6% 与 7.2%,约 6% 至 7%。

Δ 为相对同口径基准的变化,负值表示更好;两个窗口同向且超过噪声阈值才视为有效。

尝试 ΔA ΔB 结论
滞后 10 至 15 +0.00092 -0.00138 方向不一致
滞后含 26 周 -0.00164 +0.00010 方向不一致
EWM 多 alpha -0.00252 +0.00092 方向不一致
去掉 EWM +0.00624 +0.00312 两窗均变差
动量 -0.00581 +0.00126 方向不一致
生命周期 -0.00184 +0.00343 方向不一致
价格上下文 + 促销历史 +0.00598 +0.00167 两窗均变差
滑动统计,2 组种子 -0.00593 -0.00158 临界
滑动统计,5 组种子确认 -0.00441 -0.00281 采纳

上述尝试中,仅滑动统计经 5 组随机种子确认后稳定通过;去掉 EWM 会使两个窗口同时变差;其余方向或两个窗口方向相反,或两个窗口同时变差。

最终提交的预测中位数为 124.3,均值 229.1,最大 5,495;训练集中位数为 136,均值为 262,说明模型在平方误差目标下趋于保守。

训练目标与最终预测的累计分布

训练目标与最终预测的累计分布;最终预测中位数 124.3,训练中位数 136。

综观上述路径,收益最大的两个环节为目标对数化与时序特征,集成仅带来小幅但稳定的补充。以上分数均来自两个本地回测窗口,并非测试集成绩。