2026/10/20 17:12:53

时序回归与模型融合实战:从需求预测到分仓规划的完整建模方案

时序回归与模型融合实战:从需求预测到分仓规划的完整建模方案 简介一套针对天池大数据竞赛“菜鸟-需求预测与分仓规划”赛题的完整参赛代码包面向竞赛选手、供应链算法工程师及机器学习初学者。项目聚焦两个核心任务基于分仓历史订单构建回归模型预测未来需求并依据预测结果做分仓规划以平衡库存与履约成本覆盖数据清洗、缺失值填充、特征构造、模型训练与融合的完整流程。压缩包共68个文件以58个Python脚本为主体辅以6个SQL查询脚本、2个CSV结果文件、1个R脚本和1个Markdown说明整体仅133KB。Python脚本实现了XGBoost、GBDT、随机森林、SVR、AdaBoost等回归模型及融合策略SQL负责提取训练与测试特征R脚本提供了ARIMA时间序列基线对比。已有2339人学习过这套资源对想要快速理解天池赛题思路、上手分仓级建模和模型集成实践的读者是很有价值的参考。1. 这个赛题到底在预测什么从一次下单到发货的完整链路很多做时序预测的开发者刚接触这个赛题都会问需求预测和分仓规划明明是两件事为什么要放在同一套框架里因为它们被同一盘货串了起来。某物流企业的仓配网络中每个SKU放哪个仓、备多少货、未来两周各仓出多少全部依赖同一个底座——按仓库和SKU维度的出库量预测。要把这件事做好单靠一个回归模型远远不够常见做法是先用梯度提升树做主力预测再用模型融合把不同模型的偏差互相补掉。这份资源就是围绕该赛题的完整回归建模代码从特征工程、模型训练到融合提交都有落地实现适合刚入门数据竞赛、想把时序回归能力迁移到供应链场景的开发者。2. 赛题拆解与数据口径输入输出和评价指标先对齐2.1 需求预测与分仓规划同一个输入两个不同产出赛题给的原始材料通常由三类数据表构成订单流水表、仓库属性表、商品属性表。订单流水表记录每一次用户下单的时间、仓库编码、SKU编码、下单数量这是所有特征工程的原材料。仓库属性表描述每个仓的类型、容量区间和覆盖区域商品属性表则描述SKU的品类、体积、重量等信息。建模时真正用到的主表是把订单流水按“仓库 SKU 日期”三个键聚合之后的出库量宽表后续所有特征都在这张宽表上展开。需求预测的输出是一个预测宽表每个仓库、每个SKU、未来若干天各自的预测出库量。这个输出的粒度决定了分仓规划能不能落地。分仓规划是在拿到这份预测之后结合每个仓的容量、目标区域的运输成本决定“某一款SKU的货应该备到哪些仓”。预测模型在这里承担的是“告诉规划模块未来哪里会有需求”规划模块做的才是“用最低成本把货放到离需求最近的地方”。两份任务共用同一条数据管道但评价标准和输出格式完全不同。建表之前我会先在本地把三张表的关联键梳理清楚。订单流水是事实表仓库表和商品表是维度表聚合主键是 warehouse_id sku_id ftr_date。不要直接用原始订单行做训练因为同一仓库同一SKU一天内可能有几十条订单树模型在这种细粒度下学到的模式会被下单频次干扰。按日期聚合后单行样本直接对应预测目标训练和验证的行数也能对齐。2.2 目录结构与代码组织先找到入口再开始跑这份资源的目录组织方式比较典型适合直接照搬到自己项目里project/ ├── data/ │ ├── train.csv # 历史订单汇总后的出库数据 │ ├── test.csv # 待预测日期与仓库SKU组合 │ └── submit_sample.csv # 提交样例格式对齐用 ├── features/ │ ├── build_time_feat.py # 时间特征 │ ├── build_lag_feat.py # 滞后与滚动特征 │ └── build_cross_feat.py # 仓库与SKU交叉特征 ├── models/ │ ├── train_lgb.py # LightGBM训练入口 │ ├── train_xgb.py # XGBoost训练入口 │ └── fusion.py # 加权平均与Stacking融合 ├── config.py # 全局路径与全局参数 └── main.sh # 一键运行入口跑通的第一步不是执行 main.sh而是检查 config.py 里的路径。data 目录下的 train.csv 和 test.csv 必须改成你本地解压后的实际路径features 和 models 目录下的中间结果输出路径也要可写。改完之后按“先特征、后模型、再融合”的顺序执行build_time_feat.py 和 build_lag_feat.py 的输出会作为 train_lgb.py 的输入模型训练脚本又把预测结果写到 fusion.py 的输入目录。main.sh 里一般就是三段命令的串联不要跳步。特征阶段跑挂了后面模型阶段大概率会因为列名对不上直接报 KeyError。我一般会让三个特征脚本各自输出一份带时间戳的 parquet 文件这样特征改了哪一版、对应跑了哪个模型事后都能查得清楚。2.3 评价指标与提交格式先理解分数再优化模型这个赛题的评价指标以均方根误差为核心但会对不同仓库或不同SKU的预测误差做加权处理。权重的设计思路是出货量大的仓和SKU罚得更重因为它们的预测偏差直接影响备货成本和爆仓风险。理解这一点很重要你不需要对所有样本均匀地好而应该优先把出货量大的样本预测准RMSE 会自然往下走。提交格式一般长这样每一行对应一个待预测组合warehouse_id,sku_id,ftr_date,predict_qty W0001,SKU1044,2020-08-01,15 W0001,SKU1044,2020-08-02,6提交前必须校验三件事行数等于测试集行数、列名完全一致、predict_qty 是数值。赛题对预测值是保留浮点还是取整会在提交说明里写清楚如果不确定就按样例文件的格式处理不要自己发挥。我见过有人因为把输出做成了 log1p 变换后的值直接提交分数直接飘到榜尾这类问题只靠检查脚本很难发现必须在还原函数上做一次反向验证。import pandas as pd def validate_submit(pred_df, sample_df): assert list(pred_df.columns) list(sample_df.columns), 列名不一致 assert len(pred_df) len(sample_df), 行数不一致 assert pred_df[predict_qty].notna().all(), 存在空值 # 顺序也必须对齐按 warehouse_id sku_id ftr_date 排序 pred_df pred_df.sort_values([warehouse_id, sku_id, ftr_date]) sample_df sample_df.sort_values([warehouse_id, sku_id, ftr_date]) pd.testing.assert_frame_equal( pred_df[[warehouse_id, sku_id, ftr_date]], sample_df[[warehouse_id, sku_id, ftr_date]] ) print(submit format ok)这段代码的作用是把“看起来对”变成“确实对”。列名、行数、空值、排序四个维度全部校验通过提交结果才不会被格式问题坑掉。排序对齐容易被忽略因为训练数据里顺序是随机的但提交文件要求跟样例逐行对应。3. 特征工程实战时序特征、滞后窗口与仓库交叉变量的组合3.1 基础时间特征把“哪天”变成模型能懂的字段树模型对日期类型本身不敏感直接把日期列喂进去只会被当成高基数离散值学不到周期规律。所以第一步一定是把日期拆成数值字段月份、星期几、是否周末、距月末还有几天。这些字段看着朴素但对出库量的周季节性和月结节奏非常有效。import pandas as pd def build_time_features(df, date_colftr_date): df df.copy() t pd.to_datetime(df[date_col]) df[month] t.month df[day] t.day df[weekofyear] t.isocalendar().week df[weekday] t.weekday df[is_weekend] (df[weekday] 5).astype(int) df[days_to_month_end] t.days_in_month - t.day df[is_month_end] (df[days_to_month_end] 3).astype(int) return dfweekday 提供周内周期信号is_weekend 捕捉周末出货节奏is_month_end 标记月末前三天。days_to_month_end 比 is_month_end 信息量更大因为树模型可以在连续值上自己找切分点而不是只能依赖一个粗粒度的0/1标签。注意 isocalendar().week 在部分 pandas 版本里返回的是元组取值时要写成 .week否则特征列会变成带结构的对象类型。节假日要不要单独建特征是这个赛题里比较玄学的部分。粗粒度的“是否法定节假日”标签往往过拟合到样本里仅有的几个大促日我一般先不加或者只加一个“距最近大促天数”的连续特征让模型自己决定怎么用。3.2 滞后特征与滚动统计需求预测最稳定的特征来源时间序列回归和普通回归最大的区别在于历史目标值本身就是最重要的特征。滞后特征就是把过去第1天、第3天、第7天、第14天的实际出库量搬到今天这一行滚动特征则是过去一段窗口内的均值、最大值、标准差。这两组特征组合起来基本决定模型的分数下限。def build_lag_features(df, group_cols[warehouse_id, sku_id], targetqty): df df.sort_values([warehouse_id, sku_id, ftr_date]) for lag in [1, 3, 7, 14, 21, 28]: df[fqty_lag_{lag}] df.groupby(group_cols)[target].shift(lag) for win in [7, 14, 28]: df[fqty_roll_mean_{win}] ( df.groupby(group_cols)[target] .transform(lambda x: x.rolling(win, min_periods3).mean().shift(1)) ) df[fqty_roll_max_{win}] ( df.groupby(group_cols)[target] .transform(lambda x: x.rolling(win, min_periods3).max().shift(1)) ) return df这段代码里最关键的是 rolling 之后的 shift(1)。如果不加 shift滚动均值会把当天真实出库量也算进特征里模型在验证阶段等于直接读答案本地分数虚高提交后立刻翻车。shift 的顺序是“先聚合到窗口再整体往后挪一天”保证特征构造时只用目标日之前的信息。滞后步长选 1、7、14、28分别覆盖昨日、上周同期、半月节奏和整月节奏。min_periods3 的意思是窗口内至少要有3个有效值才计算均值前期样本不足时置为 NaN。LightGBM 和 XGBoost 原生支持 NaN 缺失所以这些空值不用填充。如果后面要把特征喂给线性模型或神经网络需要先用中位数或0把 NaN 补上这一点我放到第5章排查部分再细说。3.3 分仓与SKU维度的交叉特征让分仓规划信息前置进入模型有了时间和滞后特征之后模型已经知道“这个仓这个SKU过去卖得怎么样”但它还不知道“这个SKU在整体网络里是怎么分布的”。分仓规划要回答的问题是货放到哪而历史的分仓比例恰好是这个问题的答案。把分仓比例提前做成特征预测模型输出的总量里就自然带上了分配结构。def build_cross_features(df): df df.copy() grp df.groupby([warehouse_id, sku_id])[qty] df[wh_sku_median_qty] grp.transform(median) df[wh_sku_std_qty] grp.transform(std) wh_total df.groupby(warehouse_id)[qty].transform(sum) df[wh_share] wh_total / df[qty].sum() sku_total df.groupby(sku_id)[qty].transform(sum) df[sku_wh_share] df[qty] / sku_total return dfwh_sku_median_qty 代表这个组合的典型出货水平是稳定的中心趋势特征。sku_wh_share 的含义是“这款SKU在某个仓的出库量占它全网出库量的比例”如果这个值长期稳定在0.6说明这个仓是该SKU的主力发货仓分仓规划只需在这个基础上微调如果两个仓的占比一直在波动说明存在仓间调拨行为模型需要更强的滚动特征才能捕捉这种切换。wh_share 则衡量仓库本身的规模权重规模大的仓在全网占比高它的预测误差会被评价指标放大特征里带上这个信息有助于模型自动加大对该仓的拟合力度。交叉特征放在滞后特征之后构建是因为 sku_wh_share 的分母要用全量历史不能像滞后特征那样按时间窗口滚动计算。用 transform 而不是 groupby 后 merge是为了保持行顺序和索引不变后续拼接特征表时不用重新对齐。4. 建模与融合从单棵回归树到多模型Stacking的完整链路4.1 基线模型用LightGBM快速拿到分数下限特征工程做完第一步永远不是调参而是先用默认参数跑一个LightGBM回归器把分数下限摸清楚。LightGBM 对高基数分类特征和稀疏特征都友好训练速度快在这个赛题里基本是首选基线。import lightgbm as lgb import numpy as np def rmse(y_true, y_pred): return float(np.sqrt(np.mean((y_true - y_pred) ** 2))) params { objective: regression, metric: rmse, learning_rate: 0.05, num_leaves: 63, max_depth: 7, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, lambda_l2: 1.0, verbose: -1 } dtrain lgb.Dataset(X_train, y_train) dvalid lgb.Dataset(X_valid, y_valid) model lgb.train( params, dtrain, num_boost_round2000, valid_sets[dvalid], callbacks[lgb.early_stopping(100), lgb.log_evaluation(100)] )objective 用 regression 是因为预测目标是连续出库量metric 用 rmse 直接对标赛题评价方式。num_leaves 和 max_depth 控制树复杂度这个赛题的特征维度普遍在几十到上百之间叶子数从 31 起调过拟合时会看到验证集 rmse 在早停之前先涨后跌。feature_fraction 和 bagging_fraction 都设为 0.8用列采样和行采样增加随机性这样后续融合时不同模型之间差异更大融合收益更高。参数调整的主方向是观察早停轮数如果 early_stopping 在 50 轮以内触发说明学习率偏高或模型过拟合把 num_leaves 降一档如果 2000 轮跑满还没停说明学习率太低可以尝试 0.1 再配合更深的树。lambda_l2 是正则化项特征噪声大时适当加大能压住抖动。4.2 模型融合第一步加权平均比想象中稳同一个训练集上跑 LightGBM 和 XGBoost因为两个模型的切分策略和缺失值处理方式不同误差分布在局部会有错位。把两个预测加权平均往往能在不增加训练成本的情况下拿到稳定的分数提升。权重不是拍脑袋定的而是在验证集上按步长搜索出来的。def search_weight(pred_a, pred_b, y_true): best_w, best_score 0, float(inf) for w in np.arange(0, 1.05, 0.05): pred w * pred_a (1 - w) * pred_b score rmse(y_true, pred) if score best_score: best_w, best_score w, score return best_w w search_weight(pred_lgb, pred_xgb, y_valid) pred_blend w * pred_lgb (1 - w) * pred_xgb搜索步长 0.05 足够做粗筛。如果最优权重落在 0.9 以上说明 LightGBM 明显强于另一个模型此时融合只是纪律性的小幅修正如果权重接近 0.5说明两个模型水平相当融合带来的提升一般最大。权重搜索必须只在验证集上做如果在全量训练集上搜索等于把验证集信息泄漏进融合系数测试集分数会被高估。4.3 融合进阶KFold交叉训练与Stacking加权平均是融合的下限Stacking 是上限。Stacking 的思路是先用两个基模型分别对训练集和测试集做预测然后把基模型的预测结果作为新特征训练一个元模型组合它们。这里有个前提问题——基模型在自己训练过的数据上预测会过拟合所以必须用 KFold 交叉生成 OOFOut-of-Fold预测。from sklearn.model_selection import KFold from sklearn.linear_model import Ridge def get_oof(model_fn, X, y, n_splits5): kf KFold(n_splitsn_splits, shuffleTrue, random_state42) oof np.zeros(len(X)) for tr_idx, va_idx in kf.split(X): m model_fn(X.iloc[tr_idx], y.iloc[tr_idx]) oof[va_idx] m.predict(X.iloc[va_idx]) return oof def stacking_fusion(X, y, oof_lgb, oof_xgb, test_lgb, test_xgb): meta_X np.column_stack([oof_lgb, oof_xgb]) meta_test np.column_stack([test_lgb, test_xgb]) meta_model Ridge(alpha1.0) meta_model.fit(meta_X, y) return meta_model.predict(meta_test)模型融合的整体链路是X 和 y 先切成 5 折每一折轮流做验证集基模型在其余 4 折上训练并输出验证集的预测最终拼出完整的 OOF 向量。测试集同样用每一折训练出的模型分别预测再取平均得到 test_lgb 和 test_xgb。元模型选 Ridge 而不是线性回归是因为 alpha1.0 的正则项能防止某个基模型的预测权重被撑得过大。融合这一层最容易犯的错误是把 OOF 和测试集预测混着用。如果直接把基模型在全量训练集上训练的预测结果当作 OOF元模型看到的是带记忆的分数泛化能力会明显变差。每次跑融合脚本我都会先验证 OOF 分数和全量训练分数的差距差距超过 10%就说明 OOF 生成过程出了问题。5. 避坑复盘与排查分数上不去时按这个顺序自查5.1 三处高频翻车点第一个翻车点叫未来泄漏。现象是本地验证 RMSE 低得离谱提交后线上分数完全对不上或者本地和线上分数差了一大截。原因是滚动统计或滞后特征漏加了 shift模型在验证阶段直接读到了当天的真实值另一个常见来源是对全量数据做特征标准化时把测试集的分布信息带进了训练集。解决方法是写一个泄漏检查函数取验证集最后一天的样本检查它的每个特征列里是否出现了当天的真实出库量一旦出现就是泄漏。从那以后我每次跑时序赛题强制先跑一遍这个检查再进模型。第二个翻车点是长尾SKU把整体误差拉爆。现象是整体 RMSE 一直卡在某个值下不去画出误差分布后发现所有大误差都集中在少数爆款SKU上。原因是回归模型对极端值敏感一个爆款周末出货量可能是平时十倍预测差出500件的平方误差够几百个长尾SKU的误差总和。常见做法是对 qty 做 log1p 变换后再训练提交前 expm1 还原或者按销量分成“爆款”和“长尾”两个样本集分别建模爆款走专门的模型长尾走一个轻量模型。这套分层思路对这类销量差异极大的场景基本有效。第三个翻车点是节假日特征负优化。现象是加了“是否节假日”这个0/1标签之后训练集分数涨了验证集和线上分数反而掉。原因是样本里大促日和节假日总共就几天树模型很容易把这几天当噪声记住。解决方法是先跑一版不带任何节假日特征的基线再跑一版带“距最近大促天数”连续特征的版本用验证集对比哪个更稳。粗粒度标签不是不能用而是必须配合足够的样本量赛题场景里样本量不够时连续距离特征往往表现更好。5.2 分数异常时的排查套路分数不对时不要直接改模型参数按下面顺序排查命中率最高。第一步查数据对齐。特征表行数跟标签行数必须完全一致groupby 操作容易在聚合级别上丢掉组合。直接用 assert len(feat_df) len(label_df) 检查再对 warehouse_id sku_id ftr_date 三键做一次唯一性校验有重复行会导致后续训练和验证的样本对不上号。第二步查时间切分。验证集必须按时间先后切分不能随机切分。随机切分等于把未来的信息混进训练集验证分数虚高线上立刻现原形。常见做法是按 ftr_date 排序用最后14天或21天做验证集。第三步查特征时滞。用 5.1 里说的泄漏检查函数重点看 qty_roll_mean 和 qty_lag_1 这两列。rolling 的 shift 位置写错是最隐蔽的代码检查肉眼看不出来必须拿数据验证。第四步查缺失值传播。rolling 特征在训练集早期会产生 NaNLightGBM 能处理空值但进入 Stacking 的元模型之前必须填充。填充策略我一般用全量训练集中位数值而不是0因为0会引入“这个仓这个SKU没出货”的错误信号。第五步查提交格式。行数、列名、排序和预测值类型四个维度用 2.3 里的 validate_submit 函数跑一遍。格式问题不报错但直接得低分最浪费感情。6. 二次开发把赛题代码改造成可用的预测小系统6.1 把总量预测改成每日粒度输出赛题输出的预测周期是汇总量但生产环境里更常见的是“未来每一天的出货量”。最简单的改造不需要重新训练模型先用现有模型预测整个周期的总量再用过去28天该仓库SKU的“周内日占比分布”把总量拆到每天。day_share df.groupby(weekday)[qty].mean() day_share day_share / day_share.sum() total_pred model.predict(feat_agg) daily_pred total_pred * day_share.values这个方案保留了原模型的整体准确度又让下游系统拿到每天的量做产能安排。如果日粒度误差要求更高下一步再按“仓库SKU星期几”构建7个分模型工作量会翻倍但精度提升并不总是成正比。6.2 把分仓规划从规则改成带约束的贪心分配原题里的分仓规划多数用规则表实现二次开发时我会把它换成最简贪心分配按单位运输成本从低到高优先装满成本低的仓库。def alloc_by_cost(pred_qty, wh_capacity, unit_cost): sorted_wh sorted(wh_capacity.items(), keylambda x: unit_cost[x[0]]) remain pred_qty plan {} for wh, cap in sorted_wh: plan[wh] min(cap, remain) remain - plan[wh] if remain 0: break return plan这段代码是按“成本优先”分配plan 里每个仓分配的量就是实际备货建议。业务里要加时效约束比如某区域必须在24小时内达只要把 wh_capacity 改成该区域内可用仓的容量集合即可循环逻辑不用动。6.3 更新节奏每周重训练加自动回退从赛题代码迁移到生产我把训练周期改成每周自动重跑一次验证数据用最近两周的历史。每次重训练完成后跑一遍离线回测新模型在最近两周数据上的 RMSE 低于旧模型才上线否则回退到上一版。这个机制避免了节假日或大促后模型被异常数据带偏。从那以后我每次做时序类预测都用第5章那套泄漏检查规则兜底再往下写业务代码希望帮到你。本文还有配套的精品资源点击获取