库存需求预测这件事做过的人都知道难点从来不是“预测”本身而是需求数据里藏着的两个结构一个是时间上的周期性一个是SKU之间的关联性。传统的时间序列模型像LSTM、GRU很擅长抓时间维度但对SKU之间的相互影响基本无能为力。而库存需求恰恰又强依赖于这种关联——某个地区促销关联地区需求跟着波动某个SKU断货替代品的需求立刻上涨。这就像是在一个网络里做预测只看单个节点是看不全的。GCN图卷积神经网络天然就是处理这类“节点关系”问题的而库存需求预测恰好又是一个典型的时序问题。所以把GCN和递归结构结合起来做一个“图上有时间”的模型是我认为解决库存需求预测比较靠谱的方向。这篇文章把整个方案的来龙去脉、图结构怎么建、模型怎么搭、训练调参有哪些坑、上线部署要解决什么问题全部拆开讲一遍。1. 项目背景与整体设计思路1.1 为什么传统时序模型解决不了库存需求预测先看一个很常见的业务场景。某公司有1000个SKU分布在5个仓库每天需要预测未来7天的需求量然后据此安排补货。如果直接用LSTM建模一个SKU一个模型操作上是可行的但模型之间完全割裂。问题在于SKU之间的需求并不是独立的。举一个具体的情况A产品是B产品的上游原料A缺货两天B的生产计划立刻受影响C产品和D产品是替代关系C涨价D的需求量就会上升同一个地区仓的多个SKU共享同一个客户群体节假日来临时一起涨。这些都是“图”上的关系而不是“序列”上的关系。用生活化的类比来理解预测一个城市某个路口的车流量只看这个路口过去几天的历史数据准确率有限。但如果把周边所有路口的数据一起看进来哪些路口是它的上游、哪些是它的下游、哪些和它平行把这些关系建模进去预测的准确度会明显提升。GCN解决的就是“看周边路口”这个问题。1.2 GCN与递归结构结合的基本思路所谓“图与递归结构相结合”思路可以拆成两条线GCN负责空间维度把每个SKU当做一个图节点SKU之间的关联关系相关性、替代性、上下游关系等当做图上的边通过邻居聚合方式让每个节点学会“参考别人”的信息。GRU或LSTM负责时间维度把每个节点的历史序列编码成隐藏状态捕捉需求的时间演变规律。两条线结合起来就是一个时间步内先做空间聚合再做时间更新下一个时间步重复这个过程。这样模型学到的不只是“这个SKU的历史需求”还包括“这个SKU在当前时刻受其他SKU影响之后的状态”。1.3 这个方案适合谁、能解决什么问题这个方案主要适合以下几类情况SKU数量在几百到几万之间且SKU之间存在明显的联动关系。如果只有十几个SKU且相互独立用标准LSTM就够了没必要引入图结构。库存需求受促销、季节、上下游传导影响明显的零售、电商、制造行业。这类场景下需求数据的空间关联比较强。已经有一定时序预测经验想进一步提升预测精度的团队。GCN不能替代基础的数据清洗和特征工程它是在这些基础之上增加一个结构化的建模维度。一句话总结如果你发现单SKU的时序模型预测结果整体不错但某些SKU系统性偏差尤其是促销、节假日前后偏差特别大那大概率是缺少关联建模GCN递归结构就是冲着这个问题去的。2. 核心细节解析与实操要点2.1 图结构构建哪些节点相连权重怎么算图结构是整个模型的基石这块做得不好后面模型再精巧也没用。构建图结构有两种主要路径实际项目中经常组合使用。第一种是数据驱动方式也是最容易起步的方式。对每个SKU的需求序列做皮尔逊相关系数计算相关系数高于某个阈值比如0.6的两个SKU之间连一条边边的权重就是相关系数。这种方式能自动发现需求同步涨落的SKU群体实现起来最简单适合冷启动。第二种是业务驱动方式。如果企业本身有仓网结构、SKU上下游关系、替代关系等数据可以直接用这些关系建边。比如同一个配送中心的SKU之间、原材料和成品之间、替代品之间都直接连边。这种边的可信度最高但往往覆盖不全单一使用容易漏掉数据中隐含的关联。实际项目里我通常用“业务边数据边”混合方案有明确业务关系的连业务边没有明确业务关系但相关性显著的连数据边。这样既保证结构可信度又不漏掉潜在关联。2.2 动态图还是静态图这是一个容易被忽略但影响很大的选择。库存场景里SKU之间的关系并不是一成不变的。季节不同主推商品不同SKU之间的关联强度也会变化。静态图的优点是稳定、训练成本低、易于调参适合需求结构相对稳定的场景。动态图的优点是能捕捉关系的变化但实现复杂度明显上升要么每隔一段时间重新算一次相关矩阵要么把边权设为可学习参数让模型自己更新。前一种实现简单但比较粗糙后一种效果好但过拟合风险更高。我的建议是先把静态图跑通验证模型整体效果后再做动态化的迭代。前文提到过库存需求预测涉及供应链网络、SKU上下架、促销节奏逐次更新盲目追求动态图反而帮不上忙。2.3 节点特征与时间窗口设计GCN的节点特征不只是历史需求值还可以把和需求相关的因素都编码进去历史需求过去N天的实际值库存状态当前库存水平、在途库存促销信息是否促销、促销强度价格信息当前价格、价格变化率日期属性星期几、月份、节假日标记把特征堆叠成一个矩阵维度是[节点数, 特征维度]让GCN一次性聚合节点特征和邻居特征。时间窗口的选择直接影响模型对周期性的捕获能力。对于日粒度的需求数据窗口取14到30天比较合适太短学不到周期模式太长计算量上去了但收益有限。预测步长取决于业务决策节奏。每周补货预测用7天月度预算预测用30天。先明确业务要什么再定预测步长不要盲目追求更长的预测周期。2.4 模型结构的关键设计与语义解释在GCN与递归结构结合的具体实现上我强烈推荐“深度耦合”方式。每个时间步内先做GCN聚合再把聚合结果传给GRU更新隐藏状态这样空间影响伴随着时间逐步演进。举个例子说明这个设计的意义省会仓库的需求波动可能经过一到两天传导到地市仓。深度耦合结构在每个时间步都会执行一次“先聚合邻居当前状态再更新自身隐藏状态”这样模型有机会学到“邻居的昨天影响我的今天”这类延迟相关规律。如果GCN和GRU是分阶段串联——先把所有时刻跑完GRU再做GCN聚合——这种时间上的延迟传导就丢了。代码骨架如下用PyTorch Geometric实现GCN层再封装进GRU单元import torch import torch.nn as nn from torch_geometric.nn import GCNConv class GCNGRUCell(nn.Module): 在GRU的每个时间步内先做GCN聚合 def __init__(self, in_channels, hidden_channels, edge_index, num_nodes): super().__init__() self.gcn GCNConv(in_channels, hidden_channels) self.gru nn.GRUCell(hidden_channels, hidden_channels) self.edge_index edge_index self.num_nodes num_nodes def forward(self, x_t, h_t): # x_t: [num_nodes, in_channels]当前时刻所有节点的特征矩阵 # 图卷积聚合邻居信息 x_gcn self.gcn(x_t, self.edge_index) # 更新隐藏状态 h_next self.gru(x_gcn, h_t) return h_next class GCN_GRU_Model(nn.Module): def __init__(self, input_dim, hidden_dim, output_dim, edge_index, num_nodes, seq_len): super().__init__() self.seq_len seq_len self.output_dim output_dim self.num_nodes num_nodes self.cell GCNGRUCell(input_dim, hidden_dim, edge_index, num_nodes) self.fc nn.Linear(hidden_dim, output_dim) def forward(self, x): # x: [seq_len, num_nodes, input_dim] h torch.zeros(self.num_nodes, self.cell.gru.hidden_size).to(x.device) for t in range(self.seq_len): h self.cell(x[t], h) # h: [num_nodes, hidden_dim]映射到预测维度 return self.fc(h)这个结构有几个关键点GCN层的输入是当前时刻所有节点的特征矩阵edge_index是预先算好的图结构在整个前向过程中保持不变GRU的隐藏状态h在每个节点上独立维护一份相当于每个SKU都有自己的“记忆”但这个记忆的更新会受到邻居当前状态的影响。2.5 损失函数与评估指标怎么选才符合库存业务库存预测的评估不能只看单一的RMSE。SKU量级差异很大几十件的SKU和上万件的SKU混在一起一个大的SKU的偏差可能掩盖所有小SKU的偏差。实际项目中我习惯同时看三个指标RMSE反应整体量级误差、MAE更稳健的平均误差、分SKU量级的分组MAE避免大SKU掩盖小SKU。损失函数方面如果只求平均准确MAE就够了。如果业务上断货损失和超储损失不同就需要设计不对称损失。比如超量预测导致库存积压损失为每件1元而缺货预测导致丢单损失为每件2元那缺货的惩罚系数就应该是超量的两倍def asymmetric_loss(pred, true, over_penalty1.0, under_penalty2.0): 超量预测惩罚1.0缺货预测惩罚2.0 diff pred - true loss torch.where(diff 0, over_penalty * diff, under_penalty * -(diff)) return loss.mean()惩罚系数一定要根据实际的业务成本测算而不是拍脑袋。这个微调对最终补货决策的影响往往比更换模型结构更直接。3. 实操过程与核心环节实现3.1 环境依赖与库版本组合实操部分的代码基于以下环境验证过Python 3.9 PyTorch 1.13 PyTorch Geometric 2.2 NumPy 1.24 Pandas 1.5 scikit-learn 1.2PyG的安装是一个常见坑点。直接用pip install pyg经常会遇到和PyTorch版本、CUDA版本不匹配的问题装完导入就报错。建议直接用官方索引安装先确认自己的PyTorch对应版本再执行pip install torch1.13.0cu116 torchvision0.14.0cu116 \ --extra-index-url https://download.pytorch.org/whl/cu116 pip install pyg2.2.0 \ --find-links https://data.pyg.org/whl/torch-1.13.0cu116.htmlCPU版本就去掉cu116后缀用CPU对应的索引。这个环节看起来基础但确实值得认真处理版本不对能让后续所有工作卡在导入阶段。3.2 模拟数据的生成与预处理为了跑通整个流程可以生成一个带周期性和SKU相关性的模拟数据。下面这段生成3组具有相关性的库存需求序列import numpy as np import pandas as pd np.random.seed(42) n_skus 30 n_days 365 * 2 dates pd.date_range(2023-01-01, periodsn_days, freqD) trend np.linspace(0, 20, n_days) # 缓慢增长趋势 sim_matrix np.zeros((n_days, n_skus)) for sku in range(n_skus): base 50 np.random.normal(0, 10) season 8 * np.sin(2 * np.pi * np.arange(n_days) / 7) # 周季节效应 # 部分SKU共享随机冲击模拟业务联动 common_shock 10 * np.random.randn(n_days) sku_noise 5 * np.random.randn(n_days) rel_sku np.random.choice(n_skus) common_shock_sku 5 * np.random.randn(n_days) * (np.arange(n_skus) rel_sku) sim_matrix[:, sku] base trend / 10 season common_shock * 0.3 common_shock_sku sku_noise df pd.DataFrame(sim_matrix, indexdates, columns[fSKU_{i:02d} for i in range(n_skus)])预处理阶段的几个关键细节缺失值处理不要直接删除当天记录会破坏时间连续性。用前向填充加3日滚动均值做平滑。归一化SKU量级差异大对每个SKU单独做z-score归一化。用训练集的统计量去归一化验证集和测试集防止数据泄露。协变量对齐促销标记、节假日、星期几等特征必须和需求序列按时间对齐拼进同一个特征矩阵。3.3 从原始序列构建图数据管道用模拟数据构建图结构核心代码如下from scipy.stats import pearsonr def build_graph_from_corr(demand_matrix, threshold0.6): demand_matrix: [n_days, n_skus] n demand_matrix.shape[1] edge_index [[], []] edge_weight [] corr_matrix np.corrcoef(demand_matrix.T) for i in range(n): for j in range(i1, n): if abs(corr_matrix[i, j]) threshold: edge_index[0].append(i) edge_index[1].append(j) edge_weight.append(corr_matrix[i, j]) edge_index_tensor torch.tensor(edge_index, dtypetorch.long) edge_weight_tensor torch.tensor(edge_weight, dtypetorch.float) return edge_index_tensor, edge_weight_tensor图构建后一定要做连通性检查。阈值太高图分裂成多个互不相连的小团体GCN退化成单节点模型阈值太低图密集到每个节点都和所有节点相连图结构就失去了稀疏筛选的意义。经验值是从0.5起步每次调0.05同时观察训练集loss下降速度和验证集过拟合情况。数据管道我习惯封装成迭代器每个batch返回一个包含时间窗口输入、未来N天目标、边索引的完整数据块。数据量不太大时直接用张量索引组装batch不用走复杂的图批次逻辑。3.4 训练流程关键配置与收敛调优训练时learning rate从0.001起步用ReduceLROnPlateau让学习率在验证loss不下降时自动减半。批量大小64到128窗口长度14到30天。训练轮数要盯两个信号验证集loss开始反弹以及训练集loss已远低于验证集loss两者都说明过拟合开始触发early stopping。dropout在0.2到0.5之间。节点多、图稀疏时dropout可以稍高节点少、图稠密时dropout要低。我给每个GCN层后面加一个dropout层设置为0.3。优化器用Adam默认参数即可真正值得调的是weight decay从1e-4开始试。调优优先级是learning rate大于dropout大于weight decay。learning rate调好了后面两个基本只需要微调。3.5 线上预测时的序列外推技巧训练时输入窗口固定线上预测时有两种方式直接多步预测一次输出未来N天的预测值误差会在预测步之间累积。单步滚动预测每次只预测下一天拿到真实值后把它拼接到历史序列末尾再预测后续日期。这种方式精度更高但要求每天都能拿到真实需求。我实际跑过的项目里直接多步预测7天的MAPE比单步滚动高6到10个百分点。如果业务能每天拿到真实需求尽量用单步滚动如果只能一周获取一次数据只能做直接多步预测这个精度差异要在需求评审阶段和业务方讲清楚。4. 工程化落地从离线模型到线上服务4.1 模型接口设计与推理性能优化离线训练好的模型要变成线上服务第一步是设计清晰的预测接口。输入输出都用标准JSON格式方便其他系统调用{ skus: [SKU_01, SKU_02], history_length: 21, features: { demand: [...], inventory: [...], promotion: [...] }, predict_horizon: 7 }输出是一个二维数组[预测天数, SKU数量]保留两位小数。推理性能方面SKU数量从几千到几万不等GCN推理时间随节点数线性增长。要做几件事图结构预计算不要每次请求都重新算相关矩阵模型用ONNX导出省掉图构建的Python开销节点特征统一用预计算的均值方差归一化预测服务用异步任务队列管理长耗时请求。4.2 模型更新策略多久重训一次库存需求高度时变不能训练一次用一年。我常用的做法是每周重训一次用最近8到12周数据。如果数据量过大用增量微调代替全量重训拿上一版权重做初始化在新数据上跑几个epoch加early stopping。这样既控制成本又能让模型跟上需求变化。4.3 评估反馈闭环构建线下模型表现好不等于线上效果一定好。库存场景存在反馈回路模型预测需求业务根据预测补货实际观测到的需求又受到补货动作影响。所以线上指标不能只看MAPE还要看补货命中率实际需求落在预测区间内的SKU占比和断货率。每次预测后把真实需求、模型预测、库存变化三者记录下来。定期回溯分析可以发现模型在哪些维度存在系统性偏差比如某类SKU在促销期间始终预测偏低说明促销特征编码还不够准确。5. 常见问题与排查技巧实录5.1 训练不收敛的常见原因GCN对数据质量和数值稳定性比普通MLP更敏感。loss不降优先排查三类问题图退化成了全连接图或全孤立图节点特征几乎一致梯度失去方向。打印edge_index数量检查图的稀疏度。输入特征尺度差异过大激活函数梯度消失。检查归一化统计量压缩到0-1范围。edge_index节点编号越界部分框架版本会静默出错结果全错。检查节点数和特征矩阵行数是否一致。5.2 过平滑问题为什么堆叠多层GCN反而更差GCN层数超过3层节点特征会趋向同一这就是过平滑的典型表现。库存场景里强行堆叠4到5层预测结果会退化到所有SKU输出几乎相同的值。应对过平滑的三个方法控制GCN层数在1到2层需要表达能力时优先加大hidden_channel而不是加层数。加残差连接把GCN输入直接加到输出上保护节点自身特征。使用跳跃连接每层输出都累加到最终特征保留早期信息。5.3 冷启动SKU与OOV问题库存系统每天都有新增SKU这些SKU训练时没见过属于OOV问题。三类可落地的处理方式为新SKU找相似SKU复制已有SKU的embedding初始化。比如同类目、同价格带、同供应商的SKU需求曲线往往相似。用属性特征替代纯ID编码。每个节点除了ID还带上类目、价格带等属性遇到全新节点也能给出合理预测。周期性全量重训让模型重新认识全部SKU。增量微调加每周滚动重训基本能覆盖大多数新增SKU的问题。5.4 图结构更新的节奏和边界图结构影响模型行为不宜频繁变动。我的建议是按月更新图结构而不是按天。库存仓储网络的调整通常就是月度级别按天更新容易把噪声带进模型。如果发现某段时间SKU之间的相关性显著漂移可以在重训时重新计算相关矩阵作为候选图。用验证集对比新旧图的预测效果新图更好才切换。这种“候选图验证集对比”的方式既稳妥又能跟上业务变化。6. 效果观察与复盘建议6.1 如何科学对比GCN与其他算法的提升幅度模型效果对比必须在相同的验证集和时间窗口下进行。至少需要三个基线历史均值法过去30天简单平均作为预测值。标准LSTM/GRU单模型不做图结构。带注意力机制的Transformer时序模型。如果GCNGRU和纯GRU差距很小说明库存场景的空间关联比较弱或者图结构建得有问题。前文提过有一次排查了很久最后发现是相关性阈值设太低图里全是弱相关边。重新调阈值后GCN的优势才体现出来。对比时还要看训练成本。GCN多了一次邻居聚合计算在几千节点、几千时间步的数据规模下训练耗时通常是纯GRU的1.3到2倍。6.2 业务落地的三个关键条件技术方案再完整落地也需要满足三个条件数据时序完整性至少12个月以上历史数据才能覆盖周、月、季度周期。历史数据不足再高级的模型也发挥不出来。目标定义清楚需求预测和补货决策要分开。模型只做需求预测补货决策在上游的库存优化模块做耦合在一起很容易出设计问题。异常事件标注促销、大促、断货、退货等事件必须单独标注否则模型会把一次性事件当成常规规律学进去。6.3 从预测到决策的延伸思考库存需求预测本身只是一个起点真正产生业务价值的是预测结果驱动补货决策。沿着两条线可以继续延伸一是把预测分值转成补货计划。比如预测均值加安全库存公式生成建议补货量比较简单但需要和业务沟通好安全库存的设定方式。二是在预测基础上做多级库存优化在总仓、区域仓、门店之间做库存分配优化。这条线复杂度明显更高但价值也更大。如果项目周期允许先跑通“预测-补货建议”闭环再考虑多级优化。不要一上来就挑战最复杂的版本。我养成的习惯是每次模型迭代后把预测结果、真实值、图结构版本、模型版本记录在一张跟踪表里。前两次项目没有做这个出问题后回溯困难浪费了大量时间排查。现在有了这个记录复盘时几分钟就能定位是哪一版图、哪一版模型、哪段数据出了问题。这个习惯极大提高了迭代效率也算是我在库存预测项目里最重要的一条实操经验。
阅读完成 · 觉得有帮助?