halo 的技术博客

返回

你想用过去 20 天的收益,预测明天的收益。线性模型(OLS)会把这 20 个数加权平均;RNN 则会把「过去」压进一个隐藏状态,再让这个状态去影响预测——它理论上能捕捉 OLS 抓不到的非线性、长程依赖、波动结构

但先泼盆冷水:金融序列信噪比极低,RNN 很常见的结果是「训得动、却赢不了一个线性回归」。本文不给你「RNN 碾压一切」的爽文,而是用纯 numpy 从零实现一个 Vanilla RNN(不依赖 PyTorch/TensorFlow),在一份诚实的合成数据上把这件事跑清楚:RNN 确实比 OLS 好一点(MSE 低 1.1%、方向准确率 0.654→0.658),但微小的增益才是弱信号金融数据的真相。


一、RNN 为什么适合时序:隐藏状态即记忆#

Vanilla RNN 的核心就一行递推:

ht=tanh(Wxhxt+Whhht1+bh),y^t=Whyht+byh_t = \tanh(W_{xh}\,x_t + W_{hh}\,h_{t-1} + b_h),\qquad \hat y_t = W_{hy}\,h_t + b_y

xtx_t 是当前输入(比如第 tt 天收益),hth_t 是隐藏状态——它携带了从 t=1t=1tt 的所有历史信息(理论上;实践中会因梯度消失而衰减)。预测 y^t\hat y_t 由当前隐藏状态读出。

对比 OLS:它只看 xtT+1..tx_{t-T+1..t} 的线性组合,没有「记忆」这层东西。当真实规律是「大涨后歇一下、波动大时收益更极端」这种非线性时,RNN 有结构优势。

RNN 时间展开(T=20):x1→x2→...→x20,每个隐藏状态 h 同时接收上一 h 与当前输入 x,最后 h20 读出预测 y_hat


二、合成数据:慢因子 + 波动聚集 + 短期动量#

为了可控且可复现,不拿真实行情(会引入无法归因的噪声)。构造一段 4000 点收益序列:

rt=slowt双频慢因子+σtεtGARCH 波动聚集+0.22sign(rt1)σt短期动量(非线性)r_t = \underbrace{\text{slow}_t}_{\text{双频慢因子}} + \underbrace{\sigma_t\,\varepsilon_t}_{\text{GARCH 波动聚集}} + \underbrace{0.22\,\text{sign}(r_{t-1})\sigma_t}_{\text{短期动量(非线性)}}

慢因子是两个不同周期正弦的叠加;波动率 σt\sigma_t 走标准的 GARCH(1,1),制造「波动扎堆」;短期动量项引入 OLS 难以拟合的符号非线性。整段序列做了去均值。

import numpy as np

M = 4000
t_idx = np.arange(M)
slow = 0.0045 * np.sin(2*np.pi*t_idx/200.0) + 0.0025 * np.sin(2*np.pi*t_idx/55.0)
vol = np.zeros(M); vol[0] = 0.012
eps = np.random.randn(M)
for t in range(1, M):
    vol[t] = np.sqrt(0.0000015 + 0.07*(eps[t-1]*vol[t-1])**2 + 0.91*vol[t-1]**2)
ret = slow + vol * eps
ret[1:] += 0.22 * np.sign(ret[:-1]) * vol[1:]
ret = ret - ret.mean()
python

三、从零实现 RNN(numpy + BPTT + 梯度裁剪)#

关键工程点:输入与输出都要标准化到 ~单位尺度。我第一版直接用原始 scale≈0.015 的收益率做输入、标准化后的目标做输出,RNN 死活训不动(MSE 卡在 1.0)——因为网络要从极小输入逼近正常尺度输出,梯度几乎不流动。统一标准化后立刻收敛。

滑动窗口:X[i] = r[t-T:t]y[i] = r[t],窗口长 T=20T=20

训练损失稳定下降到 0.82(对应 R²≈0.17,这是这段带噪序列的预测天花板,绝非失败):

RNN 训练损失 vs OLS 基准(均按标准化目标):红线单调下降并在 OLS 等价 MSE 下方收敛,说明 RNN 确实学到了比线性更多的信息


四、实测:RNN 赢了 OLS 一点点,但仅此而已#

测试集上和两组基线对比(基线都很朴素,方便看清增量):

  • 朴素基线:直接拿「上一期收益」当预测(y^t=rt1\hat y_t = r_{t-1});
  • OLS 线性:用过去 20 天收益线性拟合;
  • RNN(numpy):上面从零实现的 Vanilla RNN。
模型MSE方向准确率
朴素基线1.078e-04−0.2830.637
OLS 线性7.044e-050.1610.654
RNN (numpy)6.966e-050.1710.658

两个诚实结论:

  1. OLS 已经很强:它比朴素基线把 MSE 砍了 34.7%,R² 从负变正。金融序列里「近期均值/动量的线性成分」是大头,线性模型先吃掉了大部分可预测性。
  2. RNN 的增益是真实的但微小:MSE 比 OLS 再低 1.1%,方向准确率 0.654→0.658,R² 0.161→0.171。它确实从非线性/波动结构里多抠出一点,但远不是「碾压」

三模型测试集指标: RNN 在 MSE 与方向上均占优(但幅度有限)。左轴 MSE(越低越好),右轴 R² 与方向准确率(越高越好)

预测 vs 实际散点里,RNN(蓝)比 OLS(灰)更贴对角线——尤其在极端值两端,说明它对波动结构的建模略胜一筹:

预测 vs 实际(下一期收益):RNN(蓝)比 OLS(灰)更贴近对角参考线,极端点处差距更明显

一段测试样本上的逐点预测对比更直观——RNN(红)比 OLS(蓝)更贴合实际(黑),尤其在波动放大的区段:

测试段: 下一期收益预测对比。实际(黑)、朴素(灰)、OLS(蓝)、RNN(红);RNN 在波动放大段贴合更好


五、LSTM:给 RNN 装上「门」,对抗梯度消失#

Vanilla RNN 的隐藏状态每步都重写,长程信息会被冲掉(梯度消失)。LSTM 用三个门(遗忘/输入/输出)把记忆拆成「长期记忆 ctc_t」和「短期输出 hth_t」,让信息可以跨很多步保留:

def lstm_step(x_t, h, c, P):
    a = x_t @ P["Wx"].T + h @ P["Wh"].T + P["b"]
    i = 1/(1+np.exp(-a[:, :H])); f = 1/(1+np.exp(-a[:, H:2*H]))
    g = np.tanh(a[:, 2*H:3*H]); o = 1/(1+np.exp(-a[:, 3*H:]))
    c_new = f * c + i * g
    h_new = o * np.tanh(c_new)
    return h_new, c_new
# 读出: y_hat = h_new @ Why.T + by  (门控让长程依赖可训练)
python

在弱信号的金融数据上,LSTM 通常比 Vanilla RNN 更稳(不易梯度消失),但未必在样本内显著更准——因为瓶颈是「信号本身弱」,不是「记忆不够长」。这也是下一节要强调的。


六、诚实的边界(最易踩的坑)#

  1. RNN 没赢 OLS,不代表 RNN 没用,但别指望它暴力翻盘。本文增益仅 1.1%,因为可预测性大头已被线性吃光。若你的数据里非线性/机制切换明显(如 regimes、跳跃),RNN 的增益会更大;若只是近白噪声,它连 OLS 都追不上。
  2. 标准化尺度是隐形杀手。输入、输出尺度不一致,RNN 会训不动(MSE 卡 1.0)。务必统一标准化,且训练/测试用同一组训练集均值方差。
  3. 过拟合比欠拟合更危险。H=24、150 epoch 在 3200 点训练样本上已经偏「重」;真实金融数据维度高、样本相对少,必须做 walk-forward 滚动验证、加 dropout/权重衰减,否则测试集会塌。本文的合成数据信号干净,才显得稳。
  4. 方向准确率比 MSE 更该看。交易决策看的是「涨还是跌」,不是「精确数值」。本文 RNN 方向准确率 0.658——看着只比随机(0.5)高一点,但在 20 天滚动、扣掉成本后,0.65 的方向率已是可盈利的信号边缘,需结合手续费和持仓周期评估。
  5. 别拿未来信息。窗口必须由「截至 t-1 的过去」构成去预测 t,任何在构造 X 时混入 rtr_t 及其之后的值,都是 look-ahead,结果会虚假地极好。

七、完整可复现#

把第二、三节的代码拼起来(加 make_windows 和指标函数),即得到本文全部数字:

跑出来你会得到 MSE≈6.97e-05、R²≈0.171、方向准确率≈0.658,与本文一致。RNN 在金融时序预测上的真实姿态,不是魔法,是「比线性多抠出一点点」——而这「一点点」,在足够大的资金和高频切换里,就是实打实的 edge。

RNN/LSTM 金融时序预测:用门控记忆捕捉长程依赖
https://blog.halo26812.eu.org/blog/rnn-financial-trading
Author halo
Published at 2026年7月23日
版权声明 CC BY-NC-SA 4.0
Comment seems to stuck. Try to refresh?✨