做过多变量时间序列分类预测的朋友,估计都遇到过同一个尴尬:模型确实能跑,准确率也还凑合,可一旦要解释"为什么把这条样本判成了故障",或者要给业务方讲清楚"哪些传感器通道起了主要作用",传统LSTM那一套黑箱玩法就会当场露怯。注意力权重勉强能看,但离严谨归因差了十万八千里。所以我这两年做这类任务,基本固定用一套组合:TCN做多变量分类预测,再用SHAP做可解释性分析,整套流程在Matlab里完整落地。今天这篇就把从数据窗口化、TCN模型搭建,到SHAP特征归因和可视化的完整链路一次讲透,代码思路也尽量给到可以直接抄作业的程度。适合那些正在用Matlab做深度学习分类、又被"解释性"卡住的朋友参考。
1. 为什么是TCN与SHAP:多变量分类的两块拼图
1.1 TCN凭什么替代LSTM
多变量时间序列分类,说白了就是多个传感器/通道并行采集数据,每个时间点有一个向量,模型要判断整段序列属于哪个类别。这类任务以前的主流方案是LSTM和GRU,但LSTM有几个麻烦:串行计算慢、长序列记忆衰减、调优门槛高。TCN(时间卷积网络)用纯卷积结构处理序列,核心是三件事:因果卷积、膨胀卷积、残差连接。
因果卷积保证输出t时刻只依赖输入t时刻及之前的信息,不会"偷看未来";膨胀卷积用dilation>1的间隔采样,让一个小卷积核也能覆盖很长的历史范围;残差连接避免网络加深后梯度消失。
我用一个生活化类比:LSTM像是批改试卷时从头往后逐题看,必须处理完上一题才能看下一题;TCN则像是不同老师分头批改,每位老师在自己的"视野范围"内同时看若干道题,通过分层配合覆盖整张卷子。所以TCN在GPU上并行效率高,训练速度快,加上滑动窗口做得足够宽时,它捕捉时序依赖的能力并不比LSTM差。
1.2 SHAP解决"信不信"的问题
模型做得再准,落不了地也是白搭。工业设备要上预测性维护,医生要接受辅助诊断,都得先回答一个问题:模型凭什么这么判断?
SHAP(Shapley Additive Explanations)是目前解释性分析里理论基础最扎实的方法。它来源于博弈论里的Shapley值,核心思想是把每个特征当成一个"玩家",把模型预测值当成"合作收益",然后计算每个玩家对收益的边际贡献。它满足可加性、对称性、虚拟性等公理,给出的归因结果是唯一且公平的。简单理解:三个同事一起做完一个项目拿到奖金,如何分配才能让每个人都觉得公平?Shapley值就是那个唯一让所有人无话可说的分配方案。
1.3 整体技术路线
我的方案在Matlab里跑通的具体路线如下:
- 多通道原始数据做滑动窗口切片,窗口化形成样本集
- 按 8:2 划分训练集和测试集,Z-score 归一化
- 搭建TCN残差块网络,设置膨胀率递增序列,实现序列到标签分类
- 训练并评估,输出准确率、F1、混淆矩阵
- 对测试集代表性样本做SHAP分析,先在通道级做枚举式归因,再细化到"通道×时间段"级做采样式归因
- 用条形图和瀑布图输出结果
这套路线的好处是每个环节都能独立验证,不会出现"模型跑完了但不知道哪一步出了问题"的情况。下面我按步骤细拆。
2. 数据准备与窗口化预处理(Matlab实现)
2.1 多变量数据规范化与训练/测试划分
假设你的数据是rawData,维度是时间点数 × 变量数。我一般先做标准化,但标准化必须在划分训练集之后、只统计训练集均值方差,否则测试集信息泄漏,后面SHAP算出来的重要度全是假的。
% 假设 rawData: nTime × nVars,标签 labelVec: nTime × 1 % 划分比例 80/20 cv = cvpartition(length(labelVec), 'HoldOut', 0.2); trainIdx = training(cv); testIdx = test(cv); % 只基于训练集计算均值和标准差 mu = mean(rawData(trainIdx, :)); sigma = std(rawData(trainIdx, :)); dataNorm = (rawData - mu) ./ sigma;这里有三个容易踩的细节:
- 如果原始数据里后续还有新批次数据进来,要保存
mu和sigma供线上推理使用,不要每次重新计算,否则特征尺度总在变。 - 类别不平衡问题一定要在划分之后再看,先看训练集的类别分布。少数类样本少到一定程度,后面的TCN分类就基本可以放弃了,先过采样或加权损失函数处理。
- 标签如果是字符串类别(如"故障A""故障B"),先把它们映射成1到n分类的整数或 categorical,Matlab的分类层要求标签必须是分类变量。
2.2 滑动窗口构造张量
多变量时间序列不能直接整段扔进分类网络,通常要切窗口。窗口长度我初期选windowLen = 80个时间点,步长step = 20,这样窗口之间有重叠,能扩充样本量。窗口切完的样本数约等于 (总点数 - 64) / 20,动手前算一下,别切完才发现样本数不够用。
windowLen = 80; step = 20; n = length(labelVec); numSamples = floor((n - windowLen) / step) + 1; X = zeros(windowLen, nVars, numSamples); % 时间×通道×样本 Y = categorical(zeros(numSamples, 1)); for i = 1:numSamples s = (i-1)*step + 1; e = s + windowLen - 1; X(:, :, i) = dataNorm(s:e, :); % 用窗口末尾时刻的标签作为该窗口的标签,这是一个常见约定 Y(i) = labelCategorical(e); end标签对齐方式建议用窗口末尾的标签,因为分类预测关心的是"当前时刻的状态",窗口前面的部分是判断依据。如果你做的是事件检测,窗口中心标签更合理,但那种情况下窗口设计本身要调整,这里不展开。
有一点必须强调:不要把切好的窗口全部随机打乱后再划分训练集测试集。时间序列相邻窗口强相关,随机打乱会出现"训练集和测试集的信息重叠",看起来准确率很高,一上真实数据就崩。正确做法是先用原始时间轴划分连续区间,再在每个区间内窗口化。有些朋友在这块吃过亏,模型在测试集上跑到98%,换新数据掉到70%,就是这个原因。
2.3 数据增强与类别不平衡的补充
窗口重叠本身就是一种弱数据增强。如果少数类仍然不足,可以对少数类样本做小幅抖动:在窗口数据上叠加高斯噪声,标准差取该通道标准差的10%~20%,标号不变。操作上只扩充训练集,测试集保持原样,这点不需要过多解释,但效果立竿见影。
另外,Matlab里可以直接用augmentedImageDatastore或者transform搭配自定义函数做在线增强,省内存,不用提前把所有增强样本都放进内存。
3. TCN模型搭建完整流程
3.1 TCN残差块原理与层结构
TCN的基本单元是残差块,每个块里有两条路径:主路径先做膨胀因果卷积、批归一化、ReLU、Dropout,再重复一组;跳跃连接把输入直接加到输出上。如果输入输出通道数不一致,就在跳跃连接上加一个1×1卷积做通道对齐。
膨胀率的选择很关键。常见的做法是按1, 2, 4, 8, 16指数递增,叠加4到5个残差块。有效感受野的理论值大约为:
感受野 ≈ 1 + (卷积核大小 - 1) × Σ(每层的膨胀率)
例如卷积核为3、5个残差块、每块两层卷积、膨胀率1,2,4,8,16,总感受野大约是1 + 2×(3-1)×(1+2+4+8+16) = 1 + 4×31 = 125个时间点。如果窗口长度只有80,感受野超过窗口长度,网络利用率不足,此时可以减少残差块数量或缩小膨胀率。这是一个很实在的调参依据,我在初版搭建时习惯先把感受野算清楚,再定网络深度,而不是盲目堆层数。
3.2 Matlab层图构建与超参数设置
用Matlab的深度学习工具箱构建TCN,核心函数是convolution1dLayer和additionLayer。以4个残差块、每块两层卷积为例,核心层图代码如下:
filterSize = 3; numChannels = 8; % 输入通道数 numFilters = 32; % 第一块输出通道数 dilations = [1 2 4 8]; lgraph = layerGraph(); % 输入层 tempLayers = [sequenceInputLayer(numChannels, 'Normalization', 'none', 'Name', 'input')]; lgraph = addLayers(lgraph, tempLayers); % 依次添加残差块 lastLayer = 'input'; for blockIdx = 1:length(dilations) d = dilations(blockIdx); blockPrefix = ['block' num2str(blockIdx)]; % 主路径:两层膨胀因果卷积 convLayers = [ convolution1dLayer(filterSize, numFilters, ... 'Dilation', d, 'Padding', 'causal', 'Name', [blockPrefix '_conv1']) batchNormalizationLayer('Name', [blockPrefix '_bn1']) reluLayer('Name', [blockPrefix '_relu1']) dropoutLayer(0.1, 'Name', [blockPrefix '_drop1']) convolution1dLayer(filterSize, numFilters, ... 'Dilation', d, 'Padding', 'causal', 'Name', [blockPrefix '_conv2']) batchNormalizationLayer('Name', [blockPrefix '_bn2']) reluLayer('Name', [blockPrefix '_relu2']) dropoutLayer(0.1, 'Name', [blockPrefix '_drop2']) ]; lgraph = addLayers(lgraph, convLayers); % 跳跃连接:通道对齐 skipLayers = [ convolution1dLayer(1, numFilters, 'Name', [blockPrefix '_skipConv']) ]; lgraph = addLayers(lgraph, skipLayers); % 加法层 addLayer = additionLayer(2, 'Name', [blockPrefix '_add']); lgraph = addLayers(lgraph, addLayer); % 连接 lgraph = connectLayers(lgraph, lastLayer, [blockPrefix '_conv1']); lgraph = connectLayers(lgraph, lastLayer, [blockPrefix '_skipConv']); lgraph = connectLayers(lgraph, [blockPrefix '_relu2'], [blockPrefix '_add/in1']); lgraph = connectLayers(lgraph, [blockPrefix '_skipConv'], [blockPrefix '_add/in2']); lastLayer = [blockPrefix '_add']; end % 分类头:全局平均池化 + 全连接 + softmax headLayers = [ globalAveragePooling1dLayer('Name', 'gap') fullyConnectedLayer(numClasses, 'Name', 'fc') softmaxLayer('Name', 'softmax') classificationLayer('Name', 'classification') ]; lgraph = addLayers(lgraph, headLayers); lgraph = connectLayers(lgraph, lastLayer, 'gap');这里有几个值得注意的点:
convolution1dLayer的Padding='causal'在较新的Matlab版本里直接可用,如果你的版本不支持,就用Padding='same'再加一层crop或者手动截掉末尾多出的部分来保证因果性。- 每个残差块我加了两次Dropout,中间层的Dropout率0.1到0.2都合理。数据量越小,Dropout越要保守,否则训练不足。
- 第一个块的输入通道数来自
sequenceInputLayer的numChannels参数,通常等于你的传感器通道数。后续块之间通道数保持一致时,skipConv其实也可以省掉,直接连接加法层,但为了通用性都保留。
3.3 训练配置与性能评估
训练配置我固定推荐这几个选项:Adam优化器、初始学习率0.001(或者0.002,超过0.005基本就发散)、MiniBatchSize选32或64、GradientThreshold设为1或2、验证频率按迭代次数走。
options = trainingOptions('adam', ... 'InitialLearnRate', 0.001, ... 'MaxEpochs', 60, ... 'MiniBatchSize', 32, ... 'GradientThreshold', 2, ... 'Shuffle', 'every-epoch', ... 'ValidationData', {XValid, YValid}, ... 'ValidationFrequency', 10, ... 'Plots', 'training-progress', ... 'Verbose', false); net = trainnet(lgraph, XTrain, YTrain, options);我在某模拟的多通道传感数据上跑完这套配置,分类准确率大约93%,测试集F1在0.92左右。关键指标不只是准确率,还要看混淆矩阵里的类别间混淆情况,尤其是两类故障类样本的区分度。多变量分类里如果类别A容易被判成类别B,往往意味着这两个类在通道模式上本身就接近,后续SHAP分析可以进一步揭示它们到底靠哪个通道区分开。
4. SHAP可解释性分析(Matlab实现)
4.1 Shapley值与KernelSHAP原理
SHAP把模型预测值分配到各个特征上:样本预测值 = 基线值 + 各特征SHAP值的和。从数学上说,特征j的Shapley值是它在所有可能的特征子集联盟中,加入前后预测变化的加权平均。
KernelSHAP是SHAP家族的实用变体。它把这个组合爆炸的排列计算问题转化为一个加权线性回归问题,核心公式是:
g(z') = φ0 + Σ(φj × z'j)
其中z'是联盟向量,取1表示特征在联盟中,0表示不在。把大量联盟的预测值收集起来,用SHAP核权重做带权最小二乘回归,回归系数就是各特征的SHAP值。这个思路巧妙在:不需要对模型做任何结构上的改动,只要模型能对任意特征子集给出预测,就能算出归因。这也是它能套在TCN这种复杂模型头上的原因。
4.2 通道级SHAP:枚举式KernelSHAP实现
对多变量时间序列分类,最自然的解释粒度首先是"通道"。把每个传感器通道当作一个特征,解释时把某个通道的整条时间序列替换成背景均值序列,相当于"把这个传感器关掉",看预测变化。
如果你的输入窗口是8通道×80时间点,通道级特征只有8个,完整枚举所有联盟只需2^8 = 256次模型预测,在当前算力条件下完全可以接受。
% 假设 trainedNet 已训练,XSample 是窗口数据:8×80 % backgroundX 是背景参照,取训练集逐通道均值序列 nChannels = 8; numSubsets = 2^nChannels; % 生成所有联盟向量 subsets = 1:numSubsets; Zmask = dec2bin(subsets-1) - '0'; % numSubsets × nChannels % 权重计算 w = zeros(numSubsets, 1); for i = 1:numSubsets s = sum(Zmask(i, :)); if s == 0 || s == nChannels w(i) = 1e6; % 空集和全集给极大权重 else w(i) = (nChannels - 1) / (nchoosek(nChannels, s) * s * (nChannels - s)); end end % 逐联盟预测 scores = zeros(numSubsets, 1); for i = 1:numSubsets Xmasked = XSample; zeroCols = find(Zmask(i, :) == 0); Xmasked(zeroCols, :) = backgroundX(zeroCols, :); % 注意:predict需要单样本维度,按你的数据层结构调整 p = predict(trainedNet, Xmasked); % 取该样本真实类别的概率,或者目标类别的概率 scores(i) = p(1, targetClass); end % 加权最小二乘解 Z = [ones(numSubsets, 1), Zmask]; W = diag(1 ./ w); phi = (Z' * W * Z) \ (Z' * W * scores); % phi(1)是基线,phi(2:end)是各通道SHAP值这段代码关键在于predict对单个样本的形状要求,以及targetClass的选择。我一般对多分类问题统一解释"目标类概率",也就是该样本被分到的那个类别;如果你想看"为什么不是某个特定类别",就把目标类换成那个类别,SHAP值代表各个通道对错判概率的贡献。
4.3 细化到"通道×时间段"的采样式SHAP
通道级解释粒度够用但略粗,因为没法回答"这个通道的前半段还是后半段起作用"。这时候可以把窗口按时间切成4段,每段聚合出均值、斜率、方差等统计量,再把"通道×时间段"当作一个特征,特征数可能来到几十个。
特征数超过20以后,枚举所有联盟就不现实了,改用采样式KernelSHAP:随机抽取一部分联盟做预测,再回归。Matlab里没有现成的shapley工具箱,但你可以自己实现一个简易采样版工具函数。
% 采样版KernelSHAP:随机抽 maxSubsets 个联盟 maxSubsets = 2000; M = size(featureMask, 1); % 总特征数 % 先按SHAP权重分布采样:优先采样联盟大小在1和M-1附近的 % 简单做法是均匀随机采样后按权重加权回归 for iter = 1:maxSubsets z = randi([0, 1], M, 1); % 计算联盟预测,存入向量... end这里提醒一句:KernelSHAP的采样结果有一定随机性,同一批测试样本跑两次,特征排序可能在边缘处有小幅抖动。解决办法是固定随机数种子rng(42),或者对多个采样种子取平均结果。SHAP分析追求的是相对稳定的贡献排序,不是小数点级别的精确数值。
4.4 特征贡献可视化
全局特征的排列图比较简单,直接把每个特征在所有测试样本上的平均|SHAP|值排个序,用条形图展示。平均绝对值反映了特征的平均影响力,正负符号则说明特征往哪个方向推。
% meanAbsShap: 每个特征的平均|SHAP| [vals, idx] = sort(meanAbsShap, 'descend'); figure; barh(vals); set(gca, 'YTickLabel', featureNames(idx)); xlabel('平均绝对值SHAP值'); title('全局特征重要度排序');样本级瀑布图更能说明"为什么这条样本被判成X类"。从模型基线值出发,每个特征对应一个箭头,向上表示推高预测概率,向下表示压低概率,最终堆积到实际预测值。
% shaps: 某样本各特征SHAP值 baseValue = phi(1); figure; % 简单瀑布堆积示意 cumVal = baseValue; for i = 1:length(shaps) yLine = cumVal + shaps(i); plot([i-1, i], [cumVal, yLine], 'b-', 'LineWidth', 2); cumVal = yLine; end实际应用中我不追求Matlab里做出Python那种交互式力导向图,条形图和瀑布图足够写进实验报告和技术方案。将来如果要给非技术背景的人看,可以把每个特征翻译成业务语言,比如"2通道前段方差贡献+0.13,是模型判定为故障A的最大依据"。
5. 踩坑记录与排查技巧
5.1 TCN训练中的常见坑
第一个坑是因果填充问题。低版本Matlab不支持Padding='causal'时,我用same凑合,结果训练误差降不下去,后来发现是卷积输出序列在时间对齐上错位了。当时排查了很久,最后在验证集上按时间顺序逐点对比预测结果才看出来。所以优先升级环境或检查文档,宁可先算清楚填充量,也不要硬用same掩盖错误。
第二个坑是感受野不够。有个数据集窗口长度120,我只堆了3个残差块,膨胀率1,2,4,感受野只有1+(3-1)×(1+2+4)=15,远小于窗口长度,模型几乎只能看到开头一小段信息。发现测试集对长周期样本的区分度特别差后,把膨胀率改成1,2,4,8,16,准确率直接涨了近10个百分点。TCN调参第一件事永远先算感受野覆盖度,这点我怎么强调都不为过。
第三个坑是训练后期过拟合。TCN数据量少时,训练集准确率能跑到99%,测试集只有80%。解决办法是提前分配10%训练集做早停监控,配合ValidationPatience在验证损失连续多轮不降时终止训练。加上Dropout率适当调到0.2,一般都能压住过拟合。
5.2 SHAP计算中的坑
做SHAP时最容易出问题的是背景替换策略。如果你把某些通道替换成"整段序列的全局均值",模型可能因为输入分布整体偏移而给出不正常的预测,特别是通道均值本身有慢漂移趋势时。更稳妥的做法是逐通道减去该通道均值、加上背景参照值,保持单通道内的波动结构不变,只是把"水平"拉回背景。实操中也可以直接对训练集随机抽一批样本的通道序列取平均作为静态背景。
第二个坑是SHAP分析的特征粒度和模型输入不一致导致解释失效。有人直接在80×8=160维的扁平特征上算SHAP,行数爆炸不说,维度间高度相关,算出来的SHAP值相互抵消,排序完全失真。正确姿态是像我前面讲的那样,先在通道或语义特征层面定义特征集,再计算SHAP。解释性分析的第一步是定义"什么是一个特征",这比跑代码重要得多。
第三个坑是采样随机性带来的结论不稳定。我踩过一次:上午分析某特征重要度第一,下午重跑变成第二,差点以为自己改了数据。后来把随机种子固定,并且对同一测试集每个特征的平均SHAP值计算标准误,边缘排序的特征直接标注"差异不显著",不再勉强解读。
5.3 几个实操心得
先说结论:TCN的稳定性和速度确实比LSTM好,尤其适合长窗口的多通道数据。但TCN调参的入口比较特殊,如果你刚上手,强烈建议先在小规模窗口数据上做一个"感受野-准确率"曲线,找到窗口和膨胀率的匹配区间再做大模型,省下的时间足够你多跑两轮实验。
SHAP这边的核心心得是:模型结构和解释性设计要同时想,不要等模型训完再来补解释。我在搭数据管线时就会先想好最终要解释的粒度是通道还是通道×时间段,让输入特征的组织方式与解释粒度一致,后面做SHAP几乎零成本。反过来,如果你等模型训练完成才琢磨解释,往往要么特征粒度不对,要么特征数目太大,只能返工重建特征管道。
另外建议每次实验都把SHAP结果输出CSV存档,包含特征名、SHAP值、样本ID、预测类别,方便后续对比不同版本的模型解释差异。我试过几次,这种做法在排查模型"学到了奇怪的规律"时特别有用。
最后分享一个小习惯:所有SHAP分析固定R2023a版本环境,固定随机种子,并且在后处理时保留置信区间。这样每次实验的可解释性结论都可以被复核,对论文、报告或者技术评审来说,这一步能让整个深度学习模型的可信度上一个台阶。