前阵子我给一个制造厂做设备振动数据的故障分类,遇到一个挺有意思的矛盾:单看某段时间窗口,波形确实有明显异常形态,但真正把故障和正常运行完全分开的,往往是好几个传感器之间的联动变化。这种场景下,纯CNN能抓住局部形态但学不懂变量间的耦合,纯Transformer能把全局依赖建模得很好,但真出了问题,很难说清楚到底是哪一段波形在起作用。KDD2024上的这篇ShapeFormer,正好是冲着这个矛盾来的——它用Shapelet这个东西给Transformer注入了显式的局部形态匹配能力。这篇博文我打算把它拆开讲清楚:Shapelet和Transformer各自的短板是什么、ShapeFormer是怎么把它们揉到一起的、我理解的核心机制有哪些,以及如果你想自己复现或落地这个思路,有哪些注意事项和实践经验。
1. Shapelet和Transformer各自的“性格缺陷”:为什么要把它们放一起
1.1 Shapelet的两面性:极强的可解释性,但建模天花板明显
Shapelet这个概念最早可以追溯到Ye和Keogh那篇经典论文,核心思想非常朴素:在一条时间序列里,可能存在某一段子序列,它一旦出现,就能很大程度决定这条序列的类别。打个比方,你觉得心电图里某个特定形态的QRS波群是病人A的特征,那这个“特定形态的QRS波群”就是一个shapelet。
传统shapelet方法的做法是:先从训练集里搜索/生成一批候选子序列,然后拿每条候选子序列去和所有样本做滑动匹配,计算它们之间的距离(最常见的是z-score归一化之后的欧氏距离),把这些距离值作为新特征喂给分类器。整个过程非常直观,而且可解释性极强——模型告诉你“因为这段波形和这个shapelet很像,所以判断为故障”,工程师能直接去查对应时间段的原始信号,验证物理上的合理性。
但这个方向后来在深度学习时代逐渐边缘化,原因也很实在:
- 早期shapelet搜索是基于枚举和剪枝的,计算开销很大,放到现代大规模数据集上不现实。
- 搜索/学习出来的shapelet之间彼此独立,特征表达力有限。很多真实场景里,类别模式不是靠单一局部形态就能区分的,而是“A变量在这个时间段的形态+B变量在另一个时间段的形态”的组合。
- 多元时间序列里,变量与变量之间的相关性(尤其是滞后相关性、相位偏移)很难用几条独立的shapelet表达清楚。
当然,后来也有团队尝试把shapelet嵌入神经网络框架,用可微方式学习shapelet,但大部分工作聚焦在单变量或简单多元场景,一旦变量数量上来、依赖关系复杂,这些方法就有点吃力。
1.2 Transformer的优势,以及它“看不见局部”的问题
Transformer在多元时间序列分类上能火,主要是靠self-attention那种任意位置之间直接交互的能力。它本质上是在做信息混合:每个位置的表示都能根据自己的query去其他位置“查资料”,从而把远距离依赖、变量间耦合都吸收进来。你不需要手工设计特征,给它足够数据,理论上它能拟合任意复杂的依赖关系。
但我在实际项目里慢慢发现,Transformer对“局部形态”这件事真的不太敏感。原因很简单:attention计算的是加权求和,权重再大,最后得到的信息也是“多个位置特征的混合”,而不是“这个局部片段跟某个标准化模板长得像不像”。你可以让网络在前几层自行学出局部滤波器,但代价是需要更多数据和更长的训练时间,而且学出来的东西通常是不透明的。
多元时序里还有一个非常磨人的点:变量之间的相位偏移。举个我踩过的坑,设备振动信号和电流信号之间的相关性常常有几十毫秒的滞后,纯attention虽然理论上能找到这种滞后关系,但训练样本不够多的时候,它会倾向于把这种关系平均成一个模糊的依赖,于是关键细节就被抹掉了。这种时候,一个显式的、可以指出“哪个时间段、哪个变量、什么形态”的模块,会极大降低学习难度。
所以ShapeFormer的设计动机在我看来非常清晰:与其让Transformer从零开始隐式学习局部形态概念,不如直接把shapelet匹配作为一个结构化的、可解释的归纳偏置注进去。
2. ShapeFormer的架构拆解:从“见树木”到“见森林”的融合方式
需要先说明一点:我这边暂时没有拿到论文的官方源码,所以下面的架构拆解是基于论文标题、方法描述以及序列建模领域的通用做法做的推理。具体模块细节、维度设置、交互方式,建议以论文原文为准,但我保证这里的分析思路是可以直接指导复现的。
2.1 多元时间序列怎么进模型
多元时间序列的输入一般可以表示成 X ∈ R^(C×L),C是变量数,L是时间步长。ShapeFormer这类模型第一步要考虑的,就是把原始序列转成token序列,让Transformer能处理。
常见的做法有两种:第一种是对时间轴做patch化,把原始序列切成若干个长度为p的窗口(可以重叠也可以不重叠),每个窗口通过一个小卷积或线性映射得到d维向量,再加时间位置编码。第二种是在变量维度做token化,每个变量独立作为一个token。多元时序场景下,我强烈建议把变量维度和时间维度分开考虑,不要一上来就把所有变量拼成一个长向量。原因很简单:如果所有变量共享一个token,attention在计算时很难区分“这是变量A在t1时刻的值”和“这是变量B在t1时刻的值”,变量身份信息会被弱化。
ShapeFormer大概率也走的是“patch embedding + 位置编码”这条路。输入多元序列后,先通过一个卷积或线性层把原始信号映射到隐空间,再以token序列的形式进入后续模块。这里有个细节值得注意:多元时序的信号尺度在不同变量间可能差很多——电流可能是个位数级别,振动加速度可能是几十甚至上百,如果不做归一化,后续匹配和attention都会偏向数值大的变量。
2.2 Shapelet Matching模块:把局部形态变成可学习的注意力先验
这个模块应该是整篇论文的题眼。我的理解是,它做的事情可以拆成三步:
第一步,定义一组可学习的shapelet集合,比如K个shapelet,每个的长度是l_s。在多元场景下,一个shapelet可能有两种定义方式:要么是单变量的(只匹配某一个变量的局部波形),要么是跨变量的(同时匹配多个变量的局部波形)。
第二步,对输入序列做滑动匹配。技术实现上,这其实就是一维卷积:把每个shapelet当作一个卷积核,在输入序列上滑过去,得到一张匹配分数图。匹配分数越高,表示这个位置的局部形态与该shapelet越相似。这里有个工程上的捷径——用Conv1d实现滑动点积,效率非常高,比手动循环切窗口快几个数量级。
第三步,对匹配分数做可微的归一化/缩放,比如经过带温度参数的softmax,得到一个在时间轴上的激活分布,表示“这条序列在哪些位置最容易触发这个shapelet”。这个激活分布就是后续模块可以使用的“注意力先验”。
我当时看到这部分的思路时,第一反应是:这其实就是在给Transformer提供一个形态级的注意力偏置。普通attention是数据驱动的,哪里重要由训练决定;而shapelet匹配是先验驱动的,我先告诉你“这些位置和某些已知形态很像”,你再去决定要不要重点看它们。
2.3 Shapelet信息和注意力怎么衔接
这是整个架构里我最想研究清楚的地方,因为它直接决定模型的可解释性和表达力。从我接触过的类似工作来看,衔接方案主要有两条路线:
路线A:把shapelet匹配分数作为attention bias,加到标准attention的logits上。公式大致是 AttentionScore = QK^T / √d + bias,其中bias来自shapelet匹配图的某种变换。这个方案实现简单,训练稳定,而且可解释性非常好——你能直接看到attention在哪些位置被shapelet引导着聚焦。缺点是bias只告诉模型“哪里值得注意”,没有告诉模型“这个位置的具体形态特征是什么”,信息量略少。
路线B:把shapelet当作特殊token,和输入token拼在一起送入Transformer。也就是说,K个shapelet对应K个可学习的“查询token”,在和输入序列做self-attention时,它们能主动从序列里抽取与自身形态相关的信息。这比单纯加bias更灵活,因为shapelet token不仅影响注意力权重,还能把匹配到的片段内容融合进自己的表示里,再通过后续层把信息回传。
从我自己的经验判断,论文很可能采用的是路线B或者两者结合的思路,因为只有这样,才能让shapelet真正参与到特征构建里,而不是只做一个“位置提示器”。如果你想复现,我建议两种都实现一下,做消融对比——这本身就是一个很有价值的实验。
2.4 多元变量间的依赖关系怎么建模
多元时序分类和单变量最大的区别在于,类别模式经常是“组合式”的。比如设备故障,可能表现为变量A在第30到50个采样点出现尖峰,同时变量B在第40到60个采样点出现高频抖动,并且两者之间的相位差有固定规律。这种模式,单靠一条单变量shapelet是学不到的。
ShapeFormer要在多元场景真正有效,需要让shapelet学到“组合形态”。我理解有两种可行的设计:
第一种,定义跨变量shapelet,一个shapelet同时覆盖多个变量,在匹配时分别与各变量片段计算相似度,再在变量维度聚合。好处是能直接捕捉多变量的联合形态,缺点是参数量大,且容易被数值尺度大的变量主导。
第二种,每个变量独立匹配,但匹配结果在attention层里通过跨变量注意力机制融合。路径是:先让每个shapelet在单个变量上找到匹配位置,再把所有变量的匹配结果组合成一个多维特征,交给Transformer去学习变量间的依赖。
我的实测经验是,第二种更稳。因为你先让模型在“形态”层面看到每个变量各自的激活位置,再让它学习“这些激活位置之间的关联”,每一步都有明确的监督信号,训练起来不容易跑偏。第一种虽然看起来更端到端,但梯度很容易被某一个大尺度变量带跑。
3. 训练与推断里的关键机制:不是“拼起来”就完事了
3.1 Shapelet初始化的几个方案
很多人在实现shapelet类模型时,最容易忽略的就是初始化。我第一次把shapelet嵌进深度学习模型时,直接随机初始化了一组shapelet参数,结果训练完一看,K个shapelet学成了一个模子,完全失去了多样性。
核心原因在于,shapelet匹配本质上是非凸的,初值不好很容易陷入局部最优。我后来总结了三套比较靠谱的初始化方案:
第一套,聚类初始化。在训练集上,每个类别分别抽取子序列,做k-means或k-Shape聚类,把聚类中心作为shapelet的初始值。这是最经典的做法,好处是起点低,训练快速稳定。
第二套,随机采样+辅助重建损失。从训练集里随机采样一批子序列作为初值,同时在损失函数里加一个辅助的“序列重建”项,强制每个shapelet能代表一类真实存在的局部形态。
第三套,对比学习初始化。把同一类样本的相似子序列作为正样本对,不同类样本的子序列作为负样本对,先用对比学习把shapelet嵌到一个相对合理的区域,再和分类任务联合微调。这个方案效果好,但工程复杂度也最高。
如果你只想先把模型跑通,我建议从第一套方案入手,成本最低,效果也足够好。等你想在论文里证明某些结论时,再考虑更精细的初始化策略。
3.2 匹配距离度量和温度参数的取舍
shapelet匹配环节的核心是一个相似度计算,常见选择是欧氏距离和点积距离。经典shapelet方法里,欧氏距离配上z-score归一化是标配。原因是:两个子序列如果形状相同但振幅差很多,直接用原始欧氏距离会得到很大的距离值,模型会认为它们“不像”,这显然不符合我们对形态相似性的直觉。
深度学习版本里,直接计算欧氏距离也是可微的,没问题,但要注意梯度的稳定性。当两个子序列差异很大时,欧氏距离的梯度容易剧烈变化,训练初期很容易震荡。相比之下,点积/余弦相似度在数值上更温和一点,但它对序列表征的“能量”很敏感——序列很强时点积天然偏大。所以,如果你用点积,务必要在匹配前做归一化,或者在归一化后的隐空间里做匹配。
温度参数也是一个被很多人忽略的细节。匹配分数经过softmax之前,通常会除以一个temperature。温度高时,softmax输出比较平滑,相当于“软匹配”,适合训练初期让梯度在多个位置上都有反馈;温度低时,softmax输出接近one-hot,相当于“硬匹配”,适合后期精修。我在调参时习惯让温度从1.0开始,训练到后半程逐步降到0.1左右,效果比固定温度好不少。
3.3 局部先验和全局注意力会不会冗余
这个问题是我在一些技术群里看到有人问的:既然有了shapelet做局部匹配,为什么还要套一层Transformer?两者功能不会重叠吗?
我的理解是,shapelet和Transformer作用在完全不同的粒度上。shapelet捕捉的是“局部形态的激活位置和匹配强度”,是一种显式的、可解释的局部先验;而Transformer的self-attention捕捉的是“不同时间位置、不同变量之间的依赖关系”,是一种隐式的、全局的信息混合机制。一个管“这有什么”,一个管“这些之间怎么关联”,两者天然互补。
如果你想自己验证这一点,可以做一个三组实验:纯Transformer、纯Shapelet特征+分类头、ShapeFormer(两者结合)。我敢打赌,在多元时序基准集上,前两者各自会有自己擅长的数据子集,但结合版在整体上更稳,尤其是在局部形态和全局依赖都重要的组合型任务上,提升会更明显。
3.4 推断时的复杂度
最后看一个工程问题:这个模型到底跑不跑得动?shapelet匹配本质上是一组一维卷积,时间复杂度是O(KCLl_s),K是shapelet数量,C是变量数,L是序列长度,l_s是shapelet长度。如果序列很长、shapelet数量多,这部分计算量并不小。
我在实现时的处理思路是:优先对原始序列做patch化,在patch级别做shapelet匹配,而不是在全长原始序列上逐点滑动。这样复杂度可以降不少,而且patch本身已经有了一定的局部语义。另外,推理阶段可以把多个shapelet匹配合并到一层分组卷积里,减少显式的循环开销。对于实时性要求高的场景,这个优化基本是必须的。
4. 这套设计能解决什么真实问题:适用场景与不适用场景
4.1 适合的场景
我最先想到的适合场景,就是我开头提到的工业设备状态监测。这种任务里,工程师的需求不只是拿到一个分类结果,还想知道“为什么”。当一个模型判断设备即将故障时,能指出“变量A在第80到100个采样点之间出现了与故障波形库中某类形态高度相似的片段”,这对现场排查和维修策略的制定非常关键。ShapeFormer的shapelet分支天然提供了这种能力。
第二个典型场景是医疗信号分类,比如心电(ECG)、脑电(EEG)和步态信号。医生看这类数据时,依靠的本身就是特定的波形形态——ST段抬高、特定频率的棘波、步态周期里的某个相位异常。这类问题里,局部形态几乎就是分类的全部依据,shapelet的匹配热力图可以直接辅助医生核验模型的判断逻辑。
第三个场景是多传感器融合的“组合模式”任务,比如人类活动识别(HAR)。走路和跑步在单个传感器上看可能很像,但多个传感器之间的相位和幅度关系不同。ShapeFormer先在各变量上做shapelet匹配,再用Transformer建模变量间依赖,正好匹配这种需求。
4.2 不适合或需要改造的场景
首先,超长序列场景要谨慎。如果一条序列有十几万个时间步,又没法做有效的patch化或降采样,那么attention和shapelet匹配的复杂度都会非常难看。这种情况下,我建议先做子序列分段或时频变换,把输入压缩到几千步级别再上模型。
其次,纯低频趋势型数据不太适合。比如股票指数的日线趋势,主要信息在缓变趋势和长周期的回归关系上,基本没有清晰的局部形态。你硬套一个shapelet模块,它学习的“形态”不会有太多实际意义,不如直接用普通的Transformer或线性模型。
最后,小样本场景要注意过拟合。shapelet数量一多,模型参数就上来了,几十个样本的训练集根本扛不住。我遇到过不少团队在小数据集上直接套大模型,结果训练集准确率100%、验证集准确率不到60%。这种情况下,我偏向于先用shapelet transform做特征提取,再接一个轻量分类器,比直接用深度模型可靠得多。
4.3 从论文评测到业务指标:我建议怎么评估
在论文里,一个模型的价值通常用整体accuracy或F1来体现,但在业务落地时,这个单一数字远远不够。我自己的习惯是多看三个东西。
第一个是混淆矩阵。尤其当类别不平衡时,整体accuracy很容易虚高,但少数类完全没被识别出来。这时候要单独看每个类的precision/recall,确认shapelet是不是只学会了多数类的模式。
第二个是shapelet热力图的稳定性。把同一个类别中多个样本的匹配热力图叠在一起看,如果热力图的峰值位置在不同样本间很一致,说明模型学到了稳定的物理模式;如果每次峰值都飘来飘去,那它大概率是在记忆噪声。
第三个是变量遮蔽测试。把某一个变量整段置零,观察模型输出变化。真正学到多变量依赖的模型,在遮蔽关键变量后,预测结果应该有明显扰动;如果遮蔽任何变量模型都不为所动,说明它实际上只是在依赖其中一两个变量,变量间依赖的建模能力并没有真正发挥出来。
5. 如果要复现或落地,我的实操建议与避坑清单
5.1 数据预处理:不要小看这些步骤
数据预处理对shapelet类模型的影响,远比一般分类模型大得多。因为shapelet匹配是拿“标准模板”去和“原始波形”做相似度比较,任何没处理好的尺度差异、缺失值,都会直接影响匹配分数。
第一,归一化。我建议按样本、按变量分别做z-score归一化,而不是全局归一化。原因很简单:不同设备的传感器灵敏度不同,同一个变量的量纲在不同设备间可能差一个数量级。全局归一化会让所有设备的数值分布被拉平到同一个均值方差,反而抹掉了设备间的真实差异。逐样本逐变量归一化则保留了一个样本内部的形态变化,shapelet匹配会更稳定。
第二,缺失值处理。时序数据的缺失值不能直接删,也不能简单填0。我的建议是优先线性插值或前向填充,然后对填充后的序列做一次平滑,避免插值破坏局部形态。如果缺失段太长,直接把这条样本丢弃可能比硬填更安全。
第三,数据划分一定要按时间顺序切分,不能随机切分。很多时序分类任务里,同一设备或同一受试者的数据是连续采集的,随机切分会造成“训练集里已经有测试集的影子”,模型评估结果虚高。这个坑我见过太多次了。
5.2 模块划分和最小实现
为了让你更直观地理解整个模型的结构,我提供一个极简的PyTorch风格骨架,它不代表论文的完整实现,但能帮你跑通整个流程并做消融实验。这里我用1D卷积实现shapelet匹配,用TransfomerEncoder实现全局建模。
import torch import torch.nn as nn import torch.nn.functional as F class ShapeletMatching(nn.Module): def __init__(self, n_vars, n_shapelets, shapelet_len, temperature=1.0): super().__init__() # n_shapelets个可学习shapelet,每个都是 (n_vars, shapelet_len) 的多变量模板 self.shapelets = nn.Parameter(torch.randn(n_shapelets, n_vars, shapelet_len)) self.temperature = temperature def forward(self, x): # x: (B, n_vars, L) B, C, L = x.shape # 用1D卷积计算每个shapelet在序列上每个位置的匹配分 # 卷积实现:把shapelet看成卷积核,在时间维上滑动 weight = self.shapelets # (K, C, l_s) match_logits = F.conv1d(x, weight, padding=self.shapelets.shape[-1] // 2) # match_logits: (B, K, L) attn = F.softmax(match_logits / self.temperature, dim=-1) # 用匹配激活分布加权原始序列,得到每个shapelet的聚合特征 x_norm = F.layer_norm(x, (C, L)) shape_features = torch.einsum('bkl,bcl->bkc', attn, x_norm) return shape_features, attn class MiniShapeFormer(nn.Module): def __init__(self, n_vars, seq_len, n_shapelets, shapelet_len, d_model=64, nhead=4, num_layers=2, num_classes=2): super().__init__() self.shapelet_matching = ShapeletMatching(n_vars, n_shapelets, shapelet_len) self.shape_proj = nn.Linear(n_shapelets * n_vars, d_model) # patch embedding:用卷积把原始序列切成若干patch并编码 self.patch_len = shapelet_len self.input_proj = nn.Conv1d(n_vars, d_model, kernel_size=self.patch_len, stride=self.patch_len) encoder_layer = nn.TransformerEncoderLayer(d_model=d_model, nhead=nhead, batch_first=True) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) self.cls_head = nn.Linear(d_model * 2, num_classes) def forward(self, x): # x: (B, n_vars, seq_len) shape_features, attn_map = self.shapelet_matching(x) B = x.shape[0] shape_features = shape_features.reshape(B, -1) shape_features = self.shape_proj(shape_features) # patch embedding x_patch = self.input_proj(x) # (B, d_model, num_patches) x_patch = x_patch.transpose(1, 2) # (B, num_patches, d_model) x_patch = self.encoder(x_patch) # (B, num_patches, d_model) x_pooled = x_patch.mean(dim=1) # (B, d_model) combined = torch.cat([x_pooled, shape_features], dim=-1) return self.cls_head(combined), attn_map这个骨架里,我把shapelet匹配和Transformer编码放在了两条平行的路径上,最后把两者特征拼接起来做分类。这么做的好处是结构清晰,方便你分别观察两条路径的贡献。如果你想做“加bias”式的融合改造,可以把attn_map处理成attention bias,注入到encoder内部的attention score里,这就要改写TransformerEncoderLayer了,工作量会大一些。
5.3 调参经验
调到什么参数组合才好用,这个确实得看具体数据集,但我可以分享几个我在多个数据集上验证过的起点值和建议方向。下面这个表格你可以直接拿去当初始配置:
| 参数 | 建议初值 | 调试方向 |
|---|---|---|
| shapelet长度 l_s | 序列长度的10%~20% | 如果类别差异是瞬时尖峰,适当调短;如果是宽脉冲/周期模式,调长 |
| shapelet数量 K | 类别数的2~4倍 | 分类复杂、样本量充足时加大;小样本下减小 |
| temperature | 1.0,后半程退火到0.1 | 训练初期偏大,后期偏小 |
| attention层数 | 2~4层 | 长序列、强依赖场景可加到6层,但注意过拟合 |
| batch size | 32~64 | 视频级超长序列适当调小 |
| 学习率 | 3e-4~1e-3(AdamW) | 配合cosine衰减,最后几个epoch做warmup |
这里尤其想强调shapelet长度的坑。很多人一开始喜欢把shapelet设得很长,觉得能捕获更多信息,但实测下来,过长的shapelet会退化成“整段波形平均模板”,失去了局部形态的区分能力。我自己调试的经验是从短往长试,先用比较短的shapelet找到激活位置的分布,如果发现激活位置分散、无法对齐到特定波形区域,再逐渐加长。
5.4 验证可解释性的三个方法
最后分享三个我自己做可解释性验证时常用的手段,不一定在论文里都有,但对业务落地特别有用。
第一个是热力图叠加。把模型输出的attn_map插值回原始序列长度,和原始波形画在同一张图里。如果某些类别的样本在某个固定时间段出现了明显的激活峰值,而且这个时间段恰好对应着人工标注的异常区,那基本可以认定模型学到了有意义的东西。
第二个是shapelet的信息增益排序。把每个shapelet在各样本上的匹配分数单独提出来,计算它和标签之间的互信息或信息增益,排序后只保留贡献最大的前几个shapelet。如果top shapelet对应的波形形态在领域专家看来是合理的,那解释性就站得住脚。
第三个是消融实验。这个我个人认为是所有验证里最重要的一步。你把模型改成三个版本:去掉shapelet分支、去掉Transformer分支、完整版,在同样的评测集上对比。这个实验能直接告诉你,论文里“shapelet和Transformer互补”的结论在你的数据上是否成立。
最后再聊两句
做时间序列分类这些年,我的一个体会是:很多模型论文在基准数据集上性能提升一点,但真正回到业务现场,用户问你的第一个问题永远是“为什么它这么判断”。ShapeFormer这类把显式形态匹配和全局建模结合起来的思路,至少方向是对的——它在性能和可解释性之间给了工程师一个可以对话的桥梁。
我自己在准备复现这类模型时,习惯把论文的贡献拆成两层看:第一层看它是否引入了新的可解释性手段,第二层看它是否真的提升了某个难点的建模能力。如果两层里至少有一层站得住,这篇论文就值得仔细读。ShapeFormer本质上是在拿“局部形态先验”换取“更稳的全局建模起点”,这个交换划不划算,需要在你自己的数据和任务上验证。如果你也经常处理多元时间序列,尤其是那种“既要解释、又要性能”的业务场景,这篇论文值得花一个下午好好啃一下。