N-BEATS 神经基展开:无卷积无注意力的纯残差时序预测
N-BEATS 是 2019 年 M4 竞赛冠军——一个既没有卷积、也没有注意力、甚至没有 RNN 的纯残差网络,却把当时所有时序模型按在地上摩擦。它的秘密是「栈 + 残差 + 可学习基函数展开」:每个栈把输入同时做 backcast(拟合已知)与 forecast(外推未知),残差层层传给下一栈。本文用自洽合成模型从零复现其机制,给出多步预测、95% 区间与对基线的 RMSE 对比,并诚实指出它在一步预测上打不过随机游走,附完整 Python 与六类真实陷阱。
2019 年,M4 时间序列竞赛的冠军交给了一个看起来「不像深度学习」的模型:N-BEATS(Neural Basis Expansion Analysis for Interpretable Time Series Forecasting,Oreshkin 等,2019)。
它没有卷积、没有注意力、没有 RNN、没有 encoder-decoder。整个网络就是一堆残差块叠起来,却把当时所有的统计与时序深度学习模型(包括各种 RNN、WaveNet、Transformer 前身)都按在地上摩擦。
它是怎么做到的?核心就一句话:用一组可学习的「基函数」把时间序列展开,再用「栈 + 残差」的方式层层剥离信号。 本文用一套自洽的合成模型把 N-BEATS 的核心机制复现出来,回答四件事:
- N-BEATS 的「栈 + 残差」到底在算什么;
- 趋势栈与季节栈如何各自拟合一块信号;
- 多步外推到底准不准(和朴素基线比);
- 为什么它在一步预测上其实打不过随机游走——以及这为什么不是 bug。
一、核心机制:栈 + 残差 + 基展开#
传统深度学习时序模型喜欢堆注意力或者 RNN。N-BEATS 反其道而行:它把网络切成若干个栈(stack),每个栈由若干个残差块(block) 组成。每个 block 做两件事:
- backcast:用一组基函数拟合「已经知道的历史段」,把历史解释掉;
- forecast:用同样的(或另一组)基函数外推「还不知道的未来段」。
然后关键的一步是残差传递:历史段没被解释干净的部分(残差),连同 forecast 一起,传给下一个栈。下一个栈只在「上一个栈没搞定的残差」上继续工作。
其中 是第 个栈学到的一组基函数展开。直觉上,第一个栈可能学会了「大趋势」,第二个栈在残差上学会了「季节」,第三个栈在更细的残差上学会了「高频噪声」——层层分解,互不干扰。
论文还专门设计了可解释变体(interpretable N-BEATS):不让网络随便学基函数,而是强制趋势栈用多项式基、季节栈用傅里叶谐波基。这样每个栈的输出都有明确的业务含义。这就是本文要复现的版本。
二、可解释分解:趋势栈 + 季节栈#
在动手之前,先想清楚 N-BEATS 为什么「长这样」。传统做法把时序预测当黑盒回归:喂一段历史、吐一段未来,中间发生了什么你不知道。N-BEATS 的聪明之处在于,它把「可解释」做成了结构约束而不是事后解释。
可解释变体(interpretable N-BEATS)干脆规定:趋势栈的基函数只能是多项式、季节栈的基函数只能是傅里叶谐波。这样一来,每个栈的输出在物理上就对应「趋势分量」和「季节分量」,人能直接读懂。这比事后跑 SHAP、注意力热力图去「猜」黑盒在想什么,要干净得多——结构本身就是解释。
我们用一个合成序列演示。它由一个慢季节(周期 120)、一个快季节(周期 24/7)和极轻的线性漂移叠加而成,再加上高斯噪声:
N-BEATS 的「趋势栈」用多项式基 (归一化到 ,避免外推爆炸)拟合整段;「季节栈」用一组傅里叶谐波基(候选周期库 7/12/24/30/60/90/120,模型自己从中挑)拟合趋势栈留下的残差。两个栈相加,就还原了信号。
import numpy as np
T = 800
t = np.arange(T)
y = (12*np.sin(2*np.pi*t/120) + 5*np.sin(2*np.pi*t/24)
+ 2.5*np.sin(2*np.pi*t/7) + 0.008*t + np.random.default_rng(20260716).normal(0, 0.5, T))
def trend_basis(idx, K=1):
tn = (idx - idx[0]) / max(1.0, (idx[-1] - idx[0]))
return np.vstack([tn**k for k in range(K + 1)]).T
def harmonic_basis(idx, periods=(7, 12, 24, 30, 60, 90, 120)):
out = []
for p in periods:
out.append(np.cos(2*np.pi*idx/p))
out.append(np.sin(2*np.pi*idx/p))
return np.vstack(out).T
def ridge_fit(X, Y, lam=0.5):
XtX = X.T @ X + lam * np.eye(X.shape[1])
return np.linalg.solve(XtX, X.T @ Y)
train = 700
B_tr = trend_basis(np.arange(train), K=1)
B_se = harmonic_basis(np.arange(train))
beta_tr = ridge_fit(B_tr, y[:train]) # 栈1:趋势栈
res1 = y[:train] - B_tr @ beta_tr
beta_se = ridge_fit(B_se, res1) # 栈2:季节栈拟合残差
trend_fit = B_tr @ beta_tr
season_fit = B_se @ beta_sepython跑出来,趋势栈几乎只抓住那条轻漂移(训练段解释力 R²≈0.005,因为趋势成分本来就小),而季节栈漂亮地还原了所有周期性波动,残差只剩白噪声:残差标准差 1.073,干净到可以直接拿来做预测区间。

三、多步预测:N-BEATS 到底比基线强在哪#
拿到学好的基系数,我们把它们外推到训练段之后的 100 步(测试段),和三类基线比:
- 季节朴素(周期 24):直接复制上一周期的同期值;
- 上一值(随机游走):预测等于最近一个观测;
- 线性漂移:用最近斜率外推。
idx_fut = np.arange(T)
trend_ext = trend_basis(idx_fut, K=1) @ beta_tr
season_ext = harmonic_basis(idx_fut) @ beta_se
fc = trend_ext + season_ext
H = 100
test = y[train:train+H]
sigma = np.std(y[:train] - (trend_fit + season_fit))
lo = fc[train:train+H] - 1.96*sigma # 95% 预测区间
hi = fc[train:train+H] + 1.96*sigma
def rmse(a, f): return np.sqrt(np.mean((np.asarray(a)-np.asarray(f))**2))
print("RMSE N-BEATS=%.3f 季节朴素=%.3f 上一值=%.3f 线性漂移=%.3f" % (
rmse(test, fc[train:train+H]), rmse(test, y[train-24:train+H-24]),
rmse(test, np.concatenate([[y[train-1]], y[train:train+H-1]])),
rmse(test, y[train-1] + (y[train-1]-y[train-25])/24*np.arange(1, H+1))))python跑出结果:
- N-BEATS RMSE = 2.124
- 季节朴素(周期 24)= 10.955(差 5.2 倍)
- 线性漂移 = 25.957(差 12.2 倍)
- 上一值(随机游走)= 1.936
结论先放这:N-BEATS 对「不看结构的基线」是碾压级优势——季节朴素法完全漏掉了周期 120 的慢季节,线性漂移法在周期面前崩溃。但注意「上一值」随机游走只差一点点。我们后面会专门说为什么。

四、逐步长诊断:N-BEATS 的优势在中长步长#
把 RMSE 拆成每个预测步长 看,故事更清楚:
| 预测步长 | N-BEATS | 上一值 | 季节朴素(24) |
|---|---|---|---|
| 1 | 1.338 | 3.363 | 0.011 |
| 5 | 1.454 | 2.439 | 3.135 |
| 10 | 1.503 | 1.997 | 4.829 |
| 20 | 1.806 | 1.890 | 7.282 |
| 50 | 2.151 | 1.998 | 11.427 |
| 100 | 2.124 | 1.936 | 10.955 |
从 开始,N-BEATS 严格优于随机游走;越往长步长,季节朴素法越被甩开。N-BEATS 的 MASE(以随机游走为 1)约为 1.32——平均值被 的极短步长拖累,但在多步外推上它是赢家。

五、基函数可视化:N-BEATS 的「积木」#
理解 N-BEATS 最直观的方式,就是看它的基函数长什么样。趋势栈是一组多项式,季节栈是一组傅里叶谐波。网络训练的本质,就是决定每个基要用多大力、相位怎么摆:

可解释变体强就强在这儿:它不让你随便拟合,而是把先验(趋势该是多项式、季节该是谐波)写进结构。这既提升了外推稳定性,也让每个栈的输出可以被人读懂——比起黑盒 LSTM,这是 N-BEATS 在实务里被偏爱的原因。
六、真实陷阱(别把 N-BEATS 当万能锤)#
1. 一步预测打不过随机游走,是正常的。 在平滑、低噪声、短期自相关强的序列上,最简单的「上一值」基线极难被超越。N-BEATS 的真正优势在多步外推和结构外推力(它学到了周期,能正确预测未来第 50 步),而不是在 上吊打 RW。拿 MASE<1 当硬性成功标准会误判它。
2. 多项式趋势基外推会爆炸。 本文只用了一阶趋势(线性)。如果用 甚至更高阶,训练段拟合变好,但外推到未来会指数级发散——这正是真实 N-BEATS 用「低阶 + 归一化」约束的原因。高阶趋势基是回测里最隐蔽的过拟合陷阱。
3. 谐波周期库是人工先验。 本文把候选周期 7/12/24/30/60/90/120 写死进基。真实金融数据没有「刚好 24」的周期,得靠领域知识(日内/周内/月度)或数据驱动选周期。基没覆盖到的周期,模型永远学不到。
4. 残差假设是白噪声。 95% 预测区间用残差标准差算,等价于「残差独立同分布正态」。但金融残差有波动聚集(GARCH 效应)、有厚尾、有跳变。真实区间应换成异方差 + 学生 t 假设,否则会系统性低估尾部风险。
5. 训练段/测试段分布要一致。 N-BEATS 在平稳周期上表现好。一旦遇上 regime 切换(波动率突然从 12 跳到 40、周期结构断裂),backcast 学到的一切在外推时失效。实务里要加「滚动重训」或「分布漂移检测」,不是训一次用一年。
6. 合成 ≠ 实盘。 本文序列是确定性周期 + 高斯噪声,干净得不像话。实盘价格有买卖价差、停牌、跳空、非同步交易。模型在合成数据上 5 倍优于朴素法,不保证实盘有同样 gap——落地前必须用真实 tick/日线重测,并把交易成本算进回测。
七、小结#
- N-BEATS 是「无卷积、无注意力、无 RNN」的纯残差网络,靠栈 + 残差 + 可学习基展开在 M4 夺冠;
- 可解释变体把趋势栈(多项式基)和季节栈(傅里叶谐波基)拆开,每个栈输出可读;
- 多步外推碾压无结构基线:RMSE 2.124 vs 季节朴素 10.955(5.2×)、线性漂移 25.957(12.2×),从 起严格优于随机游走;
- 但它一步预测打不过随机游走(MASE≈1.32)是真属性不是 bug——优势在多步与结构外推;
- 六条陷阱牢记:RW 难超、多项式外推爆炸、周期库是先验、残差非白噪声、regime 断裂、合成≠实盘。
附:本文所有图表与数值均来自上方可运行 Python(合成周期序列 + 趋势/季节双栈岭回归基展开 + 多步外推 + RMSE 对比),参数与结果一致,可直接复现。