读第一遍这篇论文的时候,我把标题里的 universal 理解成了“一个模型训完就能横跨所有数据集”,读完正文才发现自己完全跑偏了。TS2Vec 提出的时序表示学习方案,真正做的事情是让同一个编码器同时吐出两种粒度的表示——整段序列一个向量,每个时间戳一个向量,然后下游不管是分类、回归、异常检测还是缺失值填补,都能从这份表示里直接取用。这套思路在对比学习框架下落得很扎实,也是我近几年在做传感器时序建模时反复回头翻的一篇工作。
下面这篇内容不打算做逐段翻译式的复述,而是按照“它到底通用了什么、编码器为什么长这样、对比损失在学什么、复现时哪里最容易翻车、四类下游任务怎么接线”的顺序展开。适合已经了解对比学习基本概念(正负样本、InfoNCE、温度系数)并且手上有时序数据想找一套靠谱无监督预训练方案的读者;如果你只是想知道“这东西能不能直接拿来用”,第 1 节和第 5 节可以先看。
1. 把“通用表示”这个词拆开看,TS2Vec 到底通用了什么
1.1 三种最常见的误读
我见过不少讨论把这篇论文的卖点概括成“一个模型通吃所有时序数据集”,这属于典型误读。论文的实验设置是:在目标数据集上先用无标签数据做自监督训练,得到编码器,再用同一份数据的少量标签做线性探针或微调评估。它并没有做跨数据集的零样本迁移,一个在电力负荷上训出来的编码器,扔到心电图数据上大概率是不work的。所谓“通用”,指的是表示形式本身的通用性,而不是权重层面的通用。
第二种误读是把表示理解成“一条序列一个向量”。如果只有整段池化后的向量,那它和早期的一大堆时间序列分类方法没有本质差别,也没法做异常定位和缺失值填补。TS2Vec 的输出其实是一个长度为 T、维度为 F 的矩阵,池化向量只是其中一行的副产品。
第三种误读是认为对比学习必须要超大 batch 才能凑够负样本,于是拿图像领域的经验往这儿套,把 batch 拉到几百。时序对比学习的负样本来源和图像很不一样,TS2Vec 里绝大多数负样本来自时间戳维度,同一条序列内部不同时间位置之间天然构成负对,batch 大小的重要性被结构性地削弱了一大截。
1.2 论文真正的通用性落点:一套编码器,两种粒度输出
这个设计的核心就一句话:编码器保留完整的时间分辨率,池化只发生在需要全局表示的那一刻。对于一条长度为 T 的输入序列,编码器输出每个时间戳的隐表示 h_1 到 h_T,每个都是 F 维;然后把这条时间维度上的向量做 max pooling,得到一个 F 维的实例级表示 r。r 用来回答“这条序列整体像什么”,h_i 用来回答“第 i 个时刻局部像什么”。
这份设计带来的直接好处是下游任务的接线方式非常灵活。做分类和回归,用 r 接一个线性层;做异常检测,用 h_i 逐点打分再平滑;做缺失值填补,用缺失位置前后的 h 去推断原始数值。更妙的一点是,r 不是唯一可用的池化结果——你可以只对某个子区间 [a, b] 上的 h 做池化,得到一个“局部实例表示”。这意味着同一个编码器可以生成不同时间粒度的表示,短到几十步的窗口、长到上千步的整段,都能落进同一个表示空间里比较。
我之前在一个设备振动信号的项目里就用到了这个特性。整段十分钟的振动信号池化后判断“这台设备今天状态是否异常”,再把异常段对应的几十秒窗口单独池化,用同一套距离度量去看它最像历史上哪一类故障形态。不用训两个模型,也不用改网络结构,只需要在推理时控制池化的区间范围。
1.3 什么任务适合直接套,什么任务最好别碰
| 任务类型 | 用哪层表示 | 下游接法 | 需要注意的点 |
|---|---|---|---|
| 序列分类 | 实例级 r | 线性探针(冻结)或整体微调 | 类别极度不均衡时线性探针容易塌向多数类 |
| 数值回归 | 实例级 r | 一般建议微调而非冻结 | 冻结表示对幅度信息不敏感,回归精度会掉 |
| 点级异常检测 | 时间戳级 h_i | 局部池化 + 距离打分 | 需要按窗口平滑,否则误报密集 |
| 缺失值填补 | 时间戳级 h_i | 线性映射回原始通道空间 | 缺失段太长时上下文信息不足 |
| 变长序列分类 | 局部池化 r | 同分类 | 必须处理 padding mask |
| 通道数频繁变化的场景 | 不建议 | 需要重训 | 输入通道数是模型结构的一部分 |
最后一行值得单独说一下。TS2Vec 的输入投影层把通道数 C 映射到隐维度 F,这个 C 是训练时定死的。如果你的业务里传感器数量经常增减,每次都要重训,那不如老老实实做手工特征或者换一套对通道数不敏感的模型。这不是模型的缺陷,任何基于固定输入通道的神经网络都这样,只是很多人第一次接触表示学习时会忽略。
2. 膨胀卷积加残差堆叠:编码器为什么不用 Transformer
2.1 时间戳级输出这个要求,先把一批结构筛掉了
编码器必须对每个时间位置都输出一个等长的隐向量,这一条硬性约束直接排除了最常见的那种“先卷到底再池化”的结构。普通 CNN 靠 stride 或 pool 降采样来扩大感受野,降采样之后时间分辨率就丢了,你想再恢复出每个时间戳的表示就得额外做上采样,位置对齐也会变得很麻烦。
RNN 系列天然满足逐位置输出,但训练无法并行、长序列梯度传播困难,在几千步的序列上跑起来非常慢,而且对超长依赖的建模能力也一般。标准 Transformer 的注意力是 O(T²) 复杂度,序列长度到两千步时显存开销就比较难看了,更关键的是它对局部形态的归纳偏置很弱,而时序数据的判别信息往往就藏在短时形态里(一次冲击、一段平台、一个陡降)。
膨胀卷积(dilated convolution)正好卡在中间:卷积核在时间轴上是滑动的,输出保持和输入等长,每个位置一个向量;通过逐层翻倍的膨胀系数,感受野指数级扩大;计算复杂度是 O(T·k·F²),对 T 是线性的。残差连接再叠上去,层数堆到十几层也不会梯度消失。
2.2 感受野到底够不够,得算一下
膨胀卷积的感受野公式不复杂,对于核大小 k、共 L 层、第 l 层膨胀系数为 d_l 的结构,感受野是 1 + 2·(k−1)·Σd_l。如果膨胀系数按 2 的幂次增长(1, 2, 4, 8, ...),那么 Σ 部分约等于 2^L − 1,最终感受野约等于 1 + 2(k−1)(2^L − 1)。
拿常用的 k=3、L=10 代进去:1 + 2×2×(1024−1) ≈ 4093。也就是说这个编码器在理论上能看到四千步左右的上下文。对于大部分工业传感器、生理信号、用户行为序列来说,单条序列长度在几百到两千之间,十层足够覆盖整条序列,每个时间戳的表示都包含了全局信息。
这个计算在实操里有两个用处。第一个是决定层数:如果你的序列只有 128 步,十层是明显冗余的,膨胀系数很快就会超过序列长度,后面几层的卷积核几乎都在跟 padding 打交道,白算。我一般按“感受野略大于序列长度”来选层数,128 步大概 6 到 7 层就够。第二个是判断因果性:这个感受野是双向的,第 i 个位置的输出同时依赖左右两侧,第 5 节讲预测任务时会重点说这个坑。
2.3 池化放在最后一步,以及为什么是 max 而不是 mean
编码器输出 T 个隐向量之后,实例级表示由时间维 max pooling 得到。用 max 而不是 mean,论文的理由和直觉是一致的:一条序列的“身份”往往由最显著的那几个局部片段决定,一段长达几分钟的平稳基线如果做平均,会把真正有判别力的那几秒稀释掉。max pooling 相当于在问“这条序列里最强烈的那个特征是什么”,对时间上的相位偏移也更鲁棒——同一个冲击出现在第 100 步还是第 300 步,池化后结果一样。
代价是时间信息丢了。这也是为什么必须有时间戳级的 h_i 分支兜底:一旦下游需要知道“这件事发生在哪”,pooling 后的 r 就完全帮不上忙。
3. 层次化对比损失:两个层级的监督信号分别在管什么
3.1 随机裁剪是时序上最合适的增强,没有之一
做时序对比学习第一个卡点就是“怎么造两个视图”。图像领域成熟的那套(随机裁剪、颜色抖动、旋转、Cutout)搬到时序上大部分不成立。加高斯噪声(jitter)会改变数值的量纲语义,如果下游是异常检测,噪声本身就和异常信号长得像;幅度缩放(scaling)会让模型把“绝对幅度”当成噪声抹掉,而很多任务里幅度恰恰是关键判别信息;打乱时间片(permutation)直接破坏时序依赖,学出来的表示和序列顺序无关,等于放弃了时序建模的意义。
TS2Vec 只用了随机裁剪一种增强:从原序列里随机截取一段连续子序列,两个视图各自独立裁剪,长度可以不同,位置也可以不同。这个操作保留了子序列内部的局部形态、相邻点的相对关系、周期结构的完整性,同时引入了位置偏移和长度差异,正好构成对比学习需要的“同一语义、不同视角”。
我在实践中会把裁剪比例控制在原长度的 50% 到 80% 之间。低于 40% 的时候,如果数据本身周期性很强(比如日周期负荷),裁出来的片段可能只覆盖半个周期,模型会误把“半周期”当成完整语义,学出来的表示在整段推理时反而不稳。高于 90% 时两个视图几乎重合,对比学习的难度太低,损失很快降下去但表示质量一般。
3.2 时间戳级对比在学“位置对齐的局部语义”
这是整篇论文最核心的设计。两个视图分别经过编码器得到 h^1 和 h^2 两组时间戳表示,第 i 个位置的 h_i^1 和 h_i^2 构成正对,负对来自两个视图里所有其他时间位置——既包括同一个视图内的其他位置,也包括另一个视图里的其他位置。
注意:这里的“第 i 个位置”指的是裁剪后子序列内部的索引,不是原序列的绝对位置。因为两个视图的裁剪起点不同,h_i^1 和 h_i^2 对应的原始时刻通常是不一样的。
这个损失函数迫使模型学会一件事:同一条序列在不同裁剪下,同一个相对位置应该产出相似的表示;而不同位置应该产出可区分的表示。换句话说,模型被迫去编码“局部形态 + 相对位置”的联合信息,而不是简单的全局统计量。
这套监督信号带来的副作用恰好是异常检测最需要的:因为表示对局部形态高度敏感,一个从未见过的冲击形态在时间戳表示空间里会明显偏离正常样本的分布,异常分数自然就出来了。
3.3 实例级对比解决的是“这两段是不是同一条序列”
只有时间戳级损失是不够的。想象两个视图裁剪位置完全不重叠,一段取自序列开头,一段取自结尾,它们的时间戳表示本来就没法一一对齐,这时候光靠时间戳级对比会引入大量噪声。实例级对比从更高的视角补上这一环:把两个视图的所有时间戳表示各自池化成 r^1 和 r^2,这一对作为正样本,batch 内其他序列的池化表示作为负样本。
这一层的语义是“整体身份识别”,和图像领域里常见的实例判别式对比学习思路一致。它保证了即使两个视图覆盖的片段完全不同,只要来自同一条原始序列,池化后的表示就应该靠近。
两层损失合起来,才叫“层次化”。缺了实例级,模型学到的是一堆互不相干的局部模式;缺了时间戳级,模型退化成一个只能做整段分类的黑盒,异常定位和缺失填补这两类任务直接做不了。
3.4 时间维度上的归纳为什么用 max 而不是 sum
这个细节论文里写得比较轻,但我觉得是全文最实用的一个设计。时间戳级损失本身是逐位置定义的,每个位置 i 都有一个损失值,最后要在时间维度上聚合。常规做法是取平均,但这里有个问题:两个视图的裁剪起点和长度都不一样,如果强行按索引 i 一一对应,很多“正对”其实错过了真正应该对齐的位置,属于错位匹配。
论文的处理是在时间维度上取 max,也就是挑出匹配得最好的那一对位置作为主要的监督信号,让它们的损失主导梯度。直观理解是:只要两个视图里存在某个位置对得上,模型就至少能从这一对上获得有效监督,而不必强迫所有位置都严丝合缝地对齐。
我实测下来的感受是,这个 max 聚合让训练在裁剪比例比较激进(比如 40%)的时候稳定很多。如果换成 mean,损失会在训练早期剧烈震荡,因为大量错位的位置对会产生随机梯度。
3.5 温度系数和损失权重,这两个旋钮别乱拧
温度系数 τ 控制 softmax 的尖锐程度。τ 越小,模型越关注最难的那些负样本,表示分布越分散;τ 太大则所有负样本权重接近,学出来的表示区分度不足。TS2Vec 用的默认值在 0.2 附近,这个量级在时序对比学习里算是比较通行的选择,我一般在这个值上下浮动,不会跑到 0.05 以下或者 1.0 以上。
两层损失的相对权重也需要调。如果下游任务偏整段判别(分类、回归),可以适当加大实例级的权重;如果偏点位任务(异常检测、填补),时间戳级权重要保住甚至加大。我遇到过一种情况:分类准确率很漂亮,但换成异常检测任务几乎不可用,查下来是训练时实例级损失权重给得太高,时间戳表示退化成了一种“只为池化服务”的中间产物,局部判别力被压掉了。
4. 复现路上真正会翻车的地方
4.1 实例级标准化这一步千万别省
这是我认为最容易被忽略、影响又最大的一步。TS2Vec 的预处理是对每一条序列、每一个通道独立做 z-score 标准化,也就是减去这条序列自身在该通道上的均值、除以自身标准差。注意是“每条序列自己”,不是“整个训练集统一”。
为什么必须这么做?时序数据的绝对幅度往往和业务背景强相关——不同设备、不同用户、不同量程的传感器,数值范围差好几倍。如果按全局统计量标准化,模型学到的表示里会混进大量“这条序列整体偏高还是偏低”的信息,而这类信息在下游通常毫无用处,甚至会误导模型。按实例标准化之后,模型被迫关注形状、趋势、周期这些更稳定的特征。
代价是幅度信息被抹掉了。如果你的任务本身就要判断“这个值是不是超标了”,那标准化后的表示不够用,需要在特征里额外拼上原始的统计量(均值、最大值、分位数),或者干脆不标准化、改用别的归一化方案。这一点在异常检测里特别重要,因为很多工业场景的异常定义就是“超过某个绝对阈值”。
4.2 裁剪长度和 batch 大小是耦合的
前面说过 TS2Vec 对 batch 大小的要求不像图像对比学习那么苛刻,但它并不是完全不敏感。时间戳级损失提供了 O(T²) 量级的负样本,一个 batch 里哪怕只有 8 条序列,只要每条长度在几百步,负样本总量也完全够了。真正受 batch 影响的是实例级损失那一支——batch 只有 8,实例级的负样本就只有 7 个,这一支的监督信号会偏弱。
我的经验是 batch 在 8 到 32 之间比较稳妥。如果显存允许,往上加对实例级损失有帮助;如果显存吃紧,宁可减小 batch 也别缩短裁剪长度,裁剪太短对表示质量的伤害远大于 batch 变小。
4.3 变长序列的 padding mask,漏了就是白训
真实数据里序列长度几乎不可能完全一致。常见的做法是 pad 到 batch 内最大长度,但这里有个坑:卷积层对 padding 位置照样会输出向量,如果你在 max pooling 的时候不加 mask,pad 位置的零值经过若干层卷积后可能变成一个有数值的向量,被 max 挑中,直接污染实例级表示。
正确做法是在池化时把 padding 位置屏蔽掉——比较通用的实现是把这些位置的值置为负无穷再做 max,或者用 mask 加权后做 mean。时间戳级损失那边也要注意,padding 位置不应该参与损失计算,否则模型会被强迫去对齐一堆没有意义的填充向量。
这个 bug 特别隐蔽,因为训练 loss 照样在降,只是降得慢一点,你很难从曲线上看出来。等到下游评估掉点时才回头查,往往已经浪费了好几天。
4.4 出问题时的排查顺序
我把这几年遇到的典型症状和排查路径整理成了下面这张表,按这个顺序走能省不少时间。
| 症状 | 最可能的原因 | 排查动作 |
|---|---|---|
| 训练 loss 完全不降 | 学习率过大、标准化漏做、数据里有大量 NaN | 先把 lr 降到 1e-4 试跑 100 步,再检查输入统计量 |
| loss 降但下游线性探针接近随机 | 表示塌缩、裁剪比例不当、负样本构造有误 | 统计表示向量的方差,看是不是所有样本挤在一个点上 |
| 分类正常但异常检测无效 | 实例级权重过高、时间戳表示被池化目标带偏 | 提高时间戳级损失权重重新训练 |
| 训练集效果好验证集差 | 序列切分时把同一条长序列的片段分到了两边 | 按原始序列 ID 划分数据集,不要按切好的窗口划分 |
| 推理速度慢 | 层数冗余、序列过长 | 按感受野公式裁剪层数,或对长序列分段推理 |
倒数第二行那个坑在时序领域特别常见,因为一条原始记录通常要被切成很多窗口。如果切完之后随机划分训练集和验证集,同一条原始记录的不同窗口会同时出现在两边,模型能通过记忆局部形态拿到虚高的分数。必须按原始记录 ID 做 group split。
5. 四类下游任务的接线方式,细节差别比想象中大
5.1 分类任务:线性探针和微调的取舍
论文里对分类任务主要用线性探针评估——冻结整个编码器,只训练最后一层线性分类器。这种评估方式的目的是验证表示本身的质量,不让下游训练反过来“帮忙”。实际项目里我更常用微调,因为带标签数据通常不多但也不是完全没有,微调能把这部分监督信号利用上,效果一般比纯线性探针高几个点。
微调的时候学习率要压得很低,通常比预训练阶段小一个数量级。原因是编码器已经在一个不错的解附近,学习率大了会直接把表示打散,反而不如冻结。我的做法是分层设置学习率,编码器用小学习率,分类头用正常学习率,这样收敛更稳。
5.2 预测任务里的因果性陷阱
这是我在实际项目里踩得最疼的一个坑。TS2Vec 的编码器是双向的,感受野覆盖左右两侧,第 i 个位置的表示同时融合了它之前和之后的信息。如果下游做的是离线分析(事后回溯、批量诊断),这完全没问题,反而因为能看到完整上下文而更准。
但如果下游要做实时预测,拿第 i 个位置的表示去预测第 i+1 步的值,这个表示里已经包含了第 i+1 步及之后的信息,等于把答案喂给了模型。离线评估的指标会好得离谱,一上线的效果立刻崩掉。解决办法有两个:一是把编码器的卷积改成因果卷积(只往左边看,右侧 padding),重新训练;二是做实时推理时只把历史窗口送进编码器,取最后一个位置的输出。后者实现简单,代价是感受野利用率低一些,每个时间点都要跑一次前向,计算开销上去了。
5.3 异常检测:从时间戳表示到点级分数
这套流程我比较熟。基本链路是:用正常数据(或混入少量异常的无标注数据)训练编码器,然后把待检序列送进去拿到 h_1 到 h_T,对每个位置计算它和“参考表示”之间的距离,作为该点的异常分数,最后做窗口平滑和阈值判定。
参考表示怎么取有讲究。直接用整段池化的 r 会有问题,因为 r 本身是由异常点参与池化得到的,异常会污染参考。比较稳的做法是用训练集正常样本的表示构建一个参考集合,对每个待检位置计算它到参考集合最近邻的距离(kNN 距离);或者用局部窗口池化得到的子段表示与全局 r 做对比。论文里给的具体打分方式我建议直接去看官方实现,涉及多窗口拼接和归一化的细节比较多,自己凭直觉写容易在阈值上反复调整。
阈值选择上不要用固定分位数拍脑袋,拿一小段人工标注的验证数据,画出分数分布,找一个精确率和召回率都能接受的切点,或者按业务成本定(漏报的成本远高于误报时,阈值往下压)。
5.4 缺失值填补:时间戳表示在这里才显出价值
如果只有实例级表示,缺失值填补基本没法做——你只有一个向量,没法还原某个具体时刻的数值。TS2Vec 因为保留了每个时间戳的表示,填补就变成了一件很自然的事:取缺失段前后若干个时间戳的表示,通过一个线性层映射回原始通道空间,得到缺失位置的估计值。
论文里对这个任务做了专门的评估,用线性映射的方式就能拿到不错的填补精度,说明时间戳表示里确实保留了足够的局部数值信息。实操上要注意,缺失段如果很长(比如连续缺了几百步),单靠前后上下文推断中间的值是强人所难,误差会很大。这时候要么把长缺失段切成若干短段分别处理,要么在填补结果上叠一层不确定性估计,把长缺失段的置信度标低。
另外一个细节:训练编码器时如果数据本身就带缺失值,不能用 0 直接填充,因为 0 在标准化后的空间里是一个有明确含义的值,会引入虚假的模式。比较稳妥的做法是用插值先补一遍(线性插值或前向填充),在损失函数里把插值位置 mask 掉不参与计算。
6. 和同期几条技术路线的横向对照
时序表示学习这个方向在论文发表前后有好几条并行的思路,各自的假设不太一样,选型时如果只看单一指标很容易选错。
| 思路方向 | 核心手段 | 优势场景 | 主要短板 |
|---|---|---|---|
| 邻域式对比(TS2Vec 所属) | 时间戳级 + 实例级双层 InfoNCE | 需要点位表示、需要变长输入 | 感受野双向,实时预测要改造 |
| 预测式自监督 | 用历史预测未来作为预训练目标 | 预测类下游任务迁移好 | 表示里时序信息偏强,分类任务不一定占优 |
| 重构式自监督 | 掩码后重建原始片段 | 缺失填补、降噪类任务友好 | 容易学到平凡解,重建目标需要精心设计 |
| 增强式对比(变换组合) | 多种数据增强组合构造视图 | 数据形态丰富时鲁棒 | 时序上可用的增强很少,组合容易破坏语义 |
| 频域视角 | 在频域上做对比或重构 | 强周期信号的判别 | 非平稳信号的频谱漂移处理麻烦 |
我个人的判断是:如果你的任务是分类、回归这类“整段判别”,同时希望顺带能做异常定位,TS2Vec 这套双层结构是性价比很高的选择,训练成本不高,代码也不复杂。如果下游明确只有预测一个任务,预测式自监督往往更直接。如果数据本身缺失严重、主要目标是修数据,重构式那条路更对口。
还有一个常被忽略的选型维度是推理成本。膨胀卷积编码器的参数量很小,隐维度 64 时整个网络也就几十万参数,端侧部署毫无压力。对比之下,基于 Transformer 的方案在同等效果下参数量和延迟都要高一个量级,如果是要塞进嵌入式设备做在线推理,这个差距很关键。
最后分享一点个人体会:这套方法最大的价值不在于它刷新了某个 benchmark 的分数,而在于它把“时序表示应该长什么样”这件事讲清楚了——一份编码器、两个粒度、多种下游接线方式,这个结构本身比具体的损失函数设计更有借鉴意义。我自己后来的几个项目里,即使换了别的对比学习框架,也基本都会保留“时间戳级输出 + 末位池化”这个骨架,因为它给下游留出的操作空间实在太大了。