最近整理时间序列自监督这一摊实验记录,翻到"ST-MTM"这个缩写时,第一反应是"这不又是把MAE搬到时序上换个皮"。真把这个思路拆开细看才发现,关键是前面那三个字母里的逻辑——用季节-趋势分解去重新定义"掩码-重建"的目标,而不是简单地在原始序列上打格子挖洞。掩码时间序列建模(Masked Time-series Modeling)这两年并不新鲜,但凡是自己手写过预训练代码的人大概都有同感:时序上做掩码重建,效果远没有图像里那么稳。ST-MTM所代表的这类方案,恰好点中了一个要害问题——时序数据里的趋势、季节、残差混在一起,如果掩码任务不加区分,模型学到的往往是"插值技巧"而不是"时序表征"。
这篇文章就把ST-MTM拆开讲透:从设计动机、分模块实现,到我自己复现这类结构时踩过的坑和调参心得。适合正在做时序预训练、想把手头模型从"随机掩码+Transformer"升级到"结构感知掩码"的人,也适合刚接触自监督学习的读者理解为什么分解这一下动作如此关键。
1. 这个技术到底在解决什么问题
1.1 直接套用MAE式掩码,时序数据为什么会翻车
BERT在文本上做掩码语言建模,MAE在图像上做高比例掩码重建,这两种做法都取得了扎实的效果。原因在于文本和图像都有一种特性:局部可见信息足以约束被掩码区域,但又不至于让模型仅靠相邻像素或相邻词就轻松猜到答案。模型被迫去理解词与词、patch与patch之间的长程依赖,才能把重建误差压下去。
时间序列数据恰恰相反,它的局部连续性极强,尤其是以分钟、小时为采样间隔的高频数据,相邻两个点的差值往往非常小。在这个条件下做随机掩码,模型只需要把左右两边的可见点拉一条线,"插值"就能把掩码区域填得八九不离十。损失曲线看起来下降得很漂亮,表征却没有提升。另一个问题是多变量时序里,变量之间的相关性强于时间维度上的结构相关性,模型完全可以通过其他变量的可见信息去"抄答案",而根本不需要建模时间演化规律。这就是纯masked重建在时序上经常效果飘忽的原因。ST-MTM这类方案最直接的动机,就是打破这种"靠平滑性作弊"的路径。
1.2 季节-趋势分解改变了什么
季节-趋势分解(Seasonal-Trend Decomposition)不是新东西,STL、经典的时间序列加法模型早就把"趋势项+季节项+残差项"这套拆法用了几十年。把这个经典操作塞进自监督预训练,思路一下就活了。
分解之后,原始序列变成三个性质完全不同的分量:趋势项是低频的、缓慢变化的、带有长期方向的信息;季节项是有固定周期、周期性重复的波形;残差项则通常表现为不规则的噪声或短期波动。把它们放在一起让神经网络去重建,模型会被迫在同一个目标里同时处理"长期依赖+周期规律+随机噪声"三件事,这既困难又混乱。而拆开之后,掩码任务可以被设计得更有针对性——趋势项可以少掩一点,因为它的信息密度低;季节项大胆掩掉大部分,逼模型从可见片段推测整个周期的形态;残差项则负责把模型注意力拉回局部细节。分解让模型不再需要隐式区分序列的不同组成部分,任务难度被大幅降低,表征质量反而明显提升。
2. ST-MTM架构拆解
2.1 分解模块怎么选
分解模块是整个流水线的第一步,选型直接决定后面所有环节的输入质量。最稳妥、最少踩坑的方案是滑动平均提取趋势项:对序列做中心化或尾随的移动平均,窗口宽度可以根据数据频率和经验周期来定。日频数据用7天、30天窗口都常见;小时频业务数据通常用24或24*7。趋势项拿到以后,把原始序列减去趋势项,得到去趋势序列,再用周期聚合平均的方式提取季节项——把去趋势序列按周期长度切块,逐位置取平均,得到一个平均周期波形,然后沿时间轴复制加回来。最后用原始序列减去趋势项和季节项,剩下的就是残差项。
这套方法的好处是每一层都有明确的数学含义,代码短,运行快。相比之下,STL分解更稳健,能处理趋势突变和季节变化,但其参数多、迭代开销大,放进预训练数据管道里每次前向都要跑一遍,整体成本会明显上涨。傅里叶滤波提取季节项也很方便,但要注意周期长度不固定时,频域截断容易把噪声一并保留进季节项。我的建议是:第一版复现直接用移动平均加周期平均,效果不够再加复杂度。
2.2 掩码策略是ST-MTM的灵魂
掩码策略决定了模型到底在学什么。如果三种分量用同一种掩码率,那分解就只起到了数据变换的作用,意义大打折扣。合理的做法是给不同分量分配不同的掩码率,图像MAE里动辄掩掉75%的patch,在时序里不能照搬。
我的经验数值是:季节分量的掩码率可以给到0.6到0.75,因为周期的可预测性强,掩掉大半也给模型留了足够的棋盘格线索;趋势分量推荐控制在0.3到0.5,趋势本身是低频缓变信号,掩太多等于直接把长期信息抹掉,模型只能靠猜测,容易把训练过程拖垮;残差分量建议随机掩码0.5左右,保证模型不放弃局部细节建模。掩码的基本单位是patch而不是单点,patch长度一般取周期长度的四分之一到三分之一,比如周期是24的日频数据,patch长度取6到8比较合适。patch化还能让计算量下降一个量级,更贴近MAE的实际做法。
另外还有一层设计选择:是分别在三个分量上独立掩码、独立重建,还是把三个分量的patch拼接起来后再统一掩码。前者任务更纯粹,模型要分别对三个支路做重建,损失融合简单;后者能让模型在输入阶段就接触跨分量的交互信息,但实现上要小心不同分量的patch对齐问题。ST-MTM这个方向通常走的是"分别掩码、共享编码器、分头重建"的路子,这能最大程度保留分解带来的结构先验。
2.3 重建目标与损失函数设计
重建目标可以直接定为"把被掩码的patch拼接回去,在原始尺度上还原三个分量"。解码器输出三个分量的重建值,分别计算损失再按权重相加。损失函数的选择上,大部分复现里会优先用L1损失而不是L2。原因是时间序列重建里L2容易对异常尖峰和趋势段的偏差过度敏感,训练中后期梯度震荡明显;L1对噪声更鲁棒,迁移到下游预测任务时表征也通常更干净。
三个分量的损失权重需要认真调一下。如果不加权重,趋势分量会主导总loss,因为趋势的绝对值范围通常远大于季节和残差,尤其在非平稳序列里,趋势项波动可能达到几十上百,而季节残差只有个位数。这样模型会把绝大多数容量拿去拟合趋势,季节重建自然就废了。实践经验是先对三个分量做归一化(减均值除标准差),让它们落到同一量纲,再把权重设置成趋势0.2、季节0.5、残差0.3左右。残差虽然不占大头,但完全不管也不行,它承载着短期突变信息,对下游异常检测这类任务有直接帮助。
3. 复现ST-MTM的关键实现细节
3.1 窗口处理与数据预处理的准备工作
动手写代码前,先把数据处理这一步做扎实。预训练阶段一般不用标签,直接切滑动窗口就行,但窗口长度不能随便选。窗口至少要覆盖两个完整周期,否则季节分量提取不出来。对于日频数据,窗口长度取64到128比较充裕;周期未知时,可以先用自相关函数或FFT幅度谱估计主周期,再回来定窗口。
归一化要在分解之前还是之后,很多人会纠结。我的做法是先对每个窗口做z-score归一化,再做分解,因为归一化之后趋势、季节、残差的量纲差异已经缩小,后续损失权重的调节压力会小很多。注意不是对整个数据集做全局归一化,而是逐窗口做,更贴近流式预测场景。如果数据是分钟级高频序列,建议先做降采样或平滑滤波,否则残差项里全是高频噪声,模型会把大量参数花在拟合噪声上,下游任务迁移时反而学不到宏观结构。
3.2 可抄作业的PyTorch伪代码
下面是按ST-MTM思路搭建的预训练核心骨架,刻意写成了最直白的形式,方便在此基础上做改动。
import torch import torch.nn as nn def moving_average(x, kernel_size): # x: [B, T, C] pad = kernel_size // 2 x_pad = torch.nn.functional.pad(x, (0, 0, pad, pad), mode="replicate") kernel = torch.ones((1, 1, kernel_size), device=x.device) / kernel_size # 按最后一个维度做滑动平均 return torch.nn.functional.conv1d( x_pad.transpose(1, 2), kernel, padding=0 ).transpose(1, 2) def extract_seasonal(descasonalized, period): # descasonalized: [B, T, C],假设T能被period整除 B, T, C = descasonalized.shape reshaped = descasonalized.view(B, T // period, period, C) seasonal_pattern = reshaped.mean(dim=1) # [B, period, C] seasonal = seasonal_pattern.repeat(1, T // period, 1) return seasonal class STMTM(nn.Module): def __init__(self, d_model=256, nhead=8, num_layers=6, period=24, patch_len=6, mask_ratios=(0.4, 0.7, 0.5)): super().__init__() self.period = period self.patch_len = patch_len self.mask_ratios = mask_ratios # (trend, seasonal, resid) self.encoder = nn.TransformerEncoder( nn.TransformerEncoderLayer(d_model, nhead, batch_first=True), num_layers=num_layers ) self.input_proj = nn.Linear(patch_len, d_model) self.trend_head = nn.Linear(d_model, patch_len) self.seasonal_head = nn.Linear(d_model, patch_len) self.resid_head = nn.Linear(d_model, patch_len) def forward(self, x): # x: [B, T, C],已经逐窗口归一化 trend = moving_average(x, self.period // 2 + 1) seasonal = extract_seasonal(x - trend, self.period) resid = x - trend - seasonal components = [] for comp, ratio in zip( [trend, seasonal, resid], self.mask_ratios ): comp = comp.unsqueeze(1) # [B, 1, T, C] comp = comp.unfold(2, self.patch_len, self.patch_len) # 切patch comp = comp.contiguous().view( comp.size(0), 1, -1, self.patch_len * comp.size(3) ) # 这里简化处理:直接按最后一个维度作为patch向量 comp_patches = comp.squeeze(1) # [B, num_patches, patch_len*C] components.append(comp_patches) outputs = [] for comp_patches, ratio in zip(components, self.mask_ratios): B, num_patches, _ = comp_patches.shape num_keep = int(num_patches * (1 - ratio)) keep_idx = torch.randperm(num_patches, device=x.device)[:num_keep] keep_idx = torch.sort(keep_idx).values visible = comp_patches[:, keep_idx, :] hidden = comp_patches[:, ~torch.isin( torch.arange(num_patches, device=x.device), keep_idx ), :] emb = self.input_proj(visible) rep = self.encoder(emb) if hidden.shape[0] == 0: outputs.append(None) continue rec = self.reconstruct(rep, hidden, keep_idx, num_patches) outputs.append(rec) return outputs def reconstruct(self, rep, hidden, keep_idx, num_patches): # 简化版本:此处仅作示意,实际中可用predictor或直接零填充embedding # ST-MTM常见做法是类似MAE:可见patch过编码器,重建成所有patch # 由于篇幅原因省略位置编码与unpatchify细节 return hidden实际编码时还要补上位置编码、unpatchify时的顺序复原、以及分量的独立归一化参数。上面代码只是把数据流和掩码逻辑串起来,重点在于让大家看明白三个分量是如何被分别处理、分别掩码的。补全这些细节之后,系统框架也就基本成型了。
3.3 训练与下游迁移的配置建议
预训练优化器用AdamW,学习率按batch size线性缩放,基础学习率给3e-4到1e-3都是常见区间。掩码重建在预训练阶段收敛速度比普通预测任务快,一般看到验证重建损失曲线不再改善就可以停止,不必硬跑满固定epoch——时序数据本身的模式相对固定,训练太久反而会让encoder过拟合到重建插值上。batch size能开多大就开多大,小batch会加重掩码随机性带来的梯度波动,稳定性明显变差。
下游迁移的主流做法是"预训练后微调"和"预训练特征+轻量预测头"两种。微调时建议把重建头丢掉,只保留encoder,用最后几层的输出作为预测头的输入。特征提取模式则简单许多:把时间窗口过encoder取序列维度上的聚合特征(平均池化或维度拼接),再接一层MLP去预测未来一段窗口。对比下来,场景数据量较小时特征提取模式更稳;数据量够大时微调效果上限更高。下游任务类型不同,选择也会不同,但无论选哪种,都会明显优于同结构模型从零开始训练。
4. 做时序掩码建模时最常踩的坑
4.1 模型学到的是"插值"而不是"表征"
这个问题在ST-MTM上依然存在,只是被缓解了,并没有完全消失。最典型的现象是预训练loss一直很低,但下游任务的预测误差没有任何改善。判断方法也很简单:把掩码比例提高到0.8,如果重建loss只上升一点点,基本可以断定模型在靠强连续性插值混日子。排查方向集中在两个地方:一是patch长度太短,模型可以看见掩码位置的所有邻近点;二是编码器太浅,无法构造有效的高层语义,只能走捷径。
我的建议是掩码比例最好设置成"模型重建上界"附近的数值,宁可让预训练loss偏高一些,也不要让模型太轻松。另外,dropout不能开太大,时序自监督里dropout加多了会破坏掩码重建任务本身提供的正则效果,让模型更难学到跨patch结构。
4.2 趋势分量主导Loss,季节学不出来
这是新手最容易踩的坑。拆开分量之后,各分量的数值范围天然差异巨大,如果不做任何权重调整,趋势分量会在loss里占绝对主导地位。表现就是训练曲线很漂亮,但把seasonal_head的输出单独画出来一看,基本是平的,完全没有周期性。图一出来,问题一目了然。
解决方案就是前面提到的双保险:第一,每个分量单独做标准化,让数值范围一致;第二,loss权重手动偏向季节和残差。还有一个小技巧,可以分别打印三个分量的重建loss曲线,正常情况下它们应该同步下降,如果哪条曲线的下降速率明显慢于其他两条,就去检查对应分量的掩码策略和权重设置。
4.3 分解时引入未来信息泄漏
这一个坑已经坑过不少人。自监督预训练是不用标签的,但分解操作如果做不好,隐含地引入了未来信息,下游模型性能看起来很好,实际上学的是"作弊特征"。比如用中心化移动平均提取趋势,窗口中心在当前时间点上,左右都看了,未来信息就会泄进来。预训练阶段可能看不出问题,但下游做的是预测任务,推理时拿不到未来数据,效果立刻崩盘。
复现ST-MTM时注意:趋势项要么用尾随移动平均(只用过去数据),要么在离线预训练时明确记录每个窗口的分界位置,确保任何时刻的分解只依赖该时刻之前的信息。季节项提取用整段训练数据的平均周期模式是没问题的,因为这是全局统计量,不会根据当前窗口跳变;但趋势项必须严格因果。这是代码审查时的重点。
4.4 序列没有明显周期时怎么办
不是所有时间序列都有稳定的周期。某些交易数据、机器状态数据、短期流量突发的场景,周期几乎无法提取。这种情况下季节性分解得到的季节项会跟残差项高度重合,分开建模的意义就被削弱了。
这时候可以退一步:不用固定周期,改用自适应周期估计,比如根据自相关函数的第二高峰来确定周期长度,或者干脆把ST-MTM退化成"趋势-波动"两分量版本趋势项单独建模,其余统一作为残差处理。还有一条路是换到频域:对序列做短时傅里叶变换,让模型在时频域里做掩码重建,绕过显式周期提取的困难。时间序列自监督的灵活之处就在这里,分解不是目的,设计出能逼模型学结构信息的任务才是目的。
5. 与当前主流时序预训练思路的关系
5.1 ST-MTM和PatchTST、TimesNet这类方法有什么不同
PatchTST的核心是patch化加通道独立,训练目标通常是未来窗口预测,本质上还是监督学习;TimesNet把序列重排成时间二维张量,用CNN捕获周期内和跨周期的模式;而ST-MTM走的是纯自监督路线,不做未来预测,只做掩码重建。这三者最本质的差异在于:PatchTST和TimesNet把周期结构当作网络结构的先验,ST-MTM则把周期结构和趋势结构当作任务设计的先验。
实际应用中,两者并不冲突。常见操作是先把ST-MTM在无标注数据上预训练,拿到encoder权重,再接入PatchTST式的预测头做微调。这样一来,预训练阶段学到的分量感知表征可以被完整保留,下游预测时又可以用上patch化的便利。我自己做实验时,这种两段式组合比单独使用任何一种的效果都更稳定,尤其在数据量只有几千条序列的中小规模场景。
5.2 对下游任务的实际增益在哪里
预训练表征最终要落在具体任务上才能体现价值。分类任务里,ST-MTM的优势在于对季节分量的重建迫使模型保存"形状"信息,而趋势分量让模型关注"方向";这正好对应时间序列分类中常用的形态和趋势两个判别维度。异常检测任务里,残差分量的重建质量能敏感地反映异常注入的位置,因为正常周期的残差模式与异常模式有明显差异,重建误差自然成为一个优质异常分数。
预测任务里的增益则更微妙。直接做多步预测时,纯有监督训练往往会崩溃到"未来均值"这种保守预测,而先做掩码重建预热,能让encoder提前学会把可见趋势外推成完整模式,后续微调时优化器不必从一团混乱的表征里从头摸索,收敛速度和最终精度都会好一些。从时间成本看,预训练带来的额外开销是可接受的,因为自监督阶段不需要标注,数据可以随便堆。
6. 最终总结
6.1 复现ST-MTM时的核心要点清单
- 分解顺序:先归一化,再滑动平均提取趋势,再做季节提取,最后算残差。
- 掩码比例:趋势0.4左右、季节0.65-0.75、残差0.5左右,这是相对稳的起点。
- patch长度:优先取周期长度的四分之一到三分之一。
- 损失权重:各分量先标准化,再按趋势0.2、季节0.5、残差0.3设置。
- 趋势提取务必因果,用尾随窗口,不要用中心窗口。
6.2 个人经验和踩过的坑
自己复现ST-MTM这类结构时,最大的一个体会是:它并不是一个"效果立竿见影"的方法,而是需要和你的数据特性深度绑定。高频数据、低频数据、强周期数据、无周期数据,掩码率和patch长度都要重新调。如果你在公开数据集上复现,效果大概率不是碾压式提升——但当你把模型换到自己的业务数据上,尤其是那些标注稀缺的场景,ST-MTM的价值才会真正体现出来。还有一点很实用的经验:复现时不要急着上大模型,先用一个4层Transformer把完整链路跑通,确认三个分量的重建曲线都正常,再去加深度。因为时序数据即使经过分解,深层模型依然很容易在重建任务上走捷径,越大越难查。小模型跑通、逐步放大,这个节奏在自监督时序里非常管用。
另外再提一个容易被忽略的细节:位置编码要对三个分量统一使用,原因是重建时需要跨分量对齐时间轴。如果每个分量各用一套位置编码,模型会倾向于记忆分量间的固定拼接顺序,而不是真正理解时间演化关系。这个小改动调整起来不费劲,但对下游预测任务的增益是实打实的。