halo 的技术博客

返回

做时序建模的人都被同一个三难问题卡过:RNN 能记忆但串行训练慢且梯度会消失,Transformer 并行但注意力 O(L²) 在长序列上烧显存,卷积并行但感受野锁死在核宽内。2021 年之后爆火的状态空间模型(SSM)——S4、S5、一路到 Mamba——给了第四个选项,而它的骨架朴素到让人意外:一条对角线性递推

结论先放这:对角线性 SSM 的本质,是把「一组不同衰减速度的指数移动平均」并联起来当特征提取器。衰减率按对数均匀铺满从 1 步到几百步的时间尺度,短衰减状态记最近几步、长衰减状态记几百步前的累积影响,再用一个线性读出把它们组合成预测。这个结构训练时可以并行扫描(parallel scan),推理时每步只要 O(N) 次乘加——比注意力便宜几个数量级。

但便宜不等于万能。我用纯 numpy 从零实现了这个骨架,在两个精心设计的合成任务上对拆:平滑多尺度记忆任务(且只给 1800 个训练样本)上,16 个状态的 SSM 以 R²=0.787 完胜 256 个滞后的 OLS(0.664);但目标里藏着一个孤立尖锐 lag-64 的任务上,SSM 被 128 滞后的 OLS(0.922 vs 0.487)按在地上摩擦。指数记忆核是一把双刃剑——它平滑、参数省,但也因此抹平了一切「尖锐」的滞后结构。这正是本文想诚实交代的边界。

一、从 RNN 的死穴说起#

标准 RNN 的递推是:

ht=tanh(Wht1+Uxt)h_t = \tanh(W h_{t-1} + U x_t)

两个死穴:

  1. 梯度消失/爆炸:反向传播要连乘 diag(tanh)W\prod \text{diag}(\tanh') W,谱半径小于 1 就指数衰减,大于 1 就爆炸。想记住 100 步前的信息,梯度要穿过 100 层非线性——基本不可能。
  2. 串行训练hth_t 依赖 ht1h_{t-1},GPU 的并行度用不上,长序列训练慢得让人绝望。

LSTM/GRU 用门控缓解了第一条,但第二条无解——只要递推里有非线性,就无法并行展开。

SSM 的洞察是:把非线性从递推里拿出来。递推本身保持纯线性:

ht=Aht1+Bxt,yt=Cht+Dxth_t = A h_{t-1} + B x_t, \qquad y_t = C h_t + D x_t

非线性放到层与层之间(逐点 MLP / GLU)。线性递推有两个立竿见影的好处:

  • 可以展开成卷积ht=k=0tAkBxtkh_t = \sum_{k=0}^{t} A^k B x_{t-k},也就是输入和一个「结构化卷积核」{B,AB,A2B,}\{B, AB, A^2B, \dots\} 的卷积——训练时用 FFT 或并行扫描,彻底并行。
  • 梯度行为完全由 AA 的特征值决定:不再有非线性挤压,想要长记忆就把特征值放在接近 1 的位置,明确可控。

再进一步,把 AA 限制成对角矩阵(S4D、S5、Mamba 都这么做),递推就拆成 NN 条互不干扰的标量递推:

ht(i)=aiht1(i)+bixt,i=1,,Nh_t^{(i)} = a_i \, h_{t-1}^{(i)} + b_i \, x_t, \quad i = 1, \dots, N

每条就是一个指数移动平均(EWMA),衰减率 aia_i 对应时间尺度 τi=1/lnai\tau_i = -1/\ln a_i。整层 SSM = N 个不同时间尺度的 EWMA 并联。量化研究员看到这里应该会心一笑:我们手搓了十几年的「多周期均线特征」,就是 SSM 的退化版——区别在于 SSM 的时间尺度是系统铺设(甚至可学习)的,而不是拍脑袋选 5/10/20/60。

二、纯 numpy 实现:60 行的骨架#

2.1 多时间尺度状态提取#

核心就这一个函数——把衰减率按对数均匀铺满 [τmin,τmax][\tau_{\min}, \tau_{\max}]

import numpy as np

def ssm_features(x, n_states=64, min_tau=1.0, max_tau=256.0):
    """对角线性 SSM:h_t = a ⊙ h_{t-1} + b ⊙ x_t
    衰减率 a 按对数均匀覆盖 [min_tau, max_tau] 的时间尺度"""
    taus = np.exp(np.linspace(np.log(min_tau), np.log(max_tau), n_states))
    a = np.exp(-1.0 / taus)          # tau 步衰减到 1/e
    b = np.sqrt(1 - a ** 2)          # 能量归一:白噪声输入下各状态方差相同
    H = np.zeros((len(x), n_states))
    h = np.zeros(n_states)
    for t in range(len(x)):
        h = a * h + b * x[t]         # N 条并联 EWMA,一步 O(N)
        H[t] = h
    return H, a, taus
python

三个细节值得说:

  • 对数均匀铺 τ:这是 S4 里 HiPPO 初始化思想的平民版。时间尺度 1、2、4、…、256 各来一撮,短程长程都有人管。
  • 能量归一 b=1a2b=\sqrt{1-a^2}:白噪声输入下,EWMA 的稳态方差是 b2/(1a2)b^2/(1-a^2)。不归一的话长记忆状态方差爆大,Ridge 读出会被数值尺度绑架。
  • 循环写法只为教学:生产实现用并行扫描或 FFT 卷积,数学完全等价,速度差几十倍。本文重点是「它学到了什么」而非「它跑多快」。

对角线性 SSM 的记忆核:不同衰减率对应不同时间尺度的指数记忆

上图是 5 个代表性状态的冲击响应 bakb \cdot a^k:τ≈1 的状态只看最近两三步,τ≈256 的状态把几百步前的输入以缓慢衰减的权重记着。整个模型的记忆能力,就是这些指数核的线性张成——这句话同时解释了它的强项和死穴,后面实验会看到。

2.2 统一读出与基线#

为了公平,三个模型用完全相同的 Ridge 读出、相同的 warmup 丢弃(前 200 步状态未热身,直接丢掉,避免 warmup 污染评估窗口):

def ridge_fit_eval(H, y, lam=1e-3):
    Xtr = np.hstack([H[WARM:train_end], x[WARM:train_end, None]])
    ytr = y[WARM:train_end]
    Xte = np.hstack([H[test_start:test_end], x[test_start:test_end, None]])
    yte = y[test_start:test_end]
    w = np.linalg.solve(Xtr.T @ Xtr + lam * np.eye(Xtr.shape[1]), Xtr.T @ ytr)
    pred = Xte @ w
    return 1 - np.sum((yte - pred)**2) / np.sum((yte - yte.mean())**2)
python

对照组两个:

  • tanh 随机储备池(Echo State Network):64 维随机循环矩阵谱半径缩到 0.9,非线性递推,同样只训练读出层——控制「递推是否线性」这一个变量。
  • OLS 滞后回归:直接把过去 K 步原始输入摊平做特征。这是最诚实的基线:它没有任何记忆压缩,K 个滞后就是 K 个自由参数

三、任务 A:尖锐 lag-64——SSM 的滑铁卢#

第一个任务故意刁难:目标同时依赖短程(lag-1、lag-2)和一个孤立的长程点 lag-64:

yt=0.5xt10.3xt2+0.8tanh(xt64)+0.15εty_t = 0.5 x_{t-1} - 0.3 x_{t-2} + 0.8 \tanh(x_{t-64}) + 0.15\varepsilon_t

输入 xx 带 0.3 的自相关,模拟订单流冲击类信号。训练 8000 步、测试 4000 步。

合成任务 A:目标同时依赖 lag-1/2 短程与 lag-64 长程结构

结果:

模型测试集 R²
OLS 8 滞后0.474
OLS 32 滞后0.471
OLS 128 滞后0.922
tanh 储备池-640.466
线性 SSM-640.487

任务 A 对比:OLS-128 完胜,SSM 与储备池都只吃到短程部分

OLS-128 完胜,SSM 惨败。这个结果乍看反直觉——SSM 不是号称长程建模吗?拆开看恰恰是最好的教学案例:

  • OLS-8 / OLS-32 的 R²≈0.47,正好等于「只吃短程 lag-1/2」能拿到的分。lag-64 在它们的滞后窗口外,一分吃不到。
  • OLS-128 的窗口盖住了 lag-64,且它可以给第 64 个滞后单独放一个系数——尖峰有多尖它就能多尖。R² 直接跳到 0.92。
  • SSM-64 呢?它的特征是指数核的张成。要用一堆平滑衰减的指数曲线线性组合出「第 64 步单独一个尖峰、前后都是零」的核,理论上需要大量项数、数值上病态。64 个状态根本凑不出来,于是它也只吃到短程部分(0.487 ≈ OLS-8 的水平),lag-64 的信息在它眼里被糊成一片背景。

状态数扫描进一步确认:8→128 个状态,R² 只从 0.374 爬到 0.492,加状态无法逼近尖锐滞后——这不是容量问题,是基函数形状不匹配的问题。

状态数扫描与测试集预测对比:加状态无法救尖锐滞后

一句话总结任务 A:指数记忆核是「模糊的长程」,不是「精确的长程」。它记得「过去一段时间的累积影响」,记不住「恰好 64 步前那一根 bar」。

四、任务 B:平滑多尺度记忆 + 小样本——SSM 的主场#

第二个任务换成 SSM 的理论主场:目标由两个不同时间尺度的 EWMA 非线性组合而成——

yt=0.6EWMA8(x)t0.9EWMA96(x)t+0.4tanh(2EWMA96(x)t)+0.1εty_t = 0.6\,\text{EWMA}_8(x)_t - 0.9\,\text{EWMA}_{96}(x)_t + 0.4\tanh(2\,\text{EWMA}_{96}(x)_t) + 0.1\varepsilon_t

这类「快慢均线张力」结构在金融里比孤立尖锐滞后常见得多:动量是慢尺度累积、微观结构冲击是快尺度衰减、库存压力是中尺度释放。并且这次只给 1800 个有效训练样本——模拟真实量化研究里「日频数据就这么多」的窘境。

结果:

模型测试集 R²
OLS 32 滞后0.699
OLS 128 滞后0.684
OLS 256 滞后0.664
tanh 储备池-640.701
线性 SSM-160.787
线性 SSM-640.786

任务 B:平滑多尺度记忆 + 小样本,SSM-16 即接近饱和

局势完全反转,而且反转的方式很有信息量:

  • OLS 的滞后越多反而越差(0.699 → 0.664)。τ=96 的 EWMA 理论上需要几百个滞后才能覆盖,但 1800 个样本喂 256 个自由参数,方差爆炸——长记忆在滞后回归的参数化下是「参数灾难」
  • SSM 用 16 个状态就到 0.787,64 个状态几乎不再涨。因为目标本身就是指数核的组合,SSM 的基函数天生匹配:τ≈8 和 τ≈96 附近的状态直接对上真实结构,读出层只需要十几个系数。同样的记忆长度,参数量是 OLS 的几十分之一——小样本下这就是生死差距
  • tanh 储备池(0.701)没比 OLS 好多少:随机非线性递推的特征没有明确时间尺度语义,好坏全凭运气。

两个任务合起来,边界就画清楚了:

SSM 赢在「记忆结构与指数核匹配 + 样本预算紧张」的场景;输在「滞后结构尖锐孤立」的场景。 金融信号大多数是前者(累积、衰减、扩散),但事件驱动类结构(财报后恰好第 N 天的漂移)是后者——选工具前先问自己:我要抓的记忆是平滑的还是尖锐的?

五、从骨架到 S4/Mamba:缺了哪几块#

本文实现的是最小可教学骨架,距离真正的 S4/Mamba 还差几块,如实交代:

  1. 可学习的 a,ba, b:S4 用 HiPPO 理论初始化并端到端训练衰减率;本文是固定铺设 + 只训读出。可学习版本能把时间尺度精调到数据的真实结构上,但需要梯度训练框架。
  2. 复数特征值:S4 的对角元素是复数 a=eα+iωa = e^{-\alpha + i\omega},冲击响应是衰减振荡而非单调衰减——能表达周期性记忆。纯实数版表达不了「每 20 步一个周期」这类结构。
  3. 选择性机制(Mamba):Mamba 让 a,ba, b 随输入变化(input-dependent gating),递推变成「有选择地记忆/遗忘」——这是它在语言任务上逼近 Transformer 的关键,代价是失去 FFT 卷积形式,需要定制的并行扫描 kernel。
  4. 深度堆叠:真正的 SSM 网络是「SSM 层 + 逐点非线性 + 残差」堆 4~8 层,逐层提取更抽象的时序特征。本文单层线性读出,非线性表达全靠目标函数里的 tanh 恰好较弱才没露馅。

但骨架实验的价值恰恰在于剥离这些附件后看清本质:S4/Mamba 的长程能力的根,是「特征值位置可控的线性递推」,而不是玄学

六、五个真实陷阱#

陷阱一:「线性递推无损表达一切滞后结构」。 错。指数核的线性张成对平滑记忆是高效基,对尖锐孤立滞后是病态基。任务 A 里 SSM-128 状态也只有 0.49,OLS-128 是 0.92。如果你的信号假设是「事件后第 N 天精确反应」,SSM 是错误工具。

陷阱二:「状态越多越好」。 任务 A 里 8→128 状态只涨 0.12 且远未逼近上限(基函数不匹配,加多少都没用);任务 B 里 16 状态已饱和(基函数匹配,多了纯冗余)。状态数不是容量旋钮,是时间尺度分辨率旋钮——先想清楚要覆盖哪些 τ,再定 N。

陷阱三:「SSM 必胜滞后回归」。 两个任务各打了对方的脸。真正的判据是样本量 / 记忆长度比:样本充足且滞后结构紧凑时,OLS 摊平滞后简单直接还无偏;样本紧张且记忆平滑绵长时,SSM 的参数效率碾压。日频金融数据(几千个样本、记忆几百天)通常落在 SSM 有利区。

陷阱四:「长程建模能力 = 预测能力」。 本文两个任务的目标都被构造成确实依赖长程结构。真实金融收益率的长程可预测成分极其微弱——模型能表达 256 步记忆,不代表 256 步前的信息真的有 alpha。先用统计检验(如长滞后的增量 R²)确认长程信号存在,再上长程模型,顺序不能反。

陷阱五:「合成任务赢了就能直接上实盘」。 本文所有数字来自合成数据,输入信噪比、平稳性都是理想的。真实市场还有 regime 切换(衰减率本身该变)、厚尾(能量归一假设破坏)、微观结构噪声。SSM 特征进实盘前至少要过滚动窗口外推、子时段稳定性、加噪声退化测试三关。

七、收尾#

对角线性 SSM 把「长程记忆」这件事做成了工程上最便宜的形态:N 条并联 EWMA、一步 O(N)、训练可并行、时间尺度显式可控。本文的两组数字画出了它的能力边界——平滑多尺度记忆 + 小样本的主场里以 0.787 对 0.664 完胜 OLS,尖锐孤立滞后的客场里以 0.487 对 0.922 惨败

对量化研究员,最实用的收获可能是这个视角转换:你手里那套「5/10/20/60/120 日均线特征」,本质上就是一个手工初始化、不可学习、τ 铺得很稀疏的对角 SSM。把它换成对数均匀铺 32 个 τ 的版本再加 Ridge 读出,是几乎零成本的升级——而这一步不需要 GPU,不需要框架,60 行 numpy 就够了。

工具没有高下,只有匹配。指数核的世界里,SSM 是王;尖峰的世界里,请回滞后回归那儿去。

深度状态空间交易:用线性 RNN 做高效长程建模
https://blog.halo26812.eu.org/blog/deep-state-space-trading
Author halo
Published at 2026年7月24日
版权声明 CC BY-NC-SA 4.0
Comment seems to stuck. Try to refresh?✨