CNN-LSTM-KAN混合模型:多变量时间序列预测实战
1. 为什么我把CNN、LSTM、KAN这三个模型拼在一起先说结论这套结构不是我为了“堆模型”而堆出来的。我手上的任务是一个典型的多变量时序回归问题历史窗口里有十几路传感器数据要预测未来一段时间的关键指标。以前的做法是卷积提特征送进LSTM最后接两层全连接输出。结果整体可用但曲线跟踪总觉得差口气——尤其是拐点附近预测值老是钝钝的。后来偶然看到KAN的思路把最后两层的MLP换成了KAN层同样的数据验证集误差明显往下走了一截。从那时候起我把这套组合当作自己在时序建模项目里的主力方案。CNN-LSTM-KAN说白了就是把三件事分开干CNN负责从原始序列里抠局部模式LSTM负责记住跨时间步的依赖关系KAN则接手最后一步“从抽象特征到目标输出”的复杂映射。前两个模块大家已经很熟真正让我觉得有增量的是KAN这个输出头。传统MLP是靠固定激活函数ReLU、tanh这类加线性权重去拟合函数而KAN让每个“激活函数”本身变成可学习的曲线。这意味着模型最后的那张“脸”能拥有更灵活的决策边界。这篇文章适合谁看如果你已经在用CNNLSTM做时间序列、故障诊断、手势识别、负荷预测这类任务想看看能不能进一步提升或者你听说过KAN但不太确定它具体怎么嵌入既有模型那这篇就是给你准备的。我这里提供的是完整可跑的Python代码核心是基于PyTorch的KAN层实现以及把它和1D-CNN、LSTM拼起来的完整模型。1.1 三个模块各自的“专业分工”我习惯用一句话描述它们的职责这样在调参时脑子里会有清晰的地图CNN扫序列中的局部片段提取空间或局部时间特征相当于一个“局部侦察兵”。它看到的是短窗口内的形状变化比如一段时间序列的尖峰、凹陷、平台期。LSTM把所有局部特征按时间顺序作一遍阅读维持并更新内部记忆状态相当于“带着上下文的读者”。它能捕捉“前面几轮发生了什么对当前意味着什么”。KAN把LSTM最后吐出的高维特征通过一组可学习的B样条基函数加权组合映射到最终预测值相当于“做最终决策的函数拟合器”。这三个角色互不替代。你让LSTM直接吃原始多维序列也未必不行但CNN相当于先替LSTM做了一次“降维和清洗”让LSTM只关注更有价值的时间动态。至于KAN和MLP的差异我会在第二章节重点展开。1.2 为什么这套组合到2025年仍然值得用神经网络架构层出不穷但2025年依然有大量业务场景数据量不大不小模型又不能太大。CNN-LSTM-KAN的优势正好落在这个空档里CNN和LSTM部分是成熟且稳定的骨干KAN作为决策头只增加了少量参数却显著提升了非线性拟合能力。我做过一个简单对比实验同样的训练数据只替换输出头一个用两层全连接一个用单层KAN。两者参数量相差不大但KAN版本的验证集R²更高尤其是预测目标函数本身存在分段特征、不同区间规律不同的时候KAN的B样条能学着在不同区间上给出单独的走势这是固定激活函数的普通神经网络不容易做到的。2. 先把最陌生的KAN层搞明白如果你对KAN不熟不要慌它没有大家想象中那么玄。理解它的关键就两点一是背后的K-A表示定理二是在代码里怎么把“可学习的激活函数”实现出来。2.1 从K-A表示定理到可反向传播的网络层K-A表示定理说的是任意一个多元连续函数都可以用有限个一元函数相加的形式来表达。这句话翻译成人话就是复杂的高维映射可以被拆成一系列简单的单变量函数组合来完成我们不需要非要用一个“黑盒”去硬扛所有复杂度。KAN抓住的正是这个思路。普通Linear层是y W x b然后外面套一个固定激活函数。KAN把它改成y sum(可学习的单变量函数(x_i))这个“可学习的单变量函数”在常见实现里用B样条来拟合。B样条可以理解为把坐标轴切成若干个区间每个区间上用一个低阶多项式片段去逼近曲线片段之间保证光滑拼接。样条的控制点坐标和拼接系数可以通过训练调整这样网络就能自由地“弯出”各种形状的映射函数。2.2 我实现的KAN层B样条 SiLU混合结构我参考主流KAN实现的思路写了一个适合嵌入混合模型的轻量KAN层。它由两部分组成基础线性投影部分使用SiLU激活函数保证最坏情况下也不会丢掉基本的线性和非线性能力B样条部分用Cox-de Boor递推计算每个输入在样条基上的展开系数再通过可训练权重加权求和。import torch import torch.nn as nn import torch.nn.functional as F class KANLinear(nn.Module): 一个可直接嵌入PyTorch模型的KAN层。 参数: in_dim: 输入特征数 out_dim: 输出特征数 grid_size: 网格区间数越大拟合越细腻 spline_order: B样条阶数通常取3即可 def __init__(self, in_dim, out_dim, grid_size5, spline_order3): super().__init__() self.in_dim in_dim self.out_dim out_dim self.grid_size grid_size self.spline_order spline_order # B样条基函数总数 网格区间数 样条阶数 self.n_basis grid_size spline_order # 可训练权重每个输入维度在每一条样条基上的输出权重 self.spline_weight nn.Parameter(torch.zeros(in_dim, self.n_basis, out_dim)) self.base_weight nn.Parameter(torch.zeros(in_dim, out_dim)) nn.init.kaiming_uniform_(self.spline_weight, a0.1) nn.init.kaiming_uniform_(self.base_weight, a0.1) # 构造扩展网格。默认输入被归一化到 [-1, 1] base_grid torch.linspace(-1, 1, grid_size 1) step base_grid[1] - base_grid[0] left torch.linspace(base_grid[0] - spline_order * step, base_grid[0] - step, spline_order) right torch.linspace(base_grid[-1] step, base_grid[-1] spline_order * step, spline_order) grid torch.cat([left, base_grid, right]) self.register_buffer(grid, grid) def forward(self, x): # 输入形状: (batch, in_dim) x torch.clamp(x, -1.0, 1.0) bases self.compute_basis(x) # (batch, in_dim, n_basis) # 样条加权输出 spline_out torch.einsum(bin,ino-bo, bases, self.spline_weight) # 基础线性输出保留SiLU非线性 base_out F.silu(x) self.base_weight # (batch, out_dim) return spline_out base_out def compute_basis(self, x): 使用Cox-de Boor递推计算B样条基函数。 返回: (batch, in_dim, n_basis) batch, in_dim x.shape x x.unsqueeze(-1) grid self.grid total_nodes len(grid) # 0阶基函数判断输入落在哪个节点区间 prev torch.zeros(batch, in_dim, total_nodes - 1, devicex.device) for i in range(total_nodes - 1): prev[:, :, i] ((x grid[i]) (x grid[i 1])).float() # 处理右端点 prev[:, :, -1] (x grid[-1]).float() # 递推生成高阶B样条基 for k in range(1, self.spline_order 1): cur_len total_nodes - 1 - k cur torch.zeros(batch, in_dim, cur_len, devicex.device) for i in range(cur_len): d_left grid[i k] - grid[i] if d_left 1e-12: cur[:, :, i] (x - grid[i]) / d_left * prev[:, :, i] d_right grid[i k 1] - grid[i 1] if d_right 1e-12: cur[:, :, i] (grid[i k 1] - x) / d_right * prev[:, :, i 1] prev cur return prev # 形状自动收敛为 (batch, in_dim, n_basis)这段代码里有两处关键细节。一是输入必须先做裁剪归一化否则样本值超出网格范围B样条基在边界外梯度会退化这一点我在后面的踩坑部分还要细讲。二是einsum把“每个输入维度在每条样条基上的贡献”和“该基函数到输出节点的权重”一次性算出来既简洁又不容易写错索引。2.3 网格和样条阶数怎么选我把网格参数经验整理了一张表方便直接抄数据复杂程度grid_sizespline_order说明目标函数平滑规律简单3~52参数少训练快目标函数有明显分段特征6~103能拟合更多样的形状高维复杂映射10~153小心过拟合需要加正则以上是一般经验需要结合数据量调整网格数越大模型表达能力越强但也越容易记住噪声。如果你的训练样本只有几千条不建议把grid_size开到10以上。样条阶数一般用3就够了提高阶数带来的增益有限计算开销却明显增加。3. 组装完整模型的Python实现有了KAN层接下来就是把CNN和LSTM按正确的张量形状接起来。这里最容易出问题的是维度的排列顺序所以我会先讲清规则。3.1 张量形状约定PyTorch的1D卷积希望输入形状是(batch, channels, length)其中length是时间步而LSTM在batch_firstTrue时希望输入是(batch, length, features)。所以中间必须做两次转置(batch, timesteps, features) ↓ permute(0, 2, 1) (batch, features, timesteps) → CNN ↓ permute(0, 2, 1) (batch, timesteps, channels) → LSTM我在模型里加了AdaptiveMaxPool1d(n_timesteps)把卷积后的序列长度固定回原始时间步长。这样无论kernel_size怎么设置都不会因为卷积步长或池化把时间维弄乱。3.2 完整模型代码import torch import torch.nn as nn class CNNLSTMKAN(nn.Module): CNN-LSTM-KAN混合模型。 输入: (batch, n_timesteps, n_features) 输出: (batch, out_dim) def __init__(self, n_features, n_timesteps, lstm_hidden64, lstm_layers2, out_dim1, dropout0.3): super().__init__() self.n_timesteps n_timesteps # 1D CNN模块 self.conv nn.Sequential( nn.Conv1d(in_channelsn_features, out_channels32, kernel_size3, padding1), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(kernel_size2, stride1, padding0), nn.Conv1d(in_channels32, out_channels64, kernel_size3, padding1), nn.BatchNorm1d(64), nn.ReLU(), # 把长度重新对齐到 n_timesteps nn.AdaptiveMaxPool1d(n_timesteps), ) # LSTM模块 self.lstm nn.LSTM( input_size64, hidden_sizelstm_hidden, num_layerslstm_layers, batch_firstTrue, bidirectionalFalse, ) self.dropout nn.Dropout(dropout) # KAN决策头 self.kan KANLinear(in_dimlstm_hidden, out_dimout_dim, grid_size5, spline_order3) def forward(self, x): # x: (batch, timesteps, features) x x.permute(0, 2, 1) # → (batch, features, timesteps) conv_out self.conv(x) # → (batch, 64, timesteps) x conv_out.permute(0, 2, 1) # → (batch, timesteps, 64) lstm_out, _ self.lstm(x) # → (batch, timesteps, lstm_hidden) # 取最后一个时间步的输出作为时序特征 last_feature lstm_out[:, -1, :] # → (batch, lstm_hidden) last_feature self.dropout(last_feature) output self.kan(last_feature) # → (batch, out_dim) return output这个模型结构上很直观两层卷积提局部特征两层LSTM做序列记忆最后一层KAN做预测映射。如果你做的是回归任务out_dim1直接输出标量分类任务则把out_dim设成类别数外加CrossEntropyLoss。3.3 参数量估算和轻量变体我简单估算了一下这个模型的参数量。假设输入特征是12路时间步是64第一层卷积12×3×32再加上偏置大约1200个参数第二层卷积32×3×64大约6200个参数LSTM两层每层hidden64一层LSTM大约有4×(64×6464×6464)≈3.3万参数两层约6.6万KAN层in_dim64out_dim1grid_size5spline_order3那么n_basis8参数量为64×8×1512加上基础权重64个约600。全部加起来不到8万参数属于非常“轻”的模型。实际项目里如果你有时间步更长或特征更多的需求我建议优先加深LSTM而不是加大KAN收益更明显。另一个轻量做法是让CNN部分只输出单通道特征图再用一个平均池化把时间步压缩让LSTM的输入更短。这个变体会牺牲一部分时序细节换来训练速度适合在探索阶段快速验证想法。4. 训练与评估把模型真正跑起来模型搭好之后训练流程和普通PyTorch模型几乎没有区别但有几个细节影响到KAN能不能发挥出优势。4.1 数据切分、归一化与窗口构造这里有一条铁律凡是喂给KAN的特征必须做归一化。因为我的KANLinear默认输入范围是[-1,1]超出部分会被裁剪。训练前把每个特征缩放到[-1,1]区间最简单的方式是MinMaxScaler也可以用均值方差标准化后再截断。import numpy as np from sklearn.preprocessing import MinMaxScaler def build_samples(features, target, window): X, y [], [] for i in range(features.shape[0] - window): X.append(features[i:i window]) y.append(target[i window]) X np.stack(X) y np.stack(y) return X, y # 假设 full_features: (N, n_features)full_target: (N,) scaler MinMaxScaler(feature_range(-1, 1)) scaled_features scaler.fit_transform(full_features) X_all, y_all build_samples(scaled_features, full_target, window64) # 再按时间顺序切分 train/val/test不能随机打乱 train_end int(len(X_all) * 0.7) val_end int(len(X_all) * 0.85) X_train, X_val, X_test X_all[:train_end], X_all[train_end:val_end], X_all[val_end:] y_train, y_val, y_test y_all[:train_end], y_all[train_end:val_end], y_all[val_end:]为什么要按时间顺序切分因为时序数据一旦随机打乱验证集就会“偷看”未来的样本得到的评估结果会虚高。我自己初学时吃过这个亏后来才统一成时间顺序切分。4.2 训练循环、学习率调度和早停训练循环里我会格外关注KAN层参数的学习率。AdamW的默认学习率是1e-3但KAN样条权重的梯度有时偏大或偏小。保险起见我通常把整体学习率设在2e-4到5e-4之间再用余弦退火调度器慢慢衰减。import torch from torch.utils.data import DataLoader, TensorDataset train_ds TensorDataset( torch.tensor(X_train, dtypetorch.float32), torch.tensor(y_train, dtypetorch.float32).unsqueeze(-1) ) val_ds TensorDataset( torch.tensor(X_val, dtypetorch.float32), torch.tensor(y_val, dtypetorch.float32).unsqueeze(-1) ) train_loader DataLoader(train_ds, batch_size64, shuffleFalse) val_loader DataLoader(val_ds, batch_size64, shuffleFalse) model CNNLSTMKAN(n_features12, n_timesteps64, out_dim1) criterion nn.MSELoss() optimizer torch.optim.AdamW(model.parameters(), lr3e-4, weight_decay1e-5) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) best_loss float(inf) patience 0 for epoch in range(50): model.train() epoch_loss 0.0 for xb, yb in train_loader: optimizer.zero_grad() pred model(xb) loss criterion(pred, yb) loss.backward() # 防止梯度爆炸对LSTM和KAN都有保护效果 torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() epoch_loss loss.item() * xb.size(0) epoch_loss / len(train_ds) model.eval() val_loss 0.0 with torch.no_grad(): for xb, yb in val_loader: pred model(xb) val_loss criterion(pred, yb).item() * xb.size(0) val_loss / len(val_ds) scheduler.step() if val_loss best_loss: best_loss val_loss torch.save(model.state_dict(), best_cnn_lstm_kan.pt) patience 0 else: patience 1 if patience 8: print(fepoch {epoch}, early stopping) break print(fepoch {epoch:02d}, train loss {epoch_loss:.4f}, val loss {val_loss:.4f})我特别建议保留grad_clip这一步。LSTM在长序列上容易出现梯度范数骤增KAN的样条基在边界附近的梯度也会放大。梯度裁剪不会让模型更快收敛但能显著减少训练中段突然发散的概率。4.3 回归任务与分类任务的评估指标评估方法取决于你的任务类型。我做过两个方向指标如下任务类型损失函数主要指标补充指标单步回归MSELossMAERMSE、R²多步预测MSELoss / SmoothL1Loss各步MAE均值分段误差分析二分类BCEWithLogitsLossAccuracy、F1AUC、PR-AUC多分类CrossEntropyLossAccuracy、F1混淆矩阵回归任务里我尤其推荐先看MAE再看R²。MAE的单位和原始标签一致直观反映“平均偏了多少”R²则反映相对均值基准的提升幅度对异常敏感。如果项目要求不同的误判代价再额外配置损失权重。5. 实测踩坑记录与调参体会这套模型我前后跑了不少轮下面几条是真正影响结果的坑写出来希望你别再走一遍。5.1 输入不归一化KAN网格彻底失效我之前有一版代码偷懒没有做特征缩放直接把原始传感器数值送入模型。结果训练Loss下降得很奇怪前几十步几乎不动后来突然飙升。排查后发现问题出在KAN的网格上——原始数据量纲差异太大有的特征在0.01级别有的在几千级别。B样条网格一经初始化就在[-1,1]附近几千级别的输入全部被裁剪KAN完全学不到曲线形状。解决方法是在数据进入模型前必须让每个特征落在[-1,1]区间。归一化之后B样条网格才能有效覆盖输入分布。这也是为什么很多KAN代码仓库都会在示例里提前做MinMaxScaler。5.2 LSTM返回的hidden state与batch_first细节我一开始写LSTM部分时用的是hidden状态lstm_out, (h_n, c_n) self.lstm(x) last_feature h_n[-1] # 最后一层的隐藏状态这个用法没有错但后来发现如果LSTM有dropout或者多层堆叠取h_n[-1]只拿到了最后一层的最终状态容易把中间层的语义丢掉。更稳妥的常见做法是取lstm_out[:, -1, :]也就是所有时间步里最后一步的输出。两种方式在单层LSTM下结果完全一样但换成两层时后者更符合我想要的“把全部层次加工过的信息拼进去”。如果你的任务对最后一步特别敏感建议两个都试一次对比验证集误差再定。还有batch_firstTrue很容易写漏。漏掉的话输入就需要是(length, batch, features)和CNN输出的排列接不上模型会直接报维度错误排查起来也很费劲。我习惯在模型开头加一行注释把形状约定写清楚。5.3 KAN的初始化与学习率KAN层权重初始化我用了kaiming_uniform_这是实践下来比较稳的选项。用全零初始化会让样条部分一开始完全没有信号训练起步非常慢用标准正态分布初始化又容易导致激活值过大B样条基在边界处几乎全部塌陷。学习率方面我的经验值是整体用AdamW学习率3e-4附近比纯LSTM模型建议的1e-3要低一些。KAN层样条权重的梯度在训练初期震荡比较明显把学习率压低、加上梯度裁剪之后模型收敛曲线会平滑很多。5.4 把KAN放在LSTM之前会怎样我也试过把KAN放在卷积之后、LSTM之前做一层“特征重塑”结果没有惊喜反而更差。原因是KAN层的输出形状不太好和LSTM的时间步融合。要么把每个时间步单独过KAN参数翻几倍要么把整条序列压成一个向量再过KAN时间信息又丢了。相比之下把KAN放在最后做输出头是成本最低、收益最稳定的一种接法。还有个小细节不推荐再在KAN后面接BatchNorm。KAN的输出已经是最终预测再接归一化层会破坏尺度一致性尤其在回归任务里会让输出范围被拉向0附近预测曲线整体偏钝。最后再分享一点我的使用心得如果你只打算拿这套模型跑一个快速验证我建议按这个顺序来做先准备好归一化的数据用默认的grid_size5, spline_order3把模型跑通确认训练Loss能正常下降然后固定其他部分单换KAN和MLP层做一组对比量化收益。我在实际项目里最大的感受是CNN-LSTM-KAN不是那种“换上去立刻见效”的银弹它更像是把最后的决策层打开了一个新的自由度。当任务本身的映射规律比较简单时KAN和MLP差异不大但当预测目标存在明显的分段行为、不同区间风格不同的时候KAN的B样条激活能带来实质提升。未来我打算继续试试把KAN用在多步预测的解码器部分或者把KAN层和Transformer的FFN块融合看能不能在更长的序列任务里再挤出一截收益。