做过库存需求预测的朋友应该都有同感:当预测对象从一个SKU扩展到几百上千个SKU时,纯粹的时间序列模型很快就到瓶颈了。单品看,每个SKU的销量曲线都像随机噪声;放到一起看,SKU之间却存在明显的联动关系——同品类、同门店、同供应商的商品,需求曲线往往一起涨一起跌。这种“横截面结构”恰恰是传统时序模型忽略掉的信息,也是我最终转向图卷积神经网络GCN的原因。
这篇文章想聊的,是我在库存需求预测项目里把GCN和GRU这类递归结构结合起来的一套完整方案:为什么需要图、图怎么建、GCN和递归网络怎么融合、数据管道有什么坑、评估指标怎么选。内容既适合刚接触图神经网络的读者建立直觉,也适合已经跑通基础模型的人做架构选型和调参参考。
1. 库存需求预测的痛点:单看时序,忽略了大盘结构
1.1 多SKU联动:被大多数模型忽略的横截面信号
我最早做库存预测用的是LSTM和Prophet这类经典方案,单SKU模型,每个商品各跑各的。效果怎么说呢——平稳品还凑合,一旦遇到促销、季节交替、同品类爆款带动,预测偏差就非常难看。
后来我把几个SKU的销量曲线叠在一起看,发现问题很明显:某个SKU销量上涨之前,往往同品类的另一个SKU已经先动了。比如某地区门店的饮料,功能饮料和碳酸饮料的销量存在明显的此消彼长或同涨同跌关系。这种关系如果只用单变量时序模型,是不可能捕捉到的。
这就是图结构的价值。把SKU或门店当成节点,把他们的关联关系当成边,一个规模很大的库存系统就变成了一张图。GCN做的,就是在这张图上做信息聚合——每个节点的特征,不再只是自己的历史销量,还包括邻居节点的状态。
1.2 传统模型和纯时序模型的卡点分别在哪
传统统计模型,比如ARIMA、指数平滑,假设序列是线性自相关的,处理不了复杂的外部冲击和多序列交互。Seq2Seq类的深度模型能拟合非线性,但输入通常是形状为[时间步, 特征维度]的矩阵,它本质上假设每个SKU是独立样本,只是在参数层面共享。
问题在于:库存系统的物理结构不是独立的。共享仓库、共同供应链、替代品和互补品关系、门店覆盖重叠,这些结构会真实地传导需求波动。忽略结构,等于默认SKU之间没有关系——但库存管理的人都知道,这恰恰是最大的噪声来源。
所以我的思路很直接:用图卷积神经网络GCN来建模SKU之间的空间结构,用GRU来建模每个节点的时间演化,两者结合,既看得到“谁和谁一起动”,又记得住“每个节点自己过去怎么动”。
2. GCN在这个场景里到底在算什么
2.1 把库存快照变成图信号:输入输出的形状问题
先说清楚GCN的输入输出,很多新手在这里卡住。
假设我们有N个SKU,每个SKU在t时刻有F维特征(历史销量、价格、库存量、促销标记等)。那么t时刻的图信号矩阵就是X^t,形状是[N, F]。图结构用邻接矩阵A表示,形状是[N, N],A[i][j]表示节点i和节点j的关联强度。
GCN做的是逐层传播:
H^(l+1) = activation( Ã · H^(l) · W^(l) )
其中Ã是归一化后的邻接矩阵,W^(l)是可学习的权重矩阵,H^(l)是第l层的节点表示。一开始H^(0) = X^t,经过几层传播后,每个节点的表示就融合了多跳邻居的信息。
在库存预测场景里,这个变换的物理意义是:一个SKU的需求预测特征,在每一层图卷积里都会被周围SKU的特征“修正”一遍。比如某个SKU近7天销量开始下滑,但它的互补品销量在涨,那么经过图卷积后,它的隐含特征会包含“情况可能没那么糟,互补品在承接需求”的信息。
2.2 邻接矩阵怎么定:三类构建方法,我最后用了组合方案
图神经网络效果好坏,一半以上取决于图建得对不对。A矩阵如果建错了,后面全是白算。
我实际对比过三类构建方法:
基于销量相关性:计算每个SKU历史销量序列的皮尔逊相关系数,取相关性大于阈值的对作为边。优点是好理解、容易实现;缺点是只能捕捉线性同步关系,且容易把“一起被促销带动”的伪相关也建进去。
基于时序形态距离:用动态时间规整(DTW)距离衡量两条销量曲线的形态相似度。相比皮尔逊相关,DTW能捕捉相位偏移的相似形态,比如某个SKU的销量提前另一SKU两天变动。这种滞后关系在库存传导里非常常见。
基于业务先验:同品类、同仓库、同供应商、同价格带——这些业务标签本身就是天然的结构信息。比如同一仓库的SKU共享物流资源,断货风险会互相传导;同供应商的SKU会一起受供货周期波动影响。
最终我采用的是组合方案:业务先验边作为必选边,保证图结构有稳定的业务解释;相关性边作为补充边,只保留前K个强相关邻居;DTW边用于捕捉有滞后性的联动。
| 构建方法 | 边的含义 | 优点 | 缺点 |
|---|---|---|---|
| 皮尔逊相关 | 同步线性联动 | 简单、可解释 | 漏掉滞后关系、易混入伪相关 |
| DTW距离 | 形态相似联动 | 能捕捉滞后联动 | 计算量大、阈值敏感 |
| 业务先验 | 供应链物理关联 | 稳定、可解释 | 覆盖不全,需要人工维护 |
注意:所有相似度都要转成邻接矩阵后做归一化,通常用对称归一化:Ã = D^(-1/2)(A + I)D^(-1/2)。加单位阵I是为了让节点在聚合时保留自身信息,否则一层卷积之后每个节点就只剩邻居的平均了。
2.3 谱域GCN和空间域GCN,我为什么选后者
图卷积大体分两派。谱域方法从图拉普拉斯矩阵的特征分解出发,在频域做滤波,数学漂亮但泛化性差——图结构一变,特征分解就得重算。空间域方法直接在邻居节点上做聚合,更像“对局部结构做共享参数的特征提取”。
库存预测的图结构是随时间变化的:新SKU上架、老SKU下架、季节性的联动关系变化。空间域GCN天然支持这种动态性,只需要在每一步把邻接矩阵更新一下。我选的是空间域GCN,更准确地说是GraphSAGE风格的邻居聚合,配合注意力机制给不同邻居分配不同权重。这样同一个SKU的“互补品邻居”和“替代品邻居”对预测的贡献可以自动学出差异。
3. 图与递归结构结合的两种主流架构
3.1 堆叠式:GCN抽空间特征,GRU管时间演化
最直观的做法是把两者串起来:每个时间步t,先用GCN对这个时刻的图信号X^t做空间编码,得到空间增强后的特征Z^t,然后把这些Z^t按时间顺序送入GRU,由GRU学习时间依赖。
模型结构可以用伪代码表达:
class GCN_GRU_Stack(nn.Module): def __init__(self, in_dim, hidden_dim, num_layers): super().__init__() self.gcn = GCN(in_dim, hidden_dim) # 空间特征提取 self.gru = nn.GRU(hidden_dim, hidden_dim, num_layers) self.regressor = nn.Linear(hidden_dim, 1) def forward(self, x, adj): # x: [batch, seq_len, num_nodes, in_dim] # adj: [num_nodes, num_nodes] seq_len = x.size(1) encoded = [] for t in range(seq_len): z_t = self.gcn(x[:, t, :, :], adj) # 每个时刻单独做图卷积 encoded.append(z_t) encoded = torch.stack(encoded, dim=1) # [batch, seq_len, num_nodes, hidden] # 把节点维度视作batch,时序维度送入GRU b, t, n, h = encoded.shape encoded = encoded.permute(0, 2, 3, 1) # [batch, num_nodes, hidden, seq_len] encoded = encoded.reshape(b * n, h, t) out, _ = self.gru(encoded) out = out[:, -1, :] out = out.reshape(b, n, -1) return self.regressor(out).squeeze(-1)堆叠式的优点是结构清晰、容易实现、训练稳定。我在早期版本里用的就是这种方案。但有个潜在问题:GCN和GRU是串行连接的,每个时间步的图卷积没有感知时间上下文,它只是对当前时刻快照做空间增强。对于“邻居的影响有滞后”这种情况,表达能力差一些。
3.2 交互式:把GRU内部的矩阵乘法换成图卷积
交互式的思路更激进:不是让GCN和GRU串行连接,而是把GCN直接嵌入到GRU的单元内部。GRU的核心计算是:
reset门:r_t = sigmoid(W_ir · x_t + b_ir + W_hr · h_{t-1} + b_hr) update门:z_t = sigmoid(W_iz · x_t + b_iz + W_hz · h_{t-1} + b_hz) 候选状态:n_t = tanh(W_in · x_t + b_in + r_t * (W_hn · h_{t-1} + b_hn)) 输出:h_t = (1 - z_t) * n_t + z_t * h_{t-1}
交互式做法的核心改动,是把x_t和h_{t-1}送入门控计算之前,先用GCN做一次空间聚合。也就是说,门控的输入从“自己的特征”变成“自己和邻居特征融合后的结果”。这种方式在时序建模和图结构学习之间建立了更强的耦合:每个时间步的时序状态更新都会感知当前图结构下的邻居状态。
实现上有点像GraphGRU的思路:
def graph_gru_cell(x_t, h_prev, adj, w_ir, w_hr, gcn_func): # x_t: [batch, num_nodes, in_dim] # h_prev: [batch, num_nodes, hidden_dim] x_g = gcn_func(x_t, adj) # 对输入做图卷积 h_g = gcn_func(h_prev, adj) # 对隐藏状态做图卷积 r = torch.sigmoid(w_ir(x_g) + w_hr(h_g)) z = torch.sigmoid(w_iz(x_g) + w_hz(h_g)) n = torch.tanh(w_in(x_g) + r * w_hn(h_g)) h = (1 - z) * n + z * h_prev return h交互式的优势在于:图结构的信息不仅仅是输入侧的“特征增强”,而是深度参与到了信息的遗忘和记忆决策中。例如仓库库存告急时,某个SKU的断货风险会通过邻居节点的隐藏状态直接传导到门控计算,模型可以学到“邻居状态差时,要调整对自身需求的记忆”。
3.3 两种架构的对比和我的最终选择
我在实际测试中,堆叠式训练速度快很多,参数量少,对图质量要求也相对低;交互式在测试集上的sMAPE大约能再低3到5个百分点,尤其是在联动效应明显的品类上提升显著,但对邻接矩阵的噪声更敏感,训练时需要更多耐心调学习率。
最终我采用的是交互式变体,但没有对每个时间步的h都做图卷积——那样计算量太大。我做了个折中:输入侧每个时间步做图卷积,隐藏状态侧每隔两个时间步做一次图卷积。这样保持了空间信息的参与感,又控制了训练开销。
4. 数据工程:决定模型上限的前置步骤
4.1 从订单流水到滑窗样本
模型输入需要的是滑窗样本:给定过去T步的图信号序列,预测未来h步的需求。以日粒度为例,我用过去28天的数据预测未来14天的需求。
每条样本的构造过程是:
- 按SKU和时间戳对齐,生成[N, 时间天数]的销量矩阵;
- 加入外部特征:当日价格、是否促销、是否节假日、天气温度、库存水位;
- 按时间滑窗切割:第i到i+T-1天作为输入窗口,第i+T到i+T+h-1天作为预测目标;
- 把窗口内的每个时间步拼成图信号X^t,形状为[N, F]。
这里容易忽略的是:预测目标不能只取一个点,库存补货决策需要的是未来h天的累计需求分布。所以我的预测头有两个分支:一个输出未来h天的期望值,一个输出分位数用于安全库存计算。
4.2 归一化和训练集切分容易踩的坑
库存销量的分布极度不均匀:有的SKU日均销量几百件,有的只有一两件。如果不做归一化直接训练,loss会被大销量SKU主导,小销量SKU的预测质量会非常差。我用的方案是:每个SKU单独做归一化,用训练集的均值方差归一化,验证集和测试集使用训练集的统计量,避免数据泄漏。
训练集和测试集的切分也需要注意。库存数据有强烈的季节性,最简单的时间切分很容易让模型“记住”消费节奏而非学习泛化规律。我推荐滑动窗口交叉验证:第一次用第1-12周训练、预测第13-14周;第二次用第4-16周训练、预测第17-18周;以此类推。这样每个季度都有机会出现在验证集里。
4.3 外部特征怎么进入图结构
价格、促销、节假日这些特征,如果直接拼在X^t矩阵里,GCN也能学到它们的空间传播效应。但有个更好的做法:把外部特征拆成两类。一类是节点自身属性,直接进X^t;另一类是全局环境变量,不经过图卷积,在GRU的输出阶段拼接进去,和图的隐向量一起送入预测头。
我这样做的原因很实际:促销和节假日影响的是整个大盘,不是某个节点特有的邻居关系,让它们绕开图卷积,可以减少图结构对全局信号的干扰。实测下来这种拆法比全部塞进图信号的方案稳定。
5. 实验设计、消融评估:怎么证明图结构真的有用
5.1 基线该怎么选,选谁才有说服力
要让团队和业务方信服这套方案,不能只靠一张预测效果图。我跑了四组基线:
- 单变量LSTM:经典时序模型,不建模SKU关系;
- 带特征的XGBoost:把历史销量和外部特征拉平,按SKU分组训练;
- 纯GCN + 线性时序头:验证递归结构是否存在必要;
- GCN + GRU堆叠式:和交互式对比架构差异。
结果有意思的是,纯GCN因为缺少时序记忆,在需求波动大的SKU上表现很差——这从侧面说明递归结构在图场景下依然是必需品,图结构不能替代时间记忆,只能增强它。
5.2 评估指标:别只用RMSE,库存场景要用sMAPE和MASE
库存预测不能只看RMSE,因为不同SKU的量纲差异太大,RMSE会被头部SKU主导。我建议至少看三个指标:
sMAPE(对称平均绝对百分比误差):sMAPE = 200% * |预测值 - 实际值| / (|实际值| + |预测值|)。它对大销量和小销量都公平,但注意实际值接近0时还是会失真,需要加平滑项。
MASE(平均绝对标度误差):用模型误差除以naive预测误差。所谓naive预测,就是用昨天销量预测今天销量。MASE小于1说明模型比naive强,这个指标很适合向业务方解释。
Pinball Loss(分位数损失):如果想输出概率区间用于安全库存,分位数损失是标准选择。分位数预测的价值在于:业务方需要的不只是“未来卖多少”,更是“未来需求在什么区间”,区间宽度直接决定补货数量和资金占用。
5.3 消融实验的三个关键对比
我做过的消融实验里,最有说服力的三个对比是:
- 去掉邻接矩阵里的DTW边,只看相关性和业务先验边——滞后联动的SKU组预测误差上升明显;
- 把交互式改成堆叠式——整体误差上升,验证了深度耦合的价值;
- 把邻接矩阵随机打乱,保持参数规模不变——误差涨得最多,用来证明模型学到的不是噪声。
第三点尤其重要。跑完随机图对比后,业务方基本就认可了这套方案不是玄学。
6. 踩过的坑和调参经验
6.1 冷启动问题:新SKU没有历史销量怎么办
库存系统永远不缺新SKU。新品的邻接矩阵没有历史相关性可算,如果直接让它的特征全是零,图卷积聚合出来的表示也接近零,预测自然很离谱。
我的处理分三步。第一步,用业务先验边的信息做“邻居初始化”:新品上线时,自动匹配同品类、同价格带、同供应商的老SKU作为邻居,用邻居的历史销量均值做特征初始值。第二步,在邻接矩阵里给新品加一个很小的随机扰动,避免所有邻居权重完全相同导致梯度消失。第三步,模型对冷启动SKU的预测设置更大置信区间,宁宽勿窄。
6.2 过平滑:GCN不是越深越好
图卷积一个著名的问题就是层数多了会过平滑——每个节点的表示越来越像,最后趋同。库存图上这个现象尤其明显,因为大部分节点的度分布高度不均匀(少数热门SKU连接了大量边)。
实测下来,2到3层图卷积在库存场景最合适。不要盲目堆深度,如果觉得感受野不够,优先考虑增大每层隐藏维度,或者加上残差连接,而不是加层数。我甚至在最终版本里只保留了2层GCN加上一层注意力聚合,这是调参实验里效果和训练速度的平衡点。
6.3 训练不稳定:图结构引入的方差怎么压
GCN训练初期很容易震荡。一个原因是邻接矩阵的归一化方法不对,导致不同节点的梯度尺度差异巨大。我建议严格使用对称归一化,并在训练初期对图卷积层的输出做LayerNorm。
另外一个原因是数据样本之间的关联度太高——同一张图、相邻时间窗口的样本在特征上高度相似,模型容易过拟合到时间邻近的样本上。解决办法是:每个epoch里对训练样本做随机滑窗采样,同时引入时间维度的dropout,随机屏蔽部分历史时间步,强制模型不依赖特定时间位置的信息。
6.4 预测结果的可解释性:图模型容易被认为是黑盒
库存预测的模型要过业务评审,可解释性绕不开。我做的方案是:在预测完成后,把最近一个时间步的注意力权重取出来,看每个节点聚合了哪些邻居的信息,转成一张“需求关联热力图”。
这样做有两个实际价值:一是数据团队可以核对图结构是否符合业务直觉,比如“可乐和雪碧应该强关联,如果热力图显示它们没有关联,那说明图建得有问题”;二是市场团队可以把热力图当作促销效果的分析工具,看历史促销是否真的激活了关联品类的需求。
个人经验:图神经网络落地的关键,从来不是模型本身的精度,而是图结构是否和业务逻辑对齐。技术团队和业务团队一起审图、一起定义边的含义,比单独追求评估指标提升有效得多。
做了这一整套之后,库存预测项目的数据大概是这样:常规SKU的预测准确率提升不算夸张,但在联动效应明显的品类上,sMAPE相比原先的单变量LSTM方案下降了8到10个百分点,冷启动SKU的预测误差下降了差不多20个百分点。这个收益不在于模型本身多精妙,而在于它把库存系统里一直存在但没被利用的结构信息真正用起来了。如果你也在做多SKU库存预测,我建议从构建一张靠谱的关联图开始,先把图建对,再去纠结GCN的层数和GRU的隐藏维度。