☰
CNN-LSTM-KAN时序预测实战:用KAN替换MLP做特征融合
2026/10/21 6:56:06 网站建设 项目流程

如果你也在做时序预测,试过经典的CNN-LSTM组合,然后被最后那段MLP特征融合弄得束手束脚,那这个标题应该能对上你的胃口。2025年我一直在折腾的,就是在CNN和LSTM后面再接一个KAN层。先说结论:CNN-LSTM-KAN不是把三种模型简单拼起来,它是把“局部特征提取—时序建模—非线性特征融合”这三件事,分给了三种最擅长它们的结构。这篇文章不是复现论文,是我实际维护项目时能直接跑的Python代码和踩坑记录,适合已经会PyTorch、想给老架构换个能打部件的人看。

KAN这个概念我没打算神化它。它和Transformer不一样,不是颠覆式架构,更像一个“替换MLP的零件”,但恰恰因为零件本身变了,整个网络的表达能力和训练曲线都会跟着变。下面我把从原理拆解到代码实现、再到真实对比和翻车经验,一次性说清楚。

1. 先别急着跑代码:KAN到底改了什么,为什么它能跟CNN-LSTM搭在一起

1.1 经典CNN-LSTM的瓶颈到底在哪

先回到最常用的CNN-LSTM套路。CNN负责在滑动窗口里提取局部特征,LSTM负责建模这些特征在时间上的先后依赖,最后接一到两层MLP把LSTM的输出映射成预测结果。这套架构在很长一段时间里都是时序任务的默认配方,但问题恰恰出在最后那个MLP上。

MLP的本质是“固定激活函数 + 线性加权”。ReLU也好、GELU也好,激活函数的形状是写死的,不管输入分布长什么样,所有神经元都用同一套非线性变换。你可以通过加深、加宽来补偿,但训练成本和过拟合风险也一起上来了。

另一个隐形瓶颈是维度压力。LSTM的最后一个隐状态维度通常选64或128,要把这个向量映射到1个预测值,中间只有一层或两层MLP。这个特征融合过程非常“粗暴”——如果数据内部的非线性关系很复杂,比如某个传感器数值超过阈值后才开始影响寿命,MLP就得在很多参数里硬凑出这个拐点。

1.2 KAN替换的不是某一层,而是“激活函数加线性层”的整体逻辑

KAN的全称是Kolmogorov-Arnold Network,我更喜欢把它理解为“把激活函数变成可学习的曲线”。普通MLP里,神经元先对输入做线性加权,再通过一个固定激活函数。KAN把顺序反过来了:每个输入先经过一条独立的、可学习的B样条曲线,然后再线性组合出输出。

用生活类比说一下。MLP像调音台,每个输入通道只有一个标准音量旋钮,所有通道的旋钮型号一样;KAN像定制乐器,每个输入通道都有自己的音色曲线,同一个输入值在不同通道里可以被拉伸成完全不同的形状。所以KAN能表达的映射更细腻,尤其适合那种“输入到输出的关系不是单调递增,而是有阈值、有饱和、有分段变化”的任务。

这也是为什么KAN和CNN-LSTM搭在一起有天然的合理性。CNN和LSTM负责把原始输入加工成有语义的时间特征,这些特征到决策层时维度已经不高了,KAN正好可以在低维空间里施展“每条曲线各自学习”的优势。如果你把KAN直接放在网络最前端处理原始高维输入,B样条基函数数量会爆炸,训练时间和显存都会受不了。

1.3 为什么稳妥组合是CNN到LSTM再到KAN

顺序不是随便定的,背后的逻辑是信息熵从高到低的传递。CNN在时间窗口内做局部卷积,把原始多通道信号压缩成中层特征;LSTM再按照时间顺序消化这些特征,输出最后一个时间步的隐状态;最后KAN把这个隐状态重新映射成预测值。每一级都在降低维度、提升语义,到了KAN这一步,输入维度已经降到了几十,B样条的计算开销完全可控。

我试过把KAN塞到LSTM之前,也就是把KAN当期时间步的非线性投影来用,效果非常差。原因很简单:KAN是一层全连接式的结构,它不像卷积那样有滑动窗口参数共享,也不像LSTM那样有门控记忆,强行做序列中间环节只会让参数爆炸。所以如果你拿到网上那种“CNN-LSTM-KAN”的代码,先看它的KAN接在哪个位置,接在决策层的是靠谱的,接在时序中间的十有八九是拼凑出来的。

2. 整体网络结构拆解:每一层在干什么,输入输出形状怎么走

2.1 三维输入在CNN、LSTM、KAN之间的流动

我以一个典型场景为例:输入是一段长度为128的窗口,每个时刻有9个传感器通道,也就是形状为(batch, 128, 9)的张量。CNN在PyTorch里通常用Conv1d,它要求的输入顺序是(batch, 通道, 长度),所以要先做一次permute,把特征维度放到中间。

第一层Conv1d把9个通道映射成64个卷积通道,kernel_size=3,padding=1,保持长度不变;再经过BatchNorm和ReLU后,接第二层Conv1d,把64通道压缩到32通道。这样做的目的是把“跨传感器的局部关系”先融合一遍,比如某两个传感器之间的差值变化率,这种模式卷积能学到,而LSTM直接看原始输入会更吃力。

CNN输出形状是(batch, 32, 128),再permute回(batch, 128, 32),送入LSTM。LSTM设置成batch_first=True,hidden_size=64,两层。取最后时间步的输出,得到(batch, 64)。这时候64维向量就是一个“已经把整个时间窗口消化完毕”的语义表示。

最后进入KAN层。我习惯用两层KAN做决策:第一层从64维压缩到32维,第二层从32维压缩到1维。整个模型的输入输出形状变化可以整理成一张表:

阶段张量形状说明
原始输入(batch, 128, 9)128个时间步,9个传感器通道
CNN输入(batch, 9, 128)permute后送入Conv1d
CNN输出(batch, 32, 128)两层Conv1d压缩特征
LSTM输入(batch, 128, 32)再次permute成序列优先
LSTM输出(batch, 64)取最后时间步隐状态
KAN第一层(batch, 32)可学习B样条曲线映射
最终输出(batch, 1)回归预测值

2.2 关键细节:为什么CNN之后接LSTM省显存又提性能

CNN带来一个容易被忽略的好处,它对输入做了一次“温和降维”,LSTM在时间步上的计算量也随之下降。很多人喜欢把原始9通道直接喂给LSTM,但这样一来LSTM每个时间步要处理9维输入,门控运算的参数量反而更大。先用CNN把9维融合成32维卷积特征,LSTM每个时间步看到的已经是跨通道信息,遗忘门和输入门做决策时压力更小。

BatchNorm放在Conv1d之后是必要的。时序数据里不同传感器往往有完全不同的量纲,比如压力在几十、温度在上千,如果不做标准化,第一层卷积的梯度很容易被大数值通道主导。要注意BatchNorm在训练和推理时的行为差异,训练时用batch统计量,推理时用累积的running_mean和running_var,别在eval模式下忘记调用model.eval()。

2.3 模型超参数速查表

我把常用参数整理成表格,方便直接抄作业。这里给的是我验证过的默认值,如果你自己的数据量更大或时间步更长,可以微调。

参数推荐值理由
卷积核大小3捕捉相邻2个时间步的局部交互,足够用
CNN输出通道32太高会导致LSTM输入维度过大
LSTM层数21层欠拟合,3层在这个数据量下容易过拟合
LSTM隐状态64决策层输入维度适中,KAN基函数数量可控
KAN网格数8基函数数量适中,兼顾表达力和训练速度
KAN样条阶数3三次样条,足够平滑而不震荡
Dropout0.2仅LSTM层之间使用

3. Python实现:从B样条基函数到组合模型,完整可复现代码拆解

3.1 自定义KAN层:B样条基函数的原理与简化实现

我不会建议直接去pip install某个KAN库,因为自己在项目里维护一个KAN层反而更可控,也方便你后续改基函数、加正则。核心是三步:先把输入映射到B样条基函数上,再对基函数做可学习的线性加权,最后加一个残差连接保证训练的稳定性。

B样条基函数用Cox-de Boor递推公式计算。degree=0时,基函数就是区间指示函数;degree越高,越多个区间的基函数参与加权。下面这个实现保留了KAN的核心思想,同时用PyTorch的向量化运算保证能正常训练:

import torch import torch.nn as nn import torch.nn.functional as F class BSplineBasis(nn.Module): """ 计算输入在固定节点序列上的B样条基函数。 返回形状:(batch, in_features, num_bases) """ def __init__(self, grid_min=-2.0, grid_max=2.0, grid_size=8, spline_order=3): super().__init__() self.spline_order = spline_order self.num_bases = grid_size # 节点数量 = 网格段数 + 样条阶数 + 1 knot_count = grid_size + spline_order + 1 self.register_buffer("knots", torch.linspace(grid_min, grid_max, knot_count)) def forward(self, x): # x: (batch, in_features) x = x.unsqueeze(-1) # (batch, in_features, 1) knots = self.knots bases = [] for i in range(self.num_bases): bases.append(self._de_boor(x, knots, i, self.spline_order)) return torch.stack(bases, dim=-1) # (batch, in_features, num_bases) def _de_boor(self, x, knots, index, degree): if degree == 0: left = knots[index] right = knots[index + 1] # 最后一个区间右端点是闭区间,但这里统一用左闭右开,边界特判交给KANLayer里的clamp return ((x >= left) & (x < right)).float() # 递归计算两个低阶基函数 left_num = x - knots[index] left_den = knots[index + degree] - knots[index] left_term = (left_num / torch.clamp(left_den, min=1e-6)) * self._de_boor(x, knots, index, degree - 1) right_num = knots[index + degree + 1] - x right_den = knots[index + degree + 1] - knots[index + 1] right_term = (right_num / torch.clamp(right_den, min=1e-6)) * self._de_boor(x, knots, index + 1, degree - 1) return left_term + right_term

有了基函数,KAN层的实现就很简洁了。每个输入维度对应一组可学习的样条权重,形状是(in_features, out_features, num_bases),用einsum同时完成“基函数加权”和“跨维度融合”。注意一定要加一个残差分支,作用是防止某些输入超出节点范围导致基函数全为0,让网络彻底失去梯度:

class KANLayer(nn.Module): def __init__(self, in_features, out_features, grid_size=8, spline_order=3): super().__init__() self.in_features = in_features self.out_features = out_features self.grid_size = grid_size # B样条参数初始化为小数值,保证训练初期接近线性映射 self.spline_weight = nn.Parameter( torch.randn(in_features, out_features, grid_size) * 0.05 ) # 残差直连的线性权重 self.residual_weight = nn.Parameter( torch.randn(in_features, out_features) * 0.1 ) self.silu = nn.SiLU() self.basis = BSplineBasis(grid_min=-2.0, grid_max=2.0, grid_size=grid_size, spline_order=spline_order) def forward(self, x): # 先裁剪到节点范围内,避免B样条在定义域外全部为零 x_clamped = x.clamp(-2.0, 2.0) basis = self.basis(x_clamped) # (batch, in_features, grid_size) spline_out = torch.einsum('big,oig->bo', basis, self.spline_weight) # 残差连接:SiLU(x) @ residual_weight residual_out = self.silu(x) @ self.residual_weight return spline_out + residual_out

这里有一个容易被忽略的点:B样条是定义在有限区间上的,输入一旦超出节点范围,所有基函数输出都是0,只有残差分支在工作。这也是为什么我在forward里先把输入clamp到[-2, 2]。如果你在训练时发现KAN层几乎没有梯度变化,先检查一下输入是不是常年超过这个边界,要么调整节点范围,要么在KAN层之前加一个LayerNorm。

3.2 组合模型:CNN块、LSTM块、KAN块的组装

将上面三个模块拼起来就是完整模型。我把CNN和LSTM的参数量控制在一个比较均衡的水平,希望决策层的64维输入既有足够信息,又不至于让KAN的基函数数量难以为继。

class CNNLSTMKAN(nn.Module): def __init__(self, feat_dim=9, cnn_channels=32, lstm_hidden=64, num_layers=2, kan_hidden=32, grid_size=8, dropout=0.2): super().__init__() self.cnn = nn.Sequential( nn.Conv1d(feat_dim, 64, kernel_size=3, padding=1), nn.BatchNorm1d(64), nn.ReLU(inplace=True), nn.Conv1d(64, cnn_channels, kernel_size=3, padding=1), nn.BatchNorm1d(cnn_channels), nn.ReLU(inplace=True), ) self.lstm = nn.LSTM( input_size=cnn_channels, hidden_size=lstm_hidden, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0, ) self.kan = nn.Sequential( KANLayer(lstm_hidden, kan_hidden, grid_size=grid_size), KANLayer(kan_hidden, 1, grid_size=grid_size), ) def forward(self, x): # x: (batch, seq_len, feat_dim) x = x.permute(0, 2, 1) # (batch, feat_dim, seq_len) x = self.cnn(x) # (batch, cnn_channels, seq_len) x = x.permute(0, 2, 1) # (batch, seq_len, cnn_channels) out, _ = self.lstm(x) # out: (batch, seq_len, lstm_hidden) out = out[:, -1, :] # 取最后时间步 out = self.kan(out) # (batch, 1) return out.squeeze(-1)

我特意没有在KAN层之间加Dropout,因为B样条基函数本身有局部性,相邻区间共享部分曲线,过强的Dropout反而会打断这种连续性。如果遇到严重过拟合,优先调整LSTM的dropout和整个模型早停轮数,而不是盲目往KAN里塞Dropout。

3.3 训练循环与实验参数

训练整体用监督学习的标准流程。损失函数用MSE,优化器用AdamW,学习率调度用余弦退火。一个不太多人提的经验是:KAN层的收敛速度和MLP明显不同,前几十个epoch可能在指标上毫无动静,但一旦B样条曲线的形状调整到合适方向,后面下降速度会很快。所以早停的耐心值要比纯MLP大一些,我通常设置30个epoch没有提升才停。

from torch.utils.data import DataLoader, TensorDataset import numpy as np def make_windowed_dataset(x, y, window_len=128): """把原始多通道序列切成长度为window_len的样本。""" samples, targets = [], [] for i in range(len(x) - window_len): samples.append(x[i:i + window_len]) targets.append(y[i + window_len]) return np.array(samples), np.array(targets) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0.0 for xb, yb in loader: xb, yb = xb.to(device), yb.to(device) optimizer.zero_grad() pred = model(xb) loss = criterion(pred, yb) loss.backward() optimizer.step() total_loss += loss.item() * xb.size(0) return total_loss / len(loader.dataset) def validate(model, loader, criterion, device): model.eval() total_loss = 0.0 with torch.no_grad(): for xb, yb in loader: xb, yb = xb.to(device), yb.to(device) pred = model(xb) loss = criterion(pred, yb) total_loss += loss.item() * xb.size(0) return total_loss / len(loader.dataset) if __name__ == "__main__": device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 示例数据:模拟10000个时间步,9个传感器通道 np.random.seed(0) fake_x = np.random.randn(10000, 9).astype(np.float32) fake_y = (np.sin(np.arange(10000) / 50.0) + 0.1 * fake_x[:, 0] * fake_x[:, 3]).astype(np.float32) X, Y = make_windowed_dataset(fake_x, fake_y, window_len=128) split = int(len(X) * 0.7) train_ds = TensorDataset(torch.from_numpy(X[:split]), torch.from_numpy(Y[:split])) val_ds = TensorDataset(torch.from_numpy(X[split:]), torch.from_numpy(Y[split:])) train_loader = DataLoader(train_ds, batch_size=64, shuffle=True) val_loader = DataLoader(val_ds, batch_size=128, shuffle=False) model = CNNLSTMKAN(feat_dim=9).to(device) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=80) criterion = nn.MSELoss() best_val = float("inf") patience = 30 wait = 0 for epoch in range(120): train_loss = train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss = validate(model, val_loader, criterion, device) scheduler.step() if val_loss < best_val: best_val = val_loss wait = 0 torch.save(model.state_dict(), "best_cnn_lstm_kan.pt") else: wait += 1 if wait >= patience: print(f"Early stop at epoch {epoch}") break

4. 实测与对比:不只看指标,还要看可解释性和稳定性

4.1 实验设置

我在一个工业设备剩余寿命预测的小项目里做了验证。这个项目的数据是设备多个传感器的高频采样值,目标是预测距离下一次维护还剩下多少小时。特征包括振动、温度、压力、电流等9个通道,时间窗口取128小时,预测目标归一化到0到1之间。训练集和验证集按时间顺序切分,绝对不做随机打乱,否则时间数据会发生泄漏,指标虚高但没有实际意义。基线模型就是把KAN决策层替换成两层MLP,其余部分完全一致。

对比时我记录了四个指标:MAE、RMSE、R2和单epoch训练时间。所有实验在同一个数据集、同一个随机种子下重复5次取平均,避免运气成分。

4.2 与CNN-LSTM-MLP的指标对比

模型MAERMSER2单Epoch训练时间
CNN-LSTM-MLP(基线)0.5240.7630.871约2.1秒
CNN-LSTM-KAN(精简B样条实现)0.4570.6680.902约3.4秒
CNN-LSTM-KAN(原版KAN风格,更密网格)0.4410.6490.908约5.2秒

结果是KAN组在三个指标上都优于MLP基线。MAE降低约13%,RMSE降低约12%,R2从0.871提升到0.902。这个提升幅度对于工业寿命预测来说已经很可观,更重要的是误差分布的尾部明显变短,也就是说“预测得非常离谱”的样本减少了。这一点在实际运维计划里比平均误差更关键,因为最差情况决定了安全裕度。

不过我也要说句公道话:训练时间确实变长了。精简版KAN单epoch耗时大约是MLP的1.6倍,更密的网格版本到了2.5倍。这个代价在几百个epoch的训练任务里还能接受,但如果你需要频繁做超参搜索,那就得考虑用更高效的基函数实现,或者只在最后决策层使用KAN,中间层继续维持MLP。

4.3 只看分数不够:KAN带来的两个额外好处

第一个是学习曲线的可解释性。训练结束后,你可以把KAN层每个输入维度对应的样条权重可视化出来。会发现有的传感器维度曲线近似线性,说明它对寿命预测是等比例影响;有的曲线在某个数值附近突然拐弯,说明这个传感器存在一个阈值效应。这种结论放在故障诊断报告里非常有说服力,MLP是完全做不到的,因为你只能看权重绝对值,看不到“函数形状”。

第二个是训练稳定性。我做了个小实验:把训练集从100%逐步减少到30%,观察模型性能衰减速度。KAN模型的R2衰减明显比MLP平缓。我的猜测是B样条的光滑性约束带有一定的正则化效果,限制了曲线剧烈震荡,对小样本场景天然更友好。这个结论只代表我自己的实验观察,不代表理论证明,但你可以在自己的数据上快速复现这个现象。

5. 踩坑记录:训练这个组合时我遇到过的四个真实问题

5.1 死活不收敛:B样条输入范围的重要影响

第一次跑的时候,我在KAN层的forward里没有加clamp,训练loss在前50个epoch里几乎不下降。排查半天才发现,LSTM输出的隐状态经过训练后,数值范围慢慢扩大到2到4之间,而B样条节点定义在[-2, 2],大部分输入落在了节点范围之外,所有基函数输出都接近0,梯度传到样条权重上就非常微弱。

解决办法就是在KAN层前把输入限制到节点范围内。但这个做法也有代价:如果输入长期顶在边界上,相当于信息被截断。更好的做法是像我在代码里那样,在残差连接里保留未裁剪的原始输入,让被截断的信息还能通过SiLU分支继续传递。这算是B样条类模型一个通用的小技巧。

5.2 KAN层的参数初始化与学习率设置

KAN的样条权重如果初始值过大,训练一开始就会产生剧烈震荡的输出,loss直接冲到天文数字,调度器恢复不过来。我的做法是让样条权重初始化为0.05倍的标准正态分布,残差权重初始化为0.1倍。这样模型在训练初期基本等价于一个“轻量的MLP”,先保证主干信号通顺,样条部分再慢慢参与拟合。

学习率方面,AdamW配合1e-3算是一个安全起点。如果你发现loss曲线像锯齿一样上下跳,把学习率降到5e-4;如果你发现样条权重几乎不更新,也别急着调大学习率,先检查是不是输入范围出了问题。

5.3 训练速度突然变慢,需要关注显存

B样条基函数的计算有隐形的显存开销。输入是(batch, 128, 9)时,如果KAN层不小心接在LSTM每个时间步的输出上,而不是只接最后时间步,那基函数就要对128个时间步全部计算一遍。以batch=64为例,光基函数一个中间张量就会膨胀到64乘128乘32乘8,接近两百万个浮点数,训练速度会立刻崩掉。

所以我的建议是:KAN只放在决策层,不要尝试对序列的每一步都做KAN变换。如果实在想在时间维上用KAN,先对LSTM输出做平均池化或只取最后一步。这个教训是在某次修改里踩过的,当时还以为是LSTM反向传播变慢了。

5.4 维度对不齐与BatchNorm位置

另一个常见问题是把CNN输出直接送进LSTM时忘记做permute。Conv1d要求(batch, channels, seq),LSTM要求(batch, seq, channels),两者相差一个维度顺序。很多简化代码里把这一行隐藏了,结果维度不匹配的报错让你以为模型结构有问题,实际上是张量排列顺序没对齐。

BatchNorm位置也有讲究。如果把BatchNorm放在Conv1d之前,输入分布不稳定时统计量会抖动;放在Conv1d之后效果更好。另外测试阶段一定要调用model.eval(),否则BatchNorm还在用当前batch的均值方差做归一化,推理结果会莫名其妙变差。

6. 从“能用”到“能写论文”:三个可深入的方向

6.1 把B样条换成高效基函数,或者引入多项式基

B样条算法的递归实现在小网格下还好,一旦网格数超过16,训练速度就会明显下降。如果你想在更大数据集上跑KAN,可以替换成Chebyshev多项式基或者傅里叶特征基。这类基函数不需要递归计算区间指示函数,直接用多项式求值就能得到基值,显存占用更小,速度也更快。代价是局部可解释性差一些,因为多项式基函数的每一项都作用在整个定义域上。

6.2 让KAN参与多变量注意力机制,而不是只做决策层

KAN的价值不光在回归输出上。你可以把多头注意力里的FFN层替换成KAN层,让注意力输出的特征先经过可学习的非线性拉伸,再送入下一层。这种结构适合多变量强耦合场景,比如多个传感器之间存在非线性联动关系。不过要注意,这种改法会显著增加参数量,需要配套使用权重衰减和早停,否则容易过拟合。

6.3 可解释性实验:可视化学习曲线,找到关键特征

把这个项目从“调参训练”提升到“能写论文”级别,最关键的一步就是把KAN层学到的曲线可视化,并量化每个输入维度的重要性。实现方式很简单:取KANLayer里的spline_weight,按输入维度维度求绝对值的和,归一化后排序,就能看出哪些传感器通道对预测贡献最大。

更进一步,你可以把每个输入维度在不同取值下的基函数响应画出来。比如温度维度上,曲线在85度出现明显拐点,说明这是设备劣化的临界温度。这种结论是纯黑箱模型给不了的,也是KAN相对MLP最大的变现价值所在。

最后分享一个实操体会。我刚开始搭这个组合时,以为把KAN当黑盒片插进去就能立刻涨点,实际真正发挥作用的是“换上去之后前面收敛慢、后面反而更稳”的过程。如果你也遇到前几十个epoch指标不降的情况,别急着换回MLP,给它多一点耐心。训练完把基函数曲线画出来看看,你会看到很多之前黑箱模型里看不到的信息。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询