简介一份基于线性神经网络实现曲线拟合与预测的微型代码包面向 MATLAB 环境下的神经网络初学者用正弦/余弦模拟信号演示最小可运行的线性模型训练全流程。RAR 压缩包共 2 个文件包含 1 个 M 脚本与 1 个 Txt 说明文档大小仅 1KB脚本负责定义网络结构、前向传播、损失计算、反向传播与迭代预测文本文件则补充 LNN 原理、参数设置或训练说明便于边看边改。已有 201 人浏览学习适合想脱离工具箱理解梯度下降和线性逼近的读者。通过这份小资源可以掌握一个完整的最小线性神经网络示例从随机初始化权重开始逐步迭代拟合出正弦/余弦曲线并观察不同参数对收敛效果的影响文本说明还能辅助梳理算法的理论动机和实现细节是入门神经网络拟合任务的轻量练手材料。1. LNN曲线拟合为什么不是又一套LSTM在电力负荷、设备振动和金融时序这类场景里余弦信号是周期性变化的最小抽象转子不对中、昼夜电价、日气温波动都可以近似成一条带噪声的余弦曲线。用深度学习做时间序列预测的常规做法是堆LSTM或者上Transformer把序列当作离散token步长固定、窗口固定预测稍长一点就相位漂移。而LNNLiquid Neural Network液体神经网络把序列当作连续时间系统的观测值用几个神经元学到时间常数tau再顺着动力学方程往外推。标题里的“LNN曲线拟合”核心就一件事让一个小规模连续时间网络拟合余弦信号的时间动态并用它做曲线延拓与滚动预测。这篇文章从LTC单元的实现切入用PyTorch跑通带噪声余弦的最小例子再聊到多步滚动预测里真正影响精度的参数。适合正在做深度学习时间序列预测、滚动预测或者想绕过Transformer高成本的工程师。2. LNN的连续时间建模与余弦曲线拟合的任务构造2.1 为什么LNN适合余弦曲线时间常数是内建先验LNN的基础结构叫LTCLiquid Time-Constant网络它的神经元状态不是像LSTM那样“按步更新”而是用一个常微分方程来描述dx/dt -(1/tau f(I)) * x f(I) * A其中x是神经元状态tau是时间常数I是当前输入f是sigmoid激活。这个方程的含义是神经元状态向某个目标值趋近速度由tau和输入共同决定。关键点在于tau不是一个全局固定值而是由输入调制、可学习的。这给了LNN一个很实用的性质它能自适应地“放慢”或“加快”内部动态去匹配输入信号的固有周期。余弦曲线在数学上是一个二阶线性系统只需要两个正交的状态分量sin和cos就能完全描述。这解释了为什么LNN在这个任务上很划算时间和相位信息可以直接编码进连续状态不需要像LSTM那样用大量隐藏单元去隐式记忆周期。用2到8个LTC神经元拟合带噪余弦通常是够用的换成LSTM32个单元才能达到同等外推效果。连续时间建模的代价是数值求解训练时要处理ODE离散化这也是为什么标题里的“LNN曲线拟合”在工程上总伴随着ODE步长和求解器选择。2.2 滑窗、归一化与相位编码把余弦变成监督信号模型拟合的不是整条曲线而是“用过去一段预测下一点”。最常见的任务构造方式是滑窗把时间序列切成固定长度lookback的输入块预测窗口后一位或后K位。对LNN这种连续时间模型喂进去的每个时间步之间的距离就是dt模型学到的离散动态天然依赖dt。一个容易被忽视的问题是线上预测时的采样间隔必须和训练时一致否则时间常数tau对应的时间尺度全乱了。数据预处理上有三件事比网络结构更重要。第一是归一化。余弦本身就落在[-1,1]区间直接减均值除标准差会破坏幅值信息一般只缩放到[-1,1]即可预测值再做反变换。第二是噪声。纯余弦没有噪声的话模型会拟合到精确函数但应用场景里信号总有测量噪声训练时加标准差0.02到0.05的高斯噪声能明显提升滚动预测的鲁棒性。第三是相位编码。如果把原始时间t直接作为输入LNN需要额外学一个线性映射才能定位周期常见做法是做一个“周期相位”通道取值为t对周期取模后的归一化值。这相当于把时间先验直接给到网络属于这一领域非常朴素但有效的技巧。2.2.1 用相位通道构造输入特征输入特征张量是二维的第一维是信号值第二维是相位编码值。以周期period64为例相位通道的公式是phase (t % period) / period其中t是样本索引。这样每个时间点的相位都落在[0,1)区间周期起点和终点在特征空间里是连续的避免了取模造成的跳变。如果不用相位通道LNN也能学但需要更多的神经元去隐式构造一个振荡器加上相位通道后网络的精力集中在学幅值和噪声抑制上拟合精度和收敛速度都更容易控制。import numpy as np import torch def make_cos_dataset(period64, n_cycles4, noise0.05, lookback16): n period * n_cycles t np.arange(n, dtypenp.float32) signal np.cos(2 * np.pi * t / period) x signal noise * np.random.randn(n) # 相位编码让LNN直接感知当前时刻在一个周期内的位置 phase (t % period) / period X, Y [], [] for i in range(lookback, n): # 每个样本是 lookback 个时间步每个时间步两个特征信号值相位 feat np.stack([x[i - lookback:i], phase[i - lookback:i]], axis1) X.append(feat) Y.append(x[i]) # 预测下一点的真实信号值 return torch.tensor(np.array(X), dtypetorch.float32), \ torch.tensor(np.array(Y), dtypetorch.float32).unsqueeze(1), \ t, x, signal代码逻辑分三步先构造干净余弦并加噪再对每个时刻计算相位值最后按lookback滑动窗口组装样本。之所以把signal单独返回是为了后面算误差时能区分“噪声引起的误差”和“模型拟合误差”。参数上period64表示一个周期有64个采样点lookback16意味着用四分之一个周期的历史预测未来一个点这是做序列预测时比较常见的窗口比例。2.3 最小数据集的切分参数与训练集边界切分时不能随机打乱。LNN是有状态的连续时间模型训练样本之间存在时间依赖随机洗牌会让模型看到“未来信息”推理时完全无法复现。正确做法是按时序切分前80%做训练后20%留作验证验证集里不参与训练的任何数据增强或归一化统计量计算。对于cos这种高度周期性的信号还有一个特殊问题训练集必须覆盖完整周期。如果训练集只截取了一段上升沿模型会倾向学成单调递增函数至少要包含两个完整周期才能让LNN把“周期”这个先验固化在参数里。下面的切分代码直接按索引切同时保证验证集起始点落在整数周期边界上方便后面用FFT做相位验证def split_sequence(X, Y, train_ratio0.8, period64): n len(X) split int(n * train_ratio) # 对齐到周期边界保证验证集从相位0开始 split split - (split % period) return X[:split], Y[:split], X[split:], Y[split:]切分逻辑里唯一微妙的地方是“对齐周期边界”。这不会造成信息泄露只是让验证集的误差评估更干净从头预测一个完整周期比从任意相位起步更容易看出相位漂移问题。3. 用PyTorch实现LNN训练与单步预测的最小可运行代码3.1 LTC单元实现用显式欧拉替代ODE求解器LNN的完整实现在论文里用的是自适应步长ODE求解器但在工程落地和教学演示里显式欧拉已经足够前提是dt足够小。欧拉法的好处是简单、可复现、反向传播开销低缺点是dt过大时数值误差累积导致训练曲线震荡。LTC单元的状态更新离散形式是这样的h_new h dt * ( -h / tau sigmoid(W_in*x W_h*h) * tanh(A * concat(x, h)) )其中tau是每个神经元的可学习时间常数A是“液体”联接矩阵控制输入和状态如何混合。注意这个表达式里用了tanh作为输出非线性实际上LTC原论文的gate形式更复杂但这里保留核心结构一个负反馈项(-h/tau)负责“泄漏”一个输入驱动项负责“激励”。这个结构天然有界不会像RNN那样梯度爆炸。import torch import torch.nn as nn class LTCell(nn.Module): Liquid Time-Constant 单元的教学实现显式欧拉离散 def __init__(self, in_features, hidden_size, dt0.1): super().__init__() self.in_features in_features self.hidden_size hidden_size self.dt dt self.W_in nn.Linear(in_features, hidden_size, biasFalse) self.W_h nn.Linear(hidden_size, hidden_size, biasFalse) self.A nn.Linear(in_features hidden_size, hidden_size, biasFalse) self.tau nn.Parameter(torch.ones(hidden_size) * 1.0) def forward(self, x, h): # 液体的时间常数是输入相关的这里简化为可学习常量 f torch.sigmoid(self.W_in(x) self.W_h(h)) # 连续动态dh/dt -h/tau f * tanh(A*[x;h]) dh -h / torch.abs(self.tau) f * torch.tanh(self.A(torch.cat([x, h], dim-1))) # 显式欧拉更新 h_new h self.dt * dh return h_new这段代码的几个关键设计对tau取绝对值防止训练过程中时间常数变成负数负的时间常数会让系统发散。dt作为构造参数而不是可学习参数因为它在训练时固定才能保证离散动态和线上推理一致。A矩阵融合了输入和上一时刻状态这是“液体”联接的核心输入不仅驱动当前值还改变神经元之间的有效联接强度。写成LNN类就是按时间步展开调用这个Cell输出层接一个线性映射class LNN(nn.Module): def __init__(self, in_features, hidden_size, output_size1, dt0.1): super().__init__() self.cell LTCell(in_features, hidden_size, dt) self.fc_out nn.Linear(hidden_size, output_size) def forward(self, x): # x: [batch, lookback, in_features] batch, steps, _ x.shape h torch.zeros(batch, self.cell.hidden_size, devicex.device) for t in range(steps): h self.cell(x[:, t, :], h) return self.fc_out(h)展开时隐状态h在所有时间步之间传递但输出只看最后一个时间步的h。这意味着预测值由“整个滑窗的轨迹”决定而不只是最后一个点的特征。3.2 训练循环、损失函数与学习率设置训练LNN和训练普通RNN的差异主要体现在两点一是输入数据的batch维要保留时间顺序二是学习率不能太大因为LTC单元的状态方程对参数变化较敏感学习率超过0.01容易在训练初期出现NaN。损失函数直接用MSE不需要额外加正则项。tau参数本身带有隐式正则效果tau越大系统越“懒惰”输出越平滑tau越小系统越敏感拟合细节越好。如果验证集MSE不降优先检查tau是否全部朝一个方向退化——比如所有tau都跑到100以上说明网络在“忽略时间”退化成静态映射。model LNN(in_features2, hidden_size8, output_size1, dt0.1) optimizer torch.optim.Adam(model.parameters(), lr5e-3) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size200, gamma0.5) loss_fn nn.MSELoss() X_train, Y_train, X_val, Y_val split_sequence(X, Y, train_ratio0.8, period64) epochs 800 batch_size 64 for epoch in range(epochs): model.train() perm torch.randperm(len(X_train)) total_loss 0.0 for i in range(0, len(perm), batch_size): idx perm[i:i batch_size] xb, yb X_train[idx], Y_train[idx] pred model(xb) loss loss_fn(pred, yb) optimizer.zero_grad() loss.backward() # 梯度裁剪LTC的欧拉展开容易在反向传播时累积大梯度 torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss loss.item() * len(idx) scheduler.step() if epoch % 100 0: model.eval() with torch.no_grad(): val_loss loss_fn(model(X_val), Y_val).item() print(fepoch {epoch}: train_loss{total_loss / len(X_train):.6f}, val_loss{val_loss:.6f})注意到perm torch.randperm这里我做了随机打乱。这看起来和2.3节矛盾其实不矛盾这里是batch内部打乱样本仍然是完整的滑窗序列每个滑窗内部的时序没有破坏而划分训练集/验证集时按时间顺序切分防止的是验证集样本出现在训练集之前。训练日志如果出现val_loss先降后升就是过拟合信号。此时LNN的参数规模不大过拟合通常不是参数太多而是数据太少或者噪声太小。解决办法是增大noise值而不是加Dropout——Dropout对连续时间动态的破坏很大会让tau的学习变得不稳定。3.3 单步预测与多步自回归预测的差异模型训练时用的是teacher forcing每个时间步的输入都来自真实数据输出和真实值比较。但线上预测时没有真实值可用预测出的值要被当作下一时刻的输入再预测下一个值——这就是多步自回归预测也叫滚动预测。这两者的行为差异非常明显。LNN在单步预测上几乎是无损的因为滑窗里有真实的相位信息但进入自回归模式后预测误差会反馈到输入端误差在时间维度上累积。具体表现有两个幅值衰减预测的余弦波峰越来越矮和相位漂移峰值位置逐渐偏离真实周期。这是所有自回归时序模型的通病LNN的连续时间动态只能延缓不能消除。def autoregressive_predict(model, init_window, steps_ahead, period64): 从 init_window 出发逐步预测未来 steps_ahead 个点。 第 i 步预测值会被拼接到窗口末尾并丢弃窗口最前面的点。 model.eval() window init_window.clone() # [lookback, 2] preds [] t_start len(window) for k in range(steps_ahead): with torch.no_grad(): pred model(window.unsqueeze(0)).squeeze().item() preds.append(pred) # 构造下一个时间点的相位编码 next_phase ((t_start k) % period) / period new_feat torch.tensor([[pred, next_phase]], dtypetorch.float32) window torch.cat([window[1:], new_feat], dim0) return np.array(preds)这段代码里window每次丢掉最老的一个点、加入最新预测值模拟的是真实在线推理流程。关键参数是steps_ahead预测长度超过一个周期64步时误差累积会非常明显如果业务上需要预测更远常见做法是分段预测每32步用真实采样值校准一次。4. LNN拟合预测的调参与滚动外推验证技巧4.1 先调dt和tau再动神经元数量很多人在拟合效果不好时第一反应是加神经元但LNN上最值得先调的是dt和tau。dt决定模型的时间分辨率dt太大会导致欧拉离散误差大余弦曲线会出现锯齿dt太小则输入序列需要更长才能覆盖一个周期训练代价上升。实验上period64时dt0.1是常用起点。tau的初始化也很关键。把tau初始化成1.0表示系统的时间常数和采样间隔同量级如果任务需要更长记忆比如用16步预测64步之后的值可以把tau初始化到2.0或3.0。显式设置tau的初始值比让它随机初始化收敛快得多。4.2 利用FFT检查相位漂移而不是只看MSE滚动预测误差最常见的坑是MSE看起来很小但预测曲线在相位上已经滞后或超前了半个周期。MSE对相位误差的反馈是二次的无法区分“幅值小了”和“相位偏了”。验证LNN拟合质量时用FFT提取预测序列的主频和真实序列的主频做对比能直接看出相位问题。def phase_error(pred, true, period64): 计算真实序列和预测序列在目标频率上的归一化相位差。 返回值接近0表示没有漂移接近0.5表示漂移了半个周期。 n min(len(pred), len(true)) p, t pred[:n], true[:n] target_freq 1.0 / period # 计算预测序列在目标频率上的FFT分量 spec_p np.fft.fft(p * np.hanning(n)) spec_t np.fft.fft(t * np.hanning(n)) freqs np.fft.fftfreq(n, d1.0) idx np.argmin(np.abs(freqs - target_freq)) # 归一化到[0,1)相位差 / 2pi ang_p np.angle(spec_p[idx]) ang_t np.angle(spec_t[idx]) diff (ang_p - ang_t) % (2 * np.pi) return min(diff, 2 * np.pi - diff) / (2 * np.pi)这个验证函数只关注目标频率上的相位表现能过滤掉噪声和其他频率分量的干扰。在余弦拟合任务里我会把相位误差和MSE都打印出来如果相位误差在0.05以内、MSE收敛到噪声水平以下那这条拟合曲线可以放心用于后续预测。4.3 用多个相位起点检验LNN的泛化能力一个常见的调参陷阱是模型在一个周期上的某个相位区间内表现很好换一个起始相位就崩掉。这是因为训练集中某些相位区间样本更多LNN对这些区间的拟合被加了权重。做法上也简单验证集从不同相位位置截取多段分别做滚动预测然后统计每段的相位误差均值和方差。方差大说明LNN是在“背”曲线而不是学到了周期动态此时把hidden_size从8降到4或者增大dt通常能改善泛化。这个验证技巧同时适用于电力负荷预测和金融时序等真实场景本质都是检验模型学到的是“规律”还是“样本”。本文还有配套的精品资源点击获取
