多变量时间序列预测做到后面,你会发现最折磨人的不是单条序列的趋势拟合,而是多条序列之间那只可意会不可言传的交互关系。我前期做一个园区光伏与负荷联合预测的项目,光伏出力、空调负荷、楼宇用电几条曲线摆在一起,相关性就像夏天的云,风向一变关系就变,今天还能用的加权组合明天就失灵。后来跳出来换个思路,用细粒度跨变量卷积去建模动态变量交互,才把很多之前说不清楚的现象逼到了墙角。这篇博文就围绕 FACT 展开,把问题背景、模型拆解、工程实现和应用场景完全讲透。适合正在做多序列联合预测、对比 Transformer 或 MLP 类时序方案的读者,也适合刚接触跨变量建模、想搞清楚“变量交互”到底该怎么落地的朋友。
1. 问题拆解:为什么“动态变量交互”才是多变量预测的核心矛盾
1.1 静态交互假设的隐患
先说一个常见场景。做能耗预测时,大家习惯把外部温度、湿度、节假日标记、历史负荷全部拼成一个特征向量喂给模型。模型学到的,本质上是一组固定的加权关系:温度升高多少度,负荷大约增加多少千瓦。这种线性或固定权重的交互,在数据平稳的月份里很准确,但一旦进入过渡季节,早晚温差大、人流量变化明显,温度与负荷之间的敏感程度会在一天内反复横跳。再用一个全局固定的相关系数去描述,结果必然是一部分时段预测偏大、另一部分偏小。
这个痛点不止存在于传统回归和 VAR 模型,很多神经网络同样掉进这个坑。MLP 或者 TCN 虽然能拟合非线性映射,但它们的权重在训练完以后就固定了。输入变量的排列组合再复杂,模型也只能按训练阶段统计出来的“平均交互强度”去推断。我们把这种建模方式叫做静态交互假设:假设变量之间的关系在整个使用期间是恒定不变的。
在实际业务中,这个假设极少成立。拿交通流量来说,早高峰时路段 A 和路段 B 的拥堵存在强传导关系,但到了平峰期,这种传导几乎可以忽略;遇到交通事故,这种关系又会突然增强。要把这种变化捕捉进来,就得把“交互”本身当作一个随时间变化的量,而不是一个固定参数。
1.2 动态交互的两个主要来源
变量交互随时间变化,我认为主要来自两个来源。
第一个来源是系统自身运行状态的变化。比如一个空调系统,制冷模式下电压与功率的关系很直接,但在压缩机启停切换的瞬间,功率和电压之间会出现短暂的滞后和反弹,这种交互模式是随工况切换的。经济系统里更常见:牛市里板块联动强,熊市里又变成避险资产吸引资金,相关性结构跟着市场状态走。
第二个来源是外部条件的变化。很多外部变量不会直接进入模型,而是通过改变内生变量之间的关系来发挥作用。比如风速影响风电出力,但风速对电网频率的影响还取决于此刻负荷的高低;同一个外部干预,在不同背景下产生的变量联动效果完全不同。这种“被隐藏状态调节的交互”,如果模型不显式建模,就只能靠海量数据硬扛,扛不下来的部分就成了预测误差。
当交互变得如此不稳定时,简单地把所有变量扔进一个全局映射里,等于用一张渔网去兜瀑布,怎么兜都兜不干净。
1.3 注意力机制依然不够“细”
Transformer 在时序领域火了以后,大家自然想到用注意力去建模变量相关关系:让每个变量去查询其他变量的状态,把相似度当权重。这个思路比固定参数灵活,但它有两个容易被忽略的问题。
第一,标准的自注意力作用在时间维度上,不同变量通过 embedding 投影进同一个空间,再计算相似度。这种方式把“变量身份”和“时间状态”混在一起,很容易出现两个变量数值接近但语义无关的误匹配。第二,注意力权重是全局归一化的,它对局部模式的敏感性不足。动态交互往往发生在很短的窗口里——比如异常事件发生后的十几分钟内,变量间突然出现强耦合,过了这段时间又消失。全局注意力对这种瞬时局部变化是钝感的,它会把这十几分钟的特殊耦合稀释在整个序列的平均注意力里。
FACT 的切入点是:既然交互是局部的、且随时间变化的,那就用卷积来建模。卷积天然处理局部窗口,跨变量卷积又能在变量维度上做组合,如果把卷积核的动态生成和时序滑动结合起来,就能得到一种既能感知局部、又能随上下文变化的交互建模方式。
2. 细粒度跨变量卷积:设计思路与核心动机
2.1 “细粒度”到底细在哪里
细粒度这个词,不少论文都用,但 FACT 语境下的细粒度,我理解是三个层面。
第一层是时间粒度。不是把整个历史序列编码成一个全局向量后再算交互,而是在每个局部窗口内部计算变量之间的交互结构。窗口滑到哪里,交互就算到哪里,这样交互模式可以随窗口漂移。
第二层是变量对的粒度。不是对所有变量做一个全局混合,而是显式建模每一对变量之间的局部耦合关系。说白了,变量 A 和变量 B 之间的关系统一建模,变量 A 和变量 C 之间单独建模,它们的演化节奏可以不一样。
第三层是卷积核的参数粒度。同一组卷积核不一定要在整条序列上完全共享,而是可以根据局部上下文动态生成。这样模型既能享受卷积的参数共享优势,又不会因为核固化而丢失交互的动态性。
这三点组合起来,就是我们说的“细粒度跨变量卷积”:在时间维上滑窗,在变量维度上对通道分组或逐对建模,在核参数上按需生成。
2.2 为什么是卷积,不是全连接,也不是注意力
把变量交互建模成卷积,有几个很实际的好处。
首先是参数效率。假设有 C 个变量,如果用全连接层直接建模变量间的交互,需要 C×C 个参数,而且每个时间步都要单独算一次。用卷积,核心参数是卷积核的大小,假设核宽为 K,跨变量卷积的参数量大致是 C×C×K,但因为核在时间维上滑动共享,实际需要学习的独立参数远小于在每个时间步都使用全连接的情况。
其次是局部归纳偏置。变量之间的交互通常不是瞬间完成的,而是有一个传导过程。以电价和负荷的关系为例,负荷上升不会立刻推高电价,中间有十几分钟到几小时的市场响应延迟。卷积核天然覆盖一个时间窗口,通过窗口内的加权组合,可以直接建模这种“一个变量的历史信息如何影响另一变量的当前状态”的过程。
注意力也能做到这一点,但它的问题是计算复杂度随序列长度平方级增长,而且注意力没有显式的“核”概念,你很难解释变量 A 在滞后 2 步和滞后 3 步时对变量 B 的贡献分别是多少。卷积核给出的解释要直接得多:核的每一格就是一阶滞后的影响系数,这在调试和做可解释分析时非常有用。
2.3 把“变量”当通道:跨变量卷积的基本形态
在信号处理中,卷积通常作用于单通道或多通道信号。把多变量时间序列看作多通道信号,是非常自然的操作:时间轴是卷积的滑动维,每个变量就是通道。传统的一维卷积在做通道融合时,会通过输出通道的加权组合把输入通道混合起来,这其实就是一种跨变量交互。
FACT 的做法更进一步。它不只做一次静态的通道混合,而是让这个混合权重随局部上下文变化。具体来说,输入是一个形状为 (B, T, C) 的张量,B 是批次,T 是时间长度,C 是变量数。卷积核的形状是 (C_out, C_in, K),其中 K 是时间窗口长度。过一遍卷积,输出变量 j 在时间 t 的激活值,等于输入变量 i 在 t-K+1 到 t 窗口内的值乘以核权重后的累加。
这个计算过程把两件事同时做了:沿时间方向聚合局部历史信息,沿变量方向做通道混合。如果核权重是静态的,这就是一个普通的 1D 卷积;如果核权重根据输入动态生成,那就是 FACT 的核心组件——动态跨变量卷积。
3. FACT 框架核心模块拆解
3.1 输入预处理与变量嵌入
真正动手搭 FACT 时,第一步不是接卷积层,而是把输入整理成适合卷积操作的结构。
最常见的方式是对原始序列做实例归一化。多变量序列里,不同变量的量纲可能差很多,负荷的数值是兆瓦级,温度是十几到几十度,如果不做归一化,卷积核会倾向于忽略数值小的变量,导致某些交互永远学不出来。我一般对每个样本单独做标准化,而不是在整个训练集上做全局统计,这样可以避免分布漂移带来的问题。
做完标准化,还要考虑是否做 patch 化。PatchTST 这类模型告诉我们,把相邻时间步拼成一个 patch 可以减少序列长度、扩大感受野,还能降低噪声干扰。FACT 也可以采用类似思路,但要注意 patch 的尺寸不宜太大,否则局部交互的细粒度会被磨掉。我常用的配置是 patch 步长为 4 到 8,patch 长度为 8 到 16。这个参数需要根据数据的采样频率来调整,高频数据可以小一点,低频数据可以大一些。
3.2 核心模块:动态跨变量卷积块
动态跨变量卷积块是整个 FACT 框架的心脏。它的输入是经过归一化和 patch 化后的张量,输出是同等形状的张量。块内部可以拆成三条支路:上下文提取、动态核生成、卷积作用。
上下文提取负责从输入张量中提炼与当前时间步相关的“环境信息”。它不需要复杂的编码器,一个小的多层感知机加上平均池化就够用。提取出来的上下文向量,代表的是当前局部状态下变量之间交互的倾向,比如当前处于高负荷时段还是低谷时段,这决定了交互应该呈现出哪种模式。
动态核生成拿到上下文向量后,通过一个生成网络输出卷积核的权重。注意,这一步是动态卷积的关键,也是最容易失控的地方。如果直接生成全部 C×C×K 个参数,变量数稍多就会让生成网络的输出维度爆炸,而且很难训练。实践中更稳妥的做法是采用低秩分解或者超网络加缩放的设计:要么让生成网络输出两组低秩矩阵相乘来近似整个核,要么让生成网络输出一个缩放系数,去调制一个静态基础核。
最后一步是把生成的核作用到输入张量上。可以用 PyTorch 的 conv1d 或者 F.conv1d 实现。如果动态核是逐样本不同的,那就需要利用分组卷积或者逐样本循环来处理,这一点在工程实现时容易忽略,后面细说。
3.3 堆叠、残差与感受野
一个动态跨变量卷积块的感受野毕竟有限。FACT 一般通过堆叠多个块来扩展感受野,让上层的块有机会看到更长的历史窗口,从而建模更长期的交互变化。
堆叠的时候,残差连接几乎是必须的。原因很好理解:动态核生成是个非线性的高阶过程,如果每个块都直接覆盖原始输入,梯度传递路径过长,很容易出现梯度消失。加上残差连接,每个块只需要学习相对输入的增量变化,训练稳定性会好很多。
在堆叠过程中还有一个值得注意的设计选择:是否在每个块之后插入时间维的降采样。降采样可以扩大后续块的感受野,但也可能丢失细粒度信息。我的经验是,前一到两个块保持原始分辨率,后续块再逐步降采样,这样既照顾了局部模式,也保证了长程依赖。
3.4 输出头与损失函数
预测头的设计取决于预测目标。做单步预测的话,直接把最后一个时间步的隐状态接一个线性层输出即可;做多步预测,可以选择直接多输出,也可以采用自回归解码。
损失函数方面,时序预测通常用 MSE 或 MAE。如果是概率预测,可以在输出头同时输出均值和方差,然后用负对数似然作为损失。还有一个容易被忽略的细节:在多变量预测中,不同变量的量纲差异意味着它们的损失天然会有不同的尺度。如果不做加权,模型会自动偏向尺度大的变量。我建议在计算损失之前,对每个变量的误差做标准化,或者直接使用除以真实值绝对值的百分比误差类指标。
4. 实操过程:从头实现一个可用的 FACT 简化版
4.1 简化版动态核生成的代码实现
理论讲了这么多,还是要落到代码上。我给出一个可以在小数据集上直接起跑的 FACT 核心模块,用 PyTorch 实现,尽量精简但保留了关键设计。
import torch import torch.nn as nn import torch.nn.functional as F class DynamicCrossConv1d(nn.Module): def __init__(self, C, K=3, hidden=64, low_rank=8): super().__init__() self.C = C self.K = K self.low_rank = low_rank # 基础静态核,作为动态调制的基底 self.base_weight = nn.Parameter(torch.randn(C, C, K) * 0.02) # 从局部上下文生成动态调制系数 self.context_encoder = nn.Sequential( nn.Linear(C * K, hidden), nn.ReLU(), nn.Linear(hidden, (C + low_rank) * K + 1) ) def forward(self, x): # x: (B, T, C) B, T, C = x.shape K = self.K # 用窗口均值作为上下文(这里简化,只取前K步) context = x[:, :K, :].reshape(B, -1) params = self.context_encoder(context) # 从参数中切分出缩放系数、行低秩矩阵、列低秩矩阵 scale = torch.tanh(params[:, :1]) * 0.5 + 1.0 row = params[:, 1:1 + self.low_rank * K].reshape(B, self.low_rank, K) col = params[:, 1 + self.low_rank * K:].reshape(B, self.low_rank, C * C) # 生成逐样本核: base_weight 被行/列低秩结构调制 # 这里使用外积近似: 核 = base_weight + sigma(row,col) kernel = self.base_weight.unsqueeze(0) * scale # (1, C, C, K) # 简化的低秩更新,实际可以再精细 low_rank_update = torch.einsum('blk,blc->bck', row, col.view(B, self.low_rank, C, C).sum(dim=1)) kernel = kernel + low_rank_update.unsqueeze(-1) * 0.1 # 逐样本进行卷积 x_perm = x.permute(0, 2, 1) # (B, C, T) outs = [] for b in range(B): w = kernel[b] # (C, C, K) out = F.conv1d(x_perm[b:b+1], w, padding=K // 2) outs.append(out) return torch.stack(outs, dim=0).permute(0, 2, 1)这个实现是教学性质的,不是直接上生产的高性能版本。几个关键点:
第一,用缩放系数加低秩更新的方式生成动态核,而不是直接生成一个巨大的 C×C×K 张量。我实测过,直接生成的方式在 C 大于 16 时,训练速度明显下降,而且容易出现 NaN。低秩近似牺牲了一小部分表达能力,换来了训练稳定性和生成网络的小体积。
第二,逐样本循环卷积在 B 和 C 较大时效率不高。如果要上大规模数据,建议用 grouped convolution 或者把生成权重 batch 起来一次性计算。对原型验证来说,循环是最直观、最不容易出错的写法。
第三,上下文编码器只用了前 K 个时间步,这是个极大的简化。真实使用中,可以用一个 1D 卷积或者 GRU 把更长的上下文压缩成一个向量,这样核能感知的变化范围会更大。
4.2 训练稳定性的三个实用技巧
动态卷积模型最让人头疼的问题是训练不稳定。我踩过几次坑之后,总结了三件必做的小事。
第一件,实例归一化放在第一位。动态核生成依赖输入上下文,如果上下文的统计特征在不同样本间差异过大,生成网络就很难收敛。对每个样本做标准化,相当于把上下文的分布压到一个相对窄的范围,生成网络只需要学“相对模式”而不是“绝对数值”。
第二件,对生成的核做约束。我见过最典型的问题:核权重在训练后期无规律放大,导致预测值爆炸。解决方式是每步生成后做一次 clip 或规范化,比如把核的 L2 范数限制在一个上限内。这个上限设 3 到 5 就够用,太大起不到约束作用,太小又会限制表达能力。
第三件,先用小学习率跑一个静态核版本的 FACT,再打开动态生成分支。逻辑很简单:静态版本相当于动态版本的一个特例,如果特例都训不好,动态版本大概率也白搭。把静态版本训到一定指标后,冻结静态核和输出头,只训练生成网络,这个课程式的训练顺序能显著降低训练初期的发散概率。
4.3 参数效率和变量数扩展
关于参数效率,我做过一个很有意思的对比。同样是 20 个变量的时序数据,直接用全连接跨变量层,单层参数量是 20×20=400;用一个核宽为 5 的跨变量卷积,核心参数是 20×20×5=2000,看似更大,但因为核在时间维上滑动共享,实际每个时间步摊下来的参数量是 2000/T,T 是序列长度,序列越长反而越划算。注意力机制的参数主要在投影空间,但计算量随长度平方上涨,而且可解释性差。
如果变量数继续增长到 100 以上,逐对卷积核的规模也会失控。这时有两种工程化的降维思路。一种是在输入侧先做变量嵌入,把 100 个变量映射到 32 维的隐空间,再在这个隐空间上做跨变量卷积;另一种是用稀疏连接或聚类,先根据历史相关性把变量分组,组内做密集跨变量卷积,组间只保留低频交互。后一种方案更接近细粒度的本意:把力气花在真正有强交互的变量对上。
5. 应用场景:不同预测任务里的变量交互模式
5.1 能源电力:光伏、负荷与电价
能源调度是我接触最多的场景,也是动态变量交互最显著的领域。光伏出力和负荷之间,交互模式随天气和人类活动变化:晴天中午光伏峰值和空调负荷峰值叠在一起,阴天光伏骤降,负荷需求却可能不变甚至上升。用 FACT 建模时,动态核可以捕捉到“太阳辐射变化一个单位,在不同云量、不同日期下对净负荷的边际影响不同”这种细微的关系。
实际操作中,我把辐射、云量、温度、历史负荷、历史光伏出力作为输入变量。最终模型在预测净负荷时,读出的卷积核权重确实会随时间发生明显的结构化变化:上午时段辐射与光伏出力的核权重高,傍晚时段温度和负荷的交互权重抬升。这个可解释性让调度人员更容易信任模型的结果。
5.2 交通流:上下游路段与事件联动
交通预测中的变量交互动态性同样突出。上下游路段的车流传导,方向是确定的,但强度受时段、天气、事故和信号控制策略的影响。用 FACT 把多个路段的历史流量同时作为变量,卷积核能学到事故发生后大概 3 到 5 个时间步内,下游路段流量才开始呈现异常抬升,这个滞后的信号会体现在核的不同滞后期位置。
我在交通场景中特别注意了输入变量的顺序。卷积核对变量的排列顺序是敏感的,如果把空间位置相近的路段放在相邻的变量位置上,核就更容易学到空间局部性。这种“变量重排先验”在一般模型中常被忽略,但在跨变量卷积模型里是一个免费的提升手段。
5.3 金融与经济:相关性结构的切换
金融场景的变量交互更多表现为状态切换。股票、债券、商品之间的相关性在风险偏好变化时会整体切换,不是说某个变量对的交互逐渐变化,而是全矩阵在阶段之间突变。FACT 的动态核生成天然适合这种任务:生成网络接收到的上下文不同,输出的核就可能落在不同的参数区域,从而形成类似于“状态感知的交互模式”。
这里要提醒一句:金融时间序列信噪比极低,FACT 很容易过拟合到噪声交互上。我建议在损失函数中加入核权重的正则项,并增加验证集上的早停策略。宁可交互学得保守一点,也不要去追逐那些无法复现的瞬时相关性。
下面用一个表把几类典型场景的核心变量、交互特性和 FACT 的优势概括出来:
| 场景 | 典型变量 | 交互特性 | FACT 建模优势 |
|---|---|---|---|
| 能源电力 | 光伏出力、负荷、温度、辐射 | 受天气和时段影响,交互强度随工况变化 | 动态核直接刻画边际影响的时变性 |
| 交通流 | 各路段流量、速度、天气 | 空间传导存在滞后,事件引发局部增强 | 卷积窗口显式建模滞后效应 |
| 金融 | 多资产收益、波动率 | 相关性存在状态切换和突变 | 生成网络输出核随上下文状态切换 |
| 工业过程 | 温度、压力、流量、转速 | 设备工况切换导致耦合关系变化 | 局部窗口感知工况变化,不依赖全局假设 |
6. 常见问题与排查技巧实录
6.1 训练初期损失下降缓慢
如果你发现 FACT 的训练损失几乎没有下降,先检查是不是动态核生成部分学得太慢。一个很有效的排查方法:把动态生成的核输出打印出来,看不同样本之间的核差异是否在训练初期就很明显。如果一开始就差异巨大,那大概率是生成网络过拟合到噪声上;如果几乎没差异,则是生成网络没有接收到有效梯度。
修复办法通常是调整上下文编码器的容量。太小的编码器学不到有用的上下文表征,太大的编码器又容易让核的变化幅度失控。建议从两层 MLP、隐藏层 64 开始,观察损失变化再逐步增加。
6.2 预测值整体偏小或偏大
整体偏移一般不是动态核的问题,而是输出头的问题。跨变量卷积建模的是交互增量,输出的均值信息如果被卷积的局部差分性质削弱,预测就会系统性偏离。解决方法是输出头加上一个可学习的 bias 项,或者在最终预测前做一个水平校正,比如把预测结果的均值对齐到最近一段真实值的均值。
6.3 感受野不够导致交互建模失真
变量之间长周期的交互,比如月度级别的需求关联,如果卷积窗口只有几个时间步,模型无论如何都捕捉不到。排查方法是做一个简单的敏感性测试:延长输入序列的长度,看验证集指标是否明显提升。如果提升了,说明感受野不够,可以增加卷积块层数、扩大核宽,或者插入降采样层。如果没提升,那问题多半在特征或数据质量,而不在模型结构。
6.4 动态核退化为静态核
调试时我习惯把训练后动态核的实际变化幅度统计出来,计算每个核在不同样本间的方差。如果方差趋近于零,说明模型找到了一个“偷懒”的解:直接用静态模式就够拟合训练集了。这未必是坏事,说明当前数据里的动态交互本来就不强。但如果验证集表现差,那很可能是模型猜错了方向——真实数据有动态交互,但生成网络没学会触发条件。
此时可以把生成网络的输入范围扩大,比如塞进更多上下文信息,让核的变化对整个输入更敏感。
7. 最后再分享一个实践经验
在我自己的项目里,真正让 FACT 类方法发挥作用的,其实不是模型本身有多复杂,而是我花了很多时间去理解数据里的交互机制。每一次调整卷积核大小、修改上下文编码器、改变正则化强度,都是在和数据里的物理过程做对话。
如果你正在做的项目也有明显的变量联动,建议先画一张交互热度图,把各个变量对在不同时段的相关系数变化趋势画出来。这张图会直接告诉你,动态交互到底存在不存在、存在于哪些变量上、变化周期大概是多长。拿着这张图再去定 FACT 的核宽、层级和是否启用动态分支,会比盲目套用论文配置高效得多。这就是我用了很久的建模前检查流程,也希望大家能从中找到适合自己的调试节奏。