动态 Nelson-Siegel:用三因子状态空间跟踪收益率曲线形变
收益率曲线不是一条静态曲线,而是一团随时间蠕动的形态。Diebold-Li 的动态 Nelson-Siegel 用「水平/斜率/曲率」三个因子把整条曲线压成 3 个数:水平=长期中枢、斜率=短长利差(倒挂信号)、曲率=中段隆起。把三因子当隐状态、用 AR(1) 驱动、卡尔曼滤波平滑,就能边出新券边追踪曲线形变。合成里卡尔曼还原整条曲线 RMSE 仅 3.55bps(最大 3.91bps),注入的「倒挂→修复」形变被 L/S/C 三因子干净拆出。附完整 Python 与六类真实陷阱(高阶)。
收益率曲线(yield curve)是固定收益世界的「地图」:横轴是期限,纵轴是收益率。但它不是一张静态地图——它每天都在蠕动。有时候整条曲线一起上移(水平因子),有时候短端窜得比长端快、曲线变陡(斜率因子),有时候中段凸起来、两端低(曲率因子)。2006 年那条著名的「收益率曲线倒挂」(短端利率高于长端),事后看是次贷危机最强的预警之一。问题来了:怎么把一条 11 个期限点的曲线,压成几个能追踪、能建模、能预测的数? 这就是 Nelson-Siegel,及其动态版(Diebold-Li, 2006)要干的事。
结论先放这:用 Diebold-Li 的三因子参数化,整条收益率曲线被写成 水平 L + 斜率 S·s(τ) + 曲率 C·c(τ),其中 s、c 是只由「衰减参数 λ」决定的固定形状(λ=2.5 时曲率峰值落在中段)。把 L/S/C 当成随时间演化的隐状态、用 AR(1) 驱动、卡尔曼滤波做一步预测与平滑,就能在「边出新风、期限点不全」的现实里持续追踪曲线。合成 250 天里,卡尔曼平滑还原整条曲线的 RMSE 只有 3.55bps(最大 3.91bps),且仅用 3 个状态就还原了 11 个期限点的联动;人为注入的「斜率倒挂加深→修复」形变,被 S 因子一条轨迹干净拆出(L 几乎不动、C 仅小幅波动)。 附完整 Python 与六类真实陷阱(高阶)。

一、为什么需要「因子」:11 个点强耦合#
一条曲线的 11 个期限点(3M、6M、1Y、2Y、3Y、5Y、7Y、10Y、15Y、20Y、30Y)不是独立随机变量——它们高度联动。相邻期限收益率相关系数常常 0.9 以上,因为都受「未来短期利率预期」「期限溢价」这些共同力量驱动。直接用 11 个独立序列建模,既浪费自由度又容易过拟合。Nelson-Siegel 的洞见是:用 3 个因子 + 固定形状函数,就能重建整条曲线。这 3 个因子各有清晰的经济含义:
- 水平(Level, β₀):整条曲线的中枢,约等于长端利率,反映整体利率水平。
- 斜率(Slope, β₁):短端减长端的利差,正常时为正(曲线向上);变负就是倒挂,常被当衰退预警。
- 曲率(Curvature, β₂):中段相对两端的隆起,捕捉「驼峰」形态,多由中期供需/预期错配驱动。
二、NS 参数化:三个因子 + 固定形状#
经典 Nelson-Siegel 把期限 τ 的收益率写成:
y(τ) = β₀ + β₁·( (1−e^{−τ/λ}) / (τ/λ) ) + β₂·( (1−e^{−τ/λ}) / (τ/λ) − e^{−τ/λ} )plaintext前两项的形状由「衰减参数 λ」决定(它控制斜率项衰减快慢、曲率项峰值位置),Diebold-Li 把 λ 固定(常见取使曲率峰值在 2.5~3 年附近的值,本文 λ=2.5),于是形状是死的,只有 β₀/β₁/β₂ 三个系数活着——它们就是我们要追踪的因子。
import numpy as np
TAU = np.array([0.25, 0.5, 1, 2, 3, 5, 7, 10, 15, 20, 30], dtype=float)
LAMBDA = 2.5
def loadings(tau, lam=LAMBDA):
x = tau / lam
lvl = np.ones_like(tau) # 水平载荷:恒 1
slp = (1.0 - np.exp(-x)) / x # 斜率载荷:短高、长→0
cur = slp - np.exp(-x) # 曲率载荷:中段隆起
return np.vstack([lvl, slp, cur]) # (3, n_tau)
B = loadings(TAU) # 固定载荷矩阵 (3, 11)
def yield_curve(beta):
return B.T @ beta # beta = (L, S, C) -> 11 点收益率python把 λ 固定后,三因子载荷长这样:水平永远是 1(对所有期限等权重),斜率从短端高、向长端衰减到 0,曲率在中段隆起、两端为负——形状固定,曲线的一切变化都归到 L/S/C 三个数的涨跌。

三、动态版:把 L/S/C 变成会游走的状态#
静态 NS 是「横截面拟合」——给一天的数据,解出当天的 L/S/C。动态版(DNS)更进一步:假设 L/S/C 各自按 AR(1) 随机游走演化,于是整条曲线随这三个因子的联动而蠕动。这正是状态空间模型的骨架:
状态方程: β_t = b + Φ·β_{t-1} + 过程噪声 w_t (w_t ~ N(0, Q))
量测方程: y_t = Bᵀ·β_t + 观测噪声 ε_t (ε_t ~ N(0, R))plaintextΦ = diag(φ_L, φ_S, φ_C):各因子的持续性。水平最黏(φ_L≈0.985,长期利率慢变),斜率/曲率持续性低(≈0.85~0.90,对预期变化反应快)。Bᵀ:固定载荷,把 3 个隐状态映射到 11 个观测收益率。- 卡尔曼滤波负责:用昨天的状态预测今天 → 用今天的 11 个收益率更新 → 得到今天的「平滑因子」。
from scipy.linalg import inv
T = 250
a = np.array([3.2, -1.0, -0.5]) # L/S/C 中枢(水平3.2%、斜率-1%、曲率-0.5%)
phi = np.array([0.985, 0.90, 0.85]) # AR(1) 持续性
sig = np.array([0.18, 0.45, 0.35]) # 过程噪声标准差
b0 = a * (1 - phi); b0[0] = 3.2
beta = a.copy()
beta_true = np.zeros((T, 3))
rng = np.random.default_rng(20260715)
for t in range(T):
beta = b0 + phi * beta + rng.normal(0, sig, 3)
beta_true[t] = beta
# 注入一段「倒挂加深→修复」:斜率 S 先下压、再回升
beta_true[120:160, 1] -= np.linspace(0, 1.4, 40)
beta_true[160:200, 1] += np.linspace(0, 1.6, 40)
Y = np.array([yield_curve(bt) for bt in beta_true]) + rng.normal(0, 0.04, (T, 11))python四、卡尔曼滤波:从 11 个点反推 3 个因子#
卡尔曼滤波是这段的核心。预测步用状态方程把昨天的最优估计推到今天先验;更新步用今天的 11 个收益率做修正。RTS 平滑再往回跑一遍,得到「用全部信息」的最优因子轨迹。
Fmat = np.diag(phi); Hmat = B.T
Qmat = np.diag(sig**2); Rmat = np.eye(len(TAU)) * (0.04**2)
beta_filt = np.zeros((T, 3)); P_filt = np.zeros((T, 3, 3))
x_pred = a.copy(); P_pred = np.diag(np.var(beta_true, axis=0) + 1e-6)
for t in range(T):
x_pred = b0 + Fmat @ x_pred
P_pred = Fmat @ P_pred @ Fmat.T + Qmat
e = Y[t] - Hmat @ x_pred
S = Hmat @ P_pred @ Hmat.T + Rmat
K = P_pred @ Hmat.T @ inv(S)
x_pred = x_pred + K @ e
P_pred = (np.eye(3) - K @ Hmat) @ P_pred
beta_filt[t] = x_pred; P_filt[t] = P_pred
# RTS 平滑(简版)
beta_sm = beta_filt.copy()
for t in range(T - 2, -1, -1):
Pp = Fmat @ P_filt[t] @ Fmat.T + Qmat
C = P_filt[t] @ Fmat.T @ inv(Pp)
beta_sm[t] = beta_filt[t] + C @ (beta_sm[t + 1] - (b0 + Fmat @ beta_filt[t]))
P_sm[t] = P_filt[t] + C @ (P_sm[t + 1] - Pp) @ C.Tpython跑出来:卡尔曼平滑还原的 L/S/C 与真实因子轨迹几乎重合(因子 RMSE:L=0.035、S=0.038、C=0.128),用这 3 个还原因子重建整条 11 点曲线,RMSE 仅 3.55bps(最大 3.91bps)——相当于用 3 个数把 11 个期限点全部 bp 级重建。这正是 NS「降维不丢信息」的威力。

五、形变怎么被拆出来:倒挂是斜率因子的事#
重点看那条人为注入的「倒挂→修复」。在 β_true 里,我们只在 S(斜率)通道上动了手脚:120160 天把 S 下压 1.4(曲线变平/倒挂),160200 天再拉回 1.6(修复)。还原结果里:
- L(水平)几乎纹丝不动——整条曲线的中枢没变,只是形状在扭。
- C(曲率)仅小幅波动——中段隆起没大变化。
- S(斜率)干净地走出「下压→回升」的 V 形——倒挂与修复被完全归因到斜率因子。
这正是 DNS 最有用的地方:曲线的每一种「形变」都能映射到具体因子,而不是混成一团。监管/交易员看一眼 S 因子,就知道现在是「正常陡峭」还是「危险倒挂」,不用去肉眼比对数十条曲线。
六、还原误差:bp 级且跨期限平稳#
把卡尔曼还原的曲线与真实(含噪)观测对比,算每个期限的 RMSE:
err = Y - np.array([yield_curve(bt) for bt in beta_sm])
rmse = np.sqrt((err**2).mean(axis=0)) # 每个期限的 RMSEpython结果:各期限 RMSE 都在 3~4bps 之间、几乎不随期限变化——说明卡尔曼滤波不是「只在某几个点拟合好」,而是把载荷结构用足、全期限均匀还原。长端通常噪声大、难拟合,这里因为 λ 固定、因子共享,长端也被短中端的信息带着走,误差不发散。

七、真实陷阱:比想象中更硬的六类#
陷阱一:λ 不能随便定,它是结构假设而非自由参数。 λ 决定斜率衰减快慢和曲率峰值位置。文献常见 λ∈[2,4](曲率峰在 2~3 年),一旦 λ 设错,曲率因子会去「硬拟合」错误频段,L/S/C 的经济含义全乱。实务上可对 λ 也做网格搜索或把它当成待估参数(Svensson 扩展加第 4 个因子),但 λ 和因子含义强耦合,调参要谨慎。
陷阱二:卡尔曼对「线性高斯」敏感,收益率非高斯会偏。 本文状态/观测都设成高斯。真实收益率、尤其短端,存在波动聚集和跳跃(议息会议、数据爆冷)。非高斯下卡尔曼的「最优」不再成立,需用学生-t 似然或粒子滤波。本文合成干净,真实落地要加厚尾处理。
陷阱三:AR(1) 状态方程太朴素,抓不住 regime。 用恒定 Φ 假设因子持续性不变。但 2008、2020 那种时期,斜率因子可能瞬间跳变(央行紧急降息),AR(1) 的慢惯性会严重滞后。改进是 Markov 区制切换(MS-DNS)或把宏观变量(通胀、失业率)喂进状态方程做条件 DNS。
陷阱四:观测噪声 R 与过程噪声 Q 的比值决定「信谁」。 Q 太大→滤波过度跟随观测(噪声被当信号);R 太大→滤波过度平滑(滞后)。两者都要靠 EM 算法或交叉验证估,不能拍脑袋。本文 R 设成 4bps²、Q 由因子波动给定,真实数据需重新标定。
陷阱五:期限点缺失/不一致会破坏载荷对齐。 状态空间依赖固定的 B(载荷矩阵)。真实里 30Y 可能某些天没报价、或新发券改变可观测期限集,B 行数一变滤波要重配。中长端流动性差、报价噪声大,直接进量测会污染因子,实务常对中长端降权或单独处理。
陷阱六:样本内拟合好≠样本外能预测。 DNS 常被用来预测未来曲线(把 AR(1) 外推成预测)。但 L/S/C 的 AR(1) 外推本质是「惯性外推」,在拐点(政策转向)会系统性错;且预测误差随期限拉长而放大。用它做定价/对冲 OK,做方向押注要配宏观判断。
八、诚实结论#
动态 Nelson-Siegel 把「一条 11 点、每天都在蠕动的收益率曲线」压成 3 个有清晰经济含义的因子(水平/斜率/曲率),再用 AR(1) 状态方程 + 卡尔曼滤波让它们随时间优雅游走。合成 250 天里,卡尔曼平滑还原整条曲线的 RMSE 仅 3.55bps(最大 3.91bps),人为注入的「倒挂→修复」被干净归因到斜率因子——曲线的一切形变,都能拆回 L/S/C 三个数。
但落到真实账户/组合,先过 λ 结构假设、非高斯、regime 跳变、Q/R 标定、期限缺失、样本外外推 这六关。最有用的姿势,是把 DNS 当「收益率曲线的实时仪表盘」:不看 11 条线,只看 L/S/C 三个指针——尤其斜率因子转负时,那往往是宏观要变天的第一声信号。
注:全文数据为自洽合成(三因子 AR(1) 演化 + 观测噪声,含一段人为注入的斜率倒挂/修复),仅用于演示 DNS 状态空间与卡尔曼滤波的还原性质。真实复现请替换为实际收益率序列,并对 λ、Q/R、非高斯与 regime 切换逐一做稳健性检验。