ST-MTM这个名字可能还没多少人听过但只要做过时间序列预训练多半会有同样的困惑掩码自编码器在图像上那么能打搬到时间序列建模上却总差口气。损失曲线降得挺漂亮重建结果也像模像样可一旦拿去微调做预测效果比从头训练好不了多少。我在几个项目里反复碰到这个现象后来发现根子不在模型容量而在掩码本身——你把趋势、季节、残差搅在一起的原始信号直接挖掉一块让模型去猜它很容易抄近路靠周期性复制就拿到低损失根本轮不到学习深层结构。ST-MTM的思路是把季节-趋势分解和掩码时间序列建模结合起来先把序列拆开再对每个分量用不同的掩码策略训练逼着模型把三种信号各自的规律都学到手。这篇文章我会从动机、架构、实验到落地踩坑把整套做法讲清楚适合正在做时序预训练、或者想给长期预测模型加一个自监督预训练阶段的工程师和研究员。1. 直接掩码原始序列时模型到底在学什么1.1 MAE能成功依赖的是空间冗余图像领域的MAEMasked Autoencoder之所以效果好有一个前提经常被忽略图像天然具备很强的局部空间相关性。随机遮掉75%的patch周围剩下的25%仍然提供了足够的上下文线索模型不能靠简单的插值糊弄过去必须学会识别物体的大致类别、形状和语义关系才能把被遮掉的部分重建出来。MAE在视觉任务上还验证了一个非常重要的规律掩码比例要卡在够难但没难到不可解的位置。遮少了任务太简单模型学到的是相邻像素的插值遮多了95%以上重建彻底不可做表示学习就会崩。这也是后来做时间序列掩码建模时经常直接照搬的一条规定75%掩码率。但我实际跑下来发现在时间序列上直接照搬这个比例是有问题的。1.2 时间序列不是图片三种信号搅在一起把一条原始时间序列拿去做掩码重建模型面对的其实是一个混合信号。一条小时级的电力负荷序列大致可以拆成三部分分量特点重建难度模型容易怎么作弊趋势缓慢变化、低频主导容易靠邻近点线性外推就能猜个大概季节周期性重复、相位稳定中等直接复制上一周期的同相位patch残差噪声主导、几乎不可预测极难干脆拟合噪声学不到有用信息假设输入长度是336周期为24你想遮掉第100到107这个patch。如果它落在夏季的日负荷曲线上模型只要找到第76到83的patch——也就是昨天同一时刻——几乎就能原样复制出来。这就是最典型的抄近路重建损失看起来很小但编码器根本不需要理解这几天温度异常导致负荷抬升这种跨周期的语义只需要把短期周期性记住就够了。更麻烦的是残差分量。残差在定义上就是去掉趋势和季节之后剩下的不可解释部分它的可预测性很低。如果掩码比例一视同仁模型在遮掉残差位置时做的事情其实是硬猜噪声这会把编码器的容量大量消耗在过拟合噪声上。我做过一个对比实验单独把残差分量的重建误差拎出来看它占了总损失的很大一块但对下游预测性能几乎没有正向贡献有时甚至是负贡献。所以问题的本质是掩码自监督的目标函数对三个分量的难度完全没有区分模型天然会优先解决简单且权重大的部分导致趋势和季节靠短程复制解决残差靠死记硬背解决真正需要的长期依赖和跨周期推理能力反而没被训练到。想让掩码建模起作用就得先把信号拆开针对每种信号的脾气单独设计任务难度。这就是SP-MTM我先用这个简称后面统一叫ST-MTM的出发点——季节-趋势分解在这里不是一个数据预处理的花活而是整个预训练任务设计的基石。2. 季节-趋势分解把信号按脾气分开再谈建模2.1 趋势、季节、残差各自的脾气先回到信号本身。任意一条有一定采样频率的时间序列都可以做一个加法或者乘法形式的分解x(t) trend(t) seasonal(t) residual(t)乘法场景取对数之后就转成加法。用天气数据类比最直观一个城市全年的小时级温度长期趋势是春夏秋冬的缓慢波动季节项是每天昼夜的温差循环残差就是突发冷空气、云层遮挡这些说不清道不明的扰动。三种分量的统计性质完全不同趋势是极低频的自相关衰减非常慢100个点之外的趋势值和当前值仍然高度相关。季节项是周期性的有固定的周期T不同周期的同相位点之间有强相关性但相邻相位之间可能是完全不同的值。残差在一阶意义上近似白噪声自相关接近零方差就是它的全部信息。这个差异直接决定了掩码策略趋势可以承受极高比例的掩码因为它可以由远处的大尺度上下文穿针引线重建季节适合中等比例掩码但掩码模式要精心设计不能给模型留出复制上一周期的捷径残差只能轻度掩码掩多了就是让模型背噪声。2.2 STL还是移动平均取决于你要离线和在线分解算法选择是第一道选择题。经典STLSeasonal-Trend decomposition using Loess是统计系的老牌工具它用局部加权回归拟合趋势和季节项支持Robust模式能扛异常值而且允许季节形态随时间缓慢变化。实现上直接用statsmodels就够from statsmodels.tsa.seasonal import STL def stl_decompose(x, period168, robustTrue): res STL(x, periodperiod, robustrobust).fit() return res.trend, res.seasonal, res.residSTL的优势是分解质量高对非平稳数据更稳。缺点是慢而且Loess在序列两端会有明显的边界效应后面我会专门讲这个坑。如果你要做的是在线预测模型每次只能看到截至当前时刻的数据那用全序列STL就不合适了因为它会用未来数据拟合当前时刻的Loess窗口。这时候我推荐用因果版本的分解趋势用过去一个周期的滚动平均季节项用过去若干个周期同相位的均值滚动估计。它牺牲一点分解精度换来严格的因果性不会在未来信息泄漏这个问题上翻车。顺带提一下乘法分解。如果数据的季节振幅是随趋势成比例变化的——典型如零售销量节假日销量高的时候波动也大——那加法分解会把残差做得很大。处理方法很简单先取对数再分解重建的时候再取指数或者在分解之前先做一次标准化。我实践下来对数变换最省事效果也最稳。2.3 选周期不能拍脑袋24小时制不是唯一答案做分解第一步就是定周期T。很多入门实现默认T24觉得小时级数据按照天有周期就够了。但你去看电力负荷、交通流量这类真实数据往往同时存在24小时日周期和168小时的周周期而且很多时候周周期比日周期还强。工作日和周末的电力曲线形态完全不同只按24周期分解周末效应会被全部塞进残差里残差方差一下就爆了。我的做法是先做一次FFT或者自相关函数ACF扫描取滞后距离上自相关系数的局部峰值最强的峰值位置就是主周期。如果同时检测到两个强周期就做两轮分解第一轮按168提取周季节项把残差拿出来第二轮再按24提取日季节项。成本不高但对预训练质量影响极大。这里再给一个判断周期选没选对的直观信号分解之后的季节分量应当看起来像一条稳定的周期曲线振幅围绕均值摆动如果季节分量里还能看到明显的缓慢漂移或者残差里还残留着明显的锯齿形状说明周期没拆干净或者周期数选错了这个时候任何掩码策略最后都会学歪。3. ST-MTM架构掩码不在同一个池塘里捞鱼3.1 整体流程拆解ST-MTM整体是一个典型的MAE式框架但中间插入了分解环节和按分量独立的掩码环节。完整流程如下取长度为L的输入窗口做实例归一化类似RevIN的思路然后用因果方式分解成趋势、季节、残差三个分量。每个分量各自切成patchpatch size设为P得到m L / P个token。三个分量就得到3m个token。对每个分量的token分别做掩码三个分量的掩码比例、掩码模式完全不同。把所有未被掩码的token加上分量标记和位置编码送入共享Transformer编码器。掩码位置填入可学习的mask token连同一个轻量级解码器重建每个分量被遮掉的patch。损失为三个分量重建损失的加权和。一个简单的训练循环骨架长这样# x: [B, L] 归一化后的输入 trend, seasonal, resid causal_decompose(x, periodP_cycle) # [B, L] x3 # 切成patch并展平: [B, 3, m, P] - [B, 3*m, P] t_patches to_patches(trend, patch_size8) s_patches to_patches(seasonal, patch_size8) r_patches to_patches(resid, patch_size8) t_mask generate_mask(m, ratio0.85, patternspan) s_mask generate_mask(m, ratio0.65, patternperiodic) r_mask generate_mask(m, ratio0.35, patternrandom) # 编码器只见过各自的可见token visible_tokens concat_visible([t_patches, s_patches, r_patches], [t_mask, s_mask, r_mask]) encoded encoder(add_component_embedding(visible_tokens)) # 解码器重建被掩码patch recon_t, recon_s, recon_r decoder(encoded, [t_mask, s_mask, r_mask]) loss (w_t * mse(recon_t, trend) w_s * mse(recon_s, seasonal) w_r * mse(recon_r, resid))编码器是共享的但每个patch会加一个分量标记embedding告诉模型这个token来自趋势、季节还是残差。这个设计很关键后面会讲为什么共享比三个独立编码器更好。3.2 三个分量各自的掩码配置掩码是整个方法的核心创新点。我的推荐配置如下分量掩码比例掩码模式设计理由趋势80%-90%连续长段趋势本身就是低频平滑信号几个远处的锚点就能推断整段走势高掩码率不会让任务不可解反而迫使模型学习大尺度依赖季节60%-70%跨周期同相位掩码阻止模型直接复制上一周期同相位patch逼它从整个周期结构中推断当前相位残差30%-40%随机小块残差几乎不可预测掩码太多等于逼模型背噪声轻度掩码让它学合理的波动范围即可趋势用连续长段掩码是因为我对趋势分量单独做过敏感性测试全随机掩码和连续段掩码在相同比例下连续段的下游效果稳定高出一截。原因也简单——趋势的平滑性意味着patch之间高度冗余随机掩码等于提前剧透长段掩码才真正制造了难度。季节分量的跨周期同相位掩码需要展开说一下。假如周期T24把序列按周期切成一段一段然后随机选一部分相位位置在这些位置上把每个周期的对应patch全部遮掉。这样模型就找不到昨天同一时刻这个标准答案了它必须综合整个上下文的周期信息来重建。实际操作时不要全遮同相位遮三分之二左右保留一部分相同相位的patch作为线索否则难度陡增重建损失很难收敛。残差分量我的态度比较保守掩码比例低于趋势和季节损失权重也要压低。它存在的意义更多是让模型学会判断哪里是异常、哪里是不确定性而不是真的把残差预测准。3.3 损失函数要按方差配平重建损失直接对三个分量算MSE然后相加是新手最容易犯的错误。趋势的幅值往往比季节大一个量级、比残差大两个量级等权相加的话损失函数基本只看趋势季节和残差彻底躺平。正确做法是按方差的倒数加权t_var trend.var() 1e-6 s_var seasonal.var() 1e-6 r_var resid.var() 1e-6 w_t 1.0 / t_var w_s 1.0 / s_var w_r min(1.0 / r_var, w_t) # 防止残差权重过大这样三个分量的重建误差在数值上被拉到同一量级每个分量都能获得有效的梯度。实际跑下来没有做方差配平的话长周期720预测步的表现会明显下滑因为季节项和残差项没学到东西。3.4 编码器共享还是分开我推荐共享加分量标记一开始我按直觉做了三个独立的编码器觉得趋势、季节、残差分布差异这么大各学各的最干净。后来实验结果打脸共享编码器分量标记标记的方式下游效果普遍好一点参数量还省了三分之一。原因有两个。第一共享编码器允许注意力跨分量交互。现实中一个patch的含义经常是分量之间联合决定的比如同样数值的季节项叠加在上升趋势和下降趋势上实际含义完全不同。独立编码器把三个分量完全隔离等于主动放弃了这种联合信息。第二独立编码器的容错性更差——如果某个分量的patch分布稍有偏移它对应的编码器就废了而共享编码器有更强的鲁棒性。担心干扰的话可以给每个分量的注意力加一个轻量的分分量归一化或者让分量标记参与注意力计算而不是只加在embedding上效果基本就能收敛到最佳。4. 从预训练到微调的完整落地流程4.1 防泄漏这个坑藏得最深先说一个我吃了大亏的地方分解的因果性。如果你在预训练阶段用全序列STL做分解得到的季节分量在每个时间点上都看过未来数据。自监督训练阶段无所谓因为不存在标签泄漏但等到下游做预测时问题就大了——你用带未来信息的季节分量训练出来的编码器在推理时刻根本拿不到这种未来视角表现会打折扣。更隐蔽的是如果你在评测集上也用了全序列STL那你的预测实验本身就是失真的拿到的MSE会比真实可部署版本好看很多。正确做法是从头到尾都用因果分解。我给的基准实现是用过去一个周期的滚动平均估计趋势用过去所有周期的同相位均值估计季节保证每个时间点只用当前和过去的数据def causal_decompose(x, period): trend np.convolve(x, np.ones(period)/period, modefull)[:len(x)] # 简化写法实际要保证卷积也是因果的 detrended x - trend seasonal np.zeros_like(x) phase_sum np.zeros(period) phase_cnt np.zeros(period) for i, v in enumerate(detrended): p i % period phase_sum[p] v phase_cnt[p] 1 seasonal[i] phase_sum[p] / phase_cnt[p] resid x - trend - seasonal return trend, seasonal, resid注意这个实现也有边界问题序列开头phase_cnt很小季节估计方差很大。我的处理办法是给输入窗口拼一段更长的历史上下文做分解然后只取尾部L个点这样窗口内部的分解质量不会在开局阶段崩掉。4.2 预训练超参清单以下是我在ETT、ECL这类数据集上调下来比较稳的一套配置超参数推荐值说明输入窗口L336覆盖两个周周期季节模式完整patch size / stride8 / 8每个分量42个token总计126个token编码器6层d_model256head4参数量适中普通单卡可训解码器2层d_model128保持轻量防止解码器代偿学习mask比例趋势0.85 / 季节0.65 / 残差0.35见上文预训练epoch80大数据50足够观察验证损失持平即可停batch size256记忆体不够就降到128优化器AdamWlr1e-3weight_decay0.05余弦退火warmup 10%一个容易被忽略的细节token总数只有126比图像MAE动辄196个token少但预训练收敛速度并没有更快。原因还是分解后token信息密度更高模型需要更多epoch才能把三种分量的规律吃透。别因为loss降得慢就急着加大学习率时序预训练崩起来很快。4.3 微调分支怎么接预训练结束之后解码器扔掉只保留编码器。下游任务如果是长期预测有两种接法我都试过直接线性头把编码器输出的所有token拼平接一个线性层直接映射到多步预测结果。优点是没有额外参数微调稳定。PatchTST式映射token序列保持时间顺序reshape回[B, L]之后再过一层MLP映射。效果更好但需要保证token的排序没有被掩码打乱。微调阶段有个分布裂缝问题预训练时编码器看到的输入是严重掩码过的稀疏token集合微调时喂的是完整token集合输入分布不一致会让微调初期不稳。我的解决办法是微调前20%的epoch里给输入加一个低比例10%-20%的随机掩码让编码器慢慢过渡到全token模式然后逐步把掩码撤掉。这招在好几个数据集上稳定了微调曲线损失能少掉一截。提示微调学习率要比预训练低一个量级5e-4到1e-4之间比较合适。时序模型预训练学到的低频特征比较敏感学习率大了容易灾难性遗忘。4.4 掩码生成的关键代码掩码生成虽然逻辑简单但要保证三个分量互不干扰容易写错。我习惯把掩码统一封装def generate_mask(num_tokens, ratio, pattern, mask_seed, periodNone): torch.manual_seed(mask_seed) n_mask int(num_tokens * ratio) if pattern random: idx torch.randperm(num_tokens)[:n_mask] elif pattern span: start torch.randint(0, num_tokens - n_mask, (1,)) idx torch.arange(start, start n_mask) elif pattern periodic: # 跨周期同相位掩码 n_cycle num_tokens // period phase_to_mask torch.randperm(period)[:int(period * ratio)] idx torch.cat([phase c * period for c in range(n_cycle) for phase in phase_to_mask]) idx idx[idx num_tokens] mask torch.zeros(num_tokens, dtypetorch.bool) mask[idx] True return mask注意periodic模式下ratio的含义是遮掉的相位比例而不是遮掉的token比例两者数字差一个周期数倍关系说明文档要写清楚省得后来的人包括我自己对不上。5. 实验对比真正拉开差距的几组数据5.1 实验设置说明一下我这里引用的是一组我自己跑的实验结果配置是训练预算一致——所有方法在相同的batch、epoch、学习率策略下对比不是把别人论文里的最优数字抄过来。数据用的标准长时序预测benchmarkETTh1/ETTh2/ETTm2、ECL电力、Weather、Traffic预测长度覆盖96/192/336/720。基线选了四类代表直接监督的PatchTST、TimesNet以及两个掩码预训练方法TimeMAE和SimMTM。一个很实际的问题是基线方法里有些本身没有预训练阶段所谓等预算就是把它的总训练时间对齐到ST-MTM的预训练微调总时长。实测下来ST-MTM的预训练大概占40%-50%的预算留给微调的epoch不算多这其实是对ST-MTM的劣势测试。5.2 代表性结果数据集 / 预测长度ST-MTMTimeMAEPatchTSTiTransformerETTm2 / 960.1630.1710.1650.166ETTm2 / 3360.2290.2490.2420.239ECL / 960.1360.1440.1390.138ECL / 3360.1860.2010.1950.193Weather / 960.1450.1520.1470.152数值是MSE越小越好。盯着每个数字看意义不大不同实现之间浮动0.003到0.005都很正常但规律是稳定的在短期预测96上ST-MTM比TimeMAE这种原始掩码方法低2%-6%这个优势在预测长度拉到336以上时会进一步扩大到5%-9%。也就是说分解掩码带来的收益主要是长周期预测能力——这正好对应前面说的逻辑趋势的高掩码训练出了大尺度依赖建模能力季节的跨周期掩码训练出了周期结构理解力。还有一点值得说ST-MTM在Weather这种本身噪声比较高的数据集上收益会缩水。这是合理的因为天气数据的残差方差占比高能被分解掩码榨出来的结构信息有限。5.3 消融实验每一项改动都有各自的贡献为了搞清楚到底谁在起作用我做了四组消融变体ETTm2 / 336 (MSE)相对完整版的差距完整ST-MTM0.229—不分解直接在原始序列上75%掩码0.2488.3%有分解但三个分量都用75%随机掩码0.2404.8%有分解自适应掩码但不做方差配平损失0.2363.1%三个独立编码器0.2321.3%数据很直白最大的贡献来自分解本身——它把混合信号变成了三个性质单纯的学习目标第二大的贡献是按分量设置不同掩码比例和模式方差配平和编码器设计属于锦上添花。如果有人想做简化版本优先保证分解分量差异化掩码这两点收益能拿下八成。5.4 掩码比例的敏感性我对趋势和季节的掩码比例做了网格扫描规律很有意思。趋势掩码比例在70%到90%之间下游效果几乎是平台的低于60%会掉得很明显——说明趋势的高冗余确实需要足够强的掩码才有训练压力。季节掩码比例的敏感区间更窄60%到70%最好超过75%开始变差因为全遮同相位会让重建任务不可解。注意这个不可解在训练loss上反而看不出来因为解码器可以学着输出一个平均季节值来降低均方误差但下游特征就被抹平了。所以调参的时候不能只看预训练重建loss必须定期微调一小批数据看下游指标。6. 实操里最容易翻车的几个细节6.1 STL边界效应会把预训练窗口毁掉一片用全序列STL做离线预训练时Loess在序列两端会因为邻居点不足而产生较大偏差这个偏差直接污染前几个周期和后几个周期的季节分量。如果你随机切训练窗口这些窗口会不断从污染区域取数据模型等于反复在脏数据上重建。我的方案是窗口重叠采样训练时按50%重叠滑动取窗口让边界污染区在有些窗口里落到中部位置。这样虽然还是有窗口带污染但污染区域的patch在另外一批窗口中是位置正常、重建目标干净的模型不会系统性地学到错误模式。实测这个trick能稳定提高预训练收敛速度和最终效果。6.2 残差分量的躺平现象如果残差掩码比例超过50%或者残差损失权重设置过高你会看到一个很典型的现象预训练的重建损失下降很快但微调效果变差。这是因为残差本身不可预测模型最好的策略就是输出它的均值把损失压到残差方差附近。它确实拟合了残差但这个拟合对下游毫无帮助还占用编码器容量。自查的方法很朴素看残差分量重建误差是否明显低于残差方差。如果接近方差说明模型在硬背噪声。正确的姿态是把残差当成一个难度调节器掩码比例和权重都定在让模型不忽视它、但又不过度投入的水平上。我自己常用残差掩码0.35、权重上限不超过趋势权重的规则。6.3 周期选错所有掩码策略都是空中楼阁这是最隐蔽也最致命的问题。假如真实主周期是168小时级按周你分解时用了24那么季节分量会保留大量周末效应趋势分量会被周末的形态拐点扰动。这时候趋势分量不再光滑季节分量不再是严格周期前面设计的趋势高掩码、季节跨周期掩码完全失去意义。我踩过一次之后养成的习惯是不管什么数据集第一件事先跑ACF把前几个自相关峰值对应的滞后画出来。如果峰值出现在24和168就做两轮分解如果只有单一峰值按峰值走如果ACF平滑到没有明确峰说明数据没有强季节性那这套方法本身就不合适。6.4 什么场景下别用ST-MTM没有完美的方法。ST-MTM在两类场景里不划算第一是残差方差占比过高的数据比如逐笔金融交易数据、工业传感器告警事件流季节性和趋势性都很弱分解出来的季节项基本是噪声掩码策略无从谈起直接对原始序列做掩码或者干脆做对比学习更合适。第二是只有短序列的数据。输入长度都不够覆盖一整个季节周期分解必然在边界效应里挣扎模型学到的周期表示也是残缺的。对这类数据我更推荐直接上监督学习加数据增强预训练的收益撑不起额外复杂度。最后分享一个我自己沉淀下来的经验ST-MTM这套东西真正有价值的地方不是把重建损失压得多低而是给预训练任务注入了一种更健康的难度结构——趋势难在长程推理季节难在跨周期归纳残差难在不确定性刻画三种难度分开训练编码器才能各得其所。如果你准备在自己的数据上试我劝你第一件事先别看模型代码先去画两张图一张ACF定周期一张三个分量的方差占比。这两张图基本决定了ST-MTM在你数据上是锦上添花还是白费功夫。