时间序列建模这件事,我踩过的坑比走过的路还多。早些年做需求预测,上来就是ARIMA一套组合拳,平稳性检验、差分、定阶,流程走得行云流水,结果一到真实数据就傻眼——参数随时间漂移得厉害,模型训完第二个月就废了。后来接触到动态线性模型(Dynamic Linear Model,DLM),才算是找到了一个能"跟着数据一起变"的框架。这篇就围绕DLM展开,把它的核心思想、状态空间表达、递推更新机制、实操落地和常见坑点掰开揉碎讲清楚。不管你是刚接触时间序列的学生,还是被非平稳数据折磨过的从业者,都能从里面拿到能直接用的东西。
DLM本质上是状态空间模型在时间序列里的一个特化分支,用一组递推方程描述"隐藏状态如何随时间演化"以及"我们观测到的数据如何由状态生成"。它最大的价值在于:参数不是一成不变的常数,而是被建模成随时间演化的随机变量,每来一个新观测就更新一次信念。这跟传统ARIMA那种"训练一次、冻结参数"的思路完全不同。下面我按自己的理解路径,从动机到实现再到排错,一层层往下拆。
1. 为什么传统时序模型在真实数据上总是"训完就废"
1.1 参数漂移:被大多数人低估的头号杀手
先说一个我印象极深的场景。某次做一个零售销量预测,历史数据三年,用季节性ARIMA拟合,回测MAPE只有8%,漂亮得不像话。上线跑了一个季度,误差飙到30%以上。排查了半天,数据没坏、代码没改,问题出在消费习惯本身在变——促销节奏调整、新品替代、渠道迁移,这些因素让"三年前学到的参数"逐渐失效。
传统模型假设数据生成过程的参数是固定但未知的,训练就是估计这组固定参数。可现实里,参数是缓慢漂移的。你用一个静态模型去追一个动态过程,短期还行,时间一长必然掉队。这不是模型不够复杂的问题,是建模假设本身就跟现实对不上。
DLM从根上换了个思路:它不假设参数固定,而是假设参数(状态)服从一个演化方程,每个时间步都可能变化,观测只是对当前状态的一次带噪测量。参数漂移不再是"模型失效的原因",而是"模型内建的机制"。
1.2 静态模型与动态模型的本质差异
用一个类比说清楚。静态模型像给一个人拍一张照片,然后拿这张照片去认未来所有时刻的他;动态模型像持续录像,每一帧都在更新对这个人当前状态的认知。照片拍得再清晰,人换了发型、胖了瘦了,照片就认不出了;录像则天然适应变化。
落到数学上,静态模型估计的是 $\theta$,一个不随时间变的向量;DLM估计的是 $\theta_t$,带时间下标,并且显式建模 $\theta_t$ 与 $\theta_{t-1}$ 之间的转移关系。这个转移关系就是"动态"二字的来源,也是DLM能自适应漂移的关键。
1.3 什么信号告诉你"该上DLM了"
不是所有时序问题都需要DLM。我总结了几条判断标准,命中两条以上就该认真考虑:
- 回测表现好但上线后误差随时间单调上升,典型的参数漂移症状;
- 业务方明确告诉你"规律在变",比如政策调整、竞品入场、用户结构迁移;
- 数据量不大但要求模型能在线更新,不想每次重训;
- 需要预测区间而不只是点预测,且区间要随不确定性动态变化;
- 存在多个相关序列,想共享信息做联合建模。
反过来,如果数据平稳、规律稳定、样本充足,老老实实用ARIMA或梯度提升树可能更省事。DLM不是银弹,它的优势场景是"变化中的系统"。
2. DLM的状态空间骨架:两条方程撑起整个框架
2.1 观测方程与状态方程的分工
DLM的全部内容可以浓缩成两条方程。第一条是观测方程,描述我们看到的 $y_t$ 怎么由隐藏状态 $\theta_t$ 生成:
$$y_t = F_t \theta_t + v_t, \quad v_t \sim \mathcal{N}(0, V_t)$$
第二条是状态方程,描述状态自己怎么随时间演化:
$$\theta_t = G_t \theta_{t-1} + w_t, \quad w_t \sim \mathcal{N}(0, W_t)$$
这里 $F_t$ 是观测矩阵(也叫设计矩阵),$G_t$ 是状态转移矩阵,$V_t$ 是观测噪声方差,$W_t$ 是状态演化噪声方差。四个量合起来记作 ${F, G, V, W}_t$,就是DLM的完整参数集。
理解这两条方程的分工是理解DLM的钥匙。观测方程管"看得见的部分怎么来",状态方程管"看不见的部分怎么变"。$V_t$ 大意味着观测噪声大、单次测量不可信;$W_t$ 大意味着状态变化剧烈、旧信息贬值快。这两个噪声的比值,直接决定了模型对"新数据"和"旧信念"的信任分配,后面会专门讲。
2.2 一个能跑的最小例子:局部水平模型
理论说多了容易飘,直接上最简单的DLM——局部水平模型(Local Level Model),也叫随机游走加噪声模型。它只有一维状态,$F_t = 1$,$G_t = 1$,于是两条方程退化成:
$$y_t = \theta_t + v_t, \quad v_t \sim \mathcal{N}(0, V)$$ $$\theta_t = \theta_{t-1} + w_t, \quad w_t \sim \mathcal{N}(0, W)$$
含义非常直白:真实水平 $\theta_t$ 每一步随机游走一点,观测是真实水平加噪声。这个模型虽然简单,却能处理大量"水平缓慢漂移"的场景,比如某设备的基准读数、某指标的基线值。我经常拿它当baseline,先跑一遍看效果,再决定要不要上更复杂的结构。
2.3 从局部水平到趋势与季节:结构怎么搭
局部水平只建模了水平。真实序列往往还有趋势和季节性,DLM通过扩展状态向量和设计矩阵来容纳它们。常见组合:
| 成分 | 状态维度 | $F$ 的对应块 | $G$ 的对应块 |
|---|---|---|---|
| 水平 | 1 | 1 | 1 |
| 线性趋势 | 2 | [1, 0] | [[1,1],[0,1]] |
| 季节(周期p) | p-1 | [1,0,...,0] | 循环移位矩阵 |
| 回归项 | k | $x_t^\top$ | 单位阵 |
把各成分的状态向量拼接起来,$F$ 和 $G$ 做相应的分块对角拼接,就得到一个复合DLM。这种"积木式"搭建是DLM相对ARIMA的一大优势——每个成分的物理含义清晰,可解释性强,增删成分不影响其他部分。我做过一个带趋势加周季节的销量模型,把趋势块和季节块分开调,哪个块出问题一目了然,比ARIMA里一堆纠缠的系数好排查太多。
3. 卡尔曼滤波:DLM能"在线学习"的引擎
3.1 递推更新的直觉:先预测,再修正
DLM之所以能每来一个数据就更新,靠的是卡尔曼滤波这套递推算法。它的逻辑其实特别符合直觉,就两步循环:
- 预测:基于上一步的状态估计,用状态方程推这一步状态应该在哪,同时不确定性因为 $W_t$ 而增大;
- 修正:拿到这一步的观测 $y_t$,比较"预测的观测"和"真实观测"的差距,按一定权重把状态往观测方向拉,同时不确定性因为吸收了新信息而减小。
这个"预测-修正"循环,跟人做判断的过程一模一样。你对某件事有个先验判断(预测),看到新证据(观测),然后按证据的可信度调整判断(修正)。证据越可靠、先验越不确定,调整幅度越大。卡尔曼滤波把这个过程数学化了。
3.2 五个核心公式的逐步拆解
标准卡尔曼滤波的递推公式如下,我按"预测阶段"和"更新阶段"分开列,并标注每一步的物理含义:
预测阶段:
$$\hat{\theta}{t|t-1} = G_t \hat{\theta}{t-1|t-1}$$ $$P_{t|t-1} = G_t P_{t-1|t-1} G_t^\top + W_t$$
更新阶段:
$$\tilde{y}t = y_t - F_t \hat{\theta}{t|t-1}$$ $$S_t = F_t P_{t|t-1} F_t^\top + V_t$$ $$K_t = P_{t|t-1} F_t^\top S_t^{-1}$$ $$\hat{\theta}{t|t} = \hat{\theta}{t|t-1} + K_t \tilde{y}t$$ $$P{t|t} = (I - K_t F_t) P_{t|t-1}$$
逐个说清楚。$\hat{\theta}{t|t-1}$ 是"用截至t-1的信息预测的t时刻状态",$P{t|t-1}$ 是对应的协方差,注意它比上一步的 $P$ 多了 $W_t$,这就是"预测让不确定性变大"的数学体现。$\tilde{y}_t$ 叫新息(innovation),是观测与预测观测之差,整个滤波的信息就藏在这个残差里。$S_t$ 是新息的协方差,$K_t$ 是卡尔曼增益,它决定了这次修正用多大力度。
$K_t$ 值得单独说。它本质是一个"信谁多一点"的权重:当 $V_t$ 大(观测噪声大)时 $S_t$ 大,$K_t$ 小,模型更信预测;当 $P_{t|t-1}$ 大(预测不确定)时 $K_t$ 大,模型更信观测。这个自动调节机制,就是DLM能自适应不同噪声环境的根本原因。
3.3 新息序列:模型诊断的免费体检报告
很多人跑完卡尔曼滤波就完事了,其实 $\tilde{y}_t$ 序列是个宝藏。如果模型设定正确,新息应该是零均值、无自相关、方差与 $S_t$ 匹配的白噪声。一旦新息出现系统性偏离或自相关,说明模型哪里不对——可能是 $W$ 设小了导致反应迟钝,可能是漏了趋势项导致残差有漂移,也可能是 $V$ 设大了导致过度平滑。
我习惯把标准化新息 $\tilde{y}_t / \sqrt{S_t}$ 画出来看,正常应该像白噪声一样在±2之间随机跳动。如果出现连续同号、逐渐发散、或者某段突然密集,都是明确的诊断信号。这套诊断比看拟合优度有用得多,因为它能定位到"哪一步开始出问题"。
4. 噪声方差怎么定:DLM调参的真正战场
4.1 $W/V$ 比值决定模型的"记忆长度"
DLM里真正难调的其实就一个东西:状态噪声 $W$ 与观测噪声 $V$ 的比值。这个比值决定了模型的"记忆长度"——比值大,模型健忘、反应快、跟得紧但也容易被噪声带偏;比值小,模型记性好、平滑稳但也迟钝、漂移跟不上。
有个经验公式可以估算等效记忆窗口。对局部水平模型,稳态卡尔曼增益满足一个二次方程,解出来的 $K$ 大约对应 $q = W/V$ 的函数。粗略地,模型的有效记忆长度约为 $1/\sqrt{q}$ 个时间步的量级。比如 $q = 0.01$,记忆长度约10步;$q = 0.0001$,记忆长度约100步。这个估算不精确,但能帮你快速判断"我设的噪声比是不是离谱"。
4.2 极大似然估计:让数据自己说话
手调 $W$ 和 $V$ 终究是拍脑袋,正规做法是极大似然估计。卡尔曼滤波有个副产品叫预测误差分解,它把观测的联合似然写成各步新息密度的乘积:
$$\log L = -\frac{1}{2}\sum_{t=1}^{T}\left(\log(2\pi S_t) + \frac{\tilde{y}_t^2}{S_t}\right)$$
这个式子只依赖新息和它的方差,而这两者都是滤波过程中自然算出来的。所以流程是:给定一组 $(V, W)$,跑一遍滤波,算出对数似然,然后用数值优化(比如L-BFGS或Nelder-Mead)去最大化它。实践中通常对 $V$ 和 $W$ 取对数再优化,保证正性且数值更稳。
我用Python的statsmodels做过对比,它内置的UnobservedComponents就是DLM的极大似然实现,直接调能省不少事。但如果要自定义结构或者做在线更新,还是得自己写滤波循环,可控性更强。
4.3 方差不是常数怎么办:折扣因子与自适应
极大似然估计出的 $W$、$V$ 是全局常数,可现实里噪声水平也会变——平静期噪声小,动荡期噪声大。这时候有两个常用手段。
第一个是折扣因子法(discount factor)。不直接设 $W$,而是设一个折扣 $\delta \in (0,1)$,令 $P_{t|t-1} = G_t P_{t-1|t-1} G_t^\top / \delta$。$\delta$ 越接近1,模型越"记得久";越接近0,越"健忘"。这个技巧在工程实践里特别流行,因为它把调参从"调一个无界的方差"变成"调一个0到1之间的折扣",直觉友好得多。
第二个是自适应滤波,让 $W$ 或 $V$ 本身随新息大小动态调整。新息持续偏大说明模型跟不上,就临时放大 $W$;新息很小说明模型够用,就收小 $W$。这类方法在突变检测场景里很有用,但要注意别让自适应机制本身引入不稳定,通常要加平滑或上下界约束。
5. 从公式到代码:一个可复现的DLM实现
5.1 环境与依赖准备
我用Python演示,依赖就三个:numpy做矩阵运算,scipy做优化,matplotlib做诊断图。不需要重型框架,DLM的滤波循环手写也就几十行,反而比调库更透明。
pip install numpy scipy matplotlib提示:如果你的场景需要频繁做极大似然优化,建议再装
statsmodels做交叉验证,它的UnobservedComponents可以直接对拍结果,确认自己手写的滤波没写错。
5.2 手写卡尔曼滤波循环
下面是一个局部水平模型的完整滤波实现,带似然计算,可以直接拿去改:
import numpy as np def kalman_filter_local_level(y, V, W, theta0=0.0, P0=1e6): n = len(y) theta_pred = np.zeros(n) P_pred = np.zeros(n) theta_filt = np.zeros(n) P_filt = np.zeros(n) innov = np.zeros(n) S = np.zeros(n) loglik = 0.0 theta_prev, P_prev = theta0, P0 for t in range(n): # 预测 theta_p = theta_prev # G = 1 P_p = P_prev + W # G P G' + W # 新息 e = y[t] - theta_p # F = 1 s = P_p + V # F P F' + V # 增益与更新 K = P_p / s theta_f = theta_p + K * e P_f = (1 - K) * P_p # 记录 theta_pred[t], P_pred[t] = theta_p, P_p theta_filt[t], P_filt[t] = theta_f, P_f innov[t], S[t] = e, s loglik += -0.5 * (np.log(2 * np.pi * s) + e**2 / s) theta_prev, P_prev = theta_f, P_f return { "theta_pred": theta_pred, "P_pred": P_pred, "theta_filt": theta_filt, "P_filt": P_filt, "innov": innov, "S": S, "loglik": loglik }几个实现细节值得强调。P0我设成 $10^6$,这是"无信息先验"的常用做法,表示初始状态完全不知道,让第一帧观测几乎完全决定初始估计。如果你对初始状态有把握,可以设小一点,收敛会更快。另外注意P_f = (1 - K) * P_p这个写法只在一维成立,多维要用矩阵形式(I - K F) P_p,别照抄。
5.3 用极大似然把 $V$、$W$ 优化出来
有了似然函数,优化就是标准操作。注意在对数空间优化,保证正性:
from scipy.optimize import minimize def neg_loglik(params, y): logV, logW = params V, W = np.exp(logV), np.exp(logW) res = kalman_filter_local_level(y, V, W) return -res["loglik"] # 初始猜测:观测方差用数据方差,状态方差取小一点 init = [np.log(np.var(y) * 0.5), np.log(np.var(y) * 0.01)] opt = minimize(neg_loglik, init, args=(y,), method="Nelder-Mead", options={"xatol": 1e-8, "fatol": 1e-8, "maxiter": 5000}) V_hat, W_hat = np.exp(opt.x) print(f"V = {V_hat:.4f}, W = {W_hat:.4f}, q = {W_hat/V_hat:.6f}")Nelder-Mead不需要梯度,对这种低维问题够用且稳。如果维度上去了(比如复合模型有多个方差要估),换成L-BFGS-B加数值梯度会更快。优化完记得看一眼收敛状态,opt.success为False的话结果不可信,得换初值重来。
5.4 预测与区间:把不确定性一起交付
滤波给的是"截至当前的状态估计",预测要往前推。h步预测就是把状态方程迭代h次,观测预测再叠加观测噪声:
def forecast(res, h, V, W): theta = res["theta_filt"][-1] P = res["P_filt"][-1] means, lowers, uppers = [], [], [] for _ in range(h): theta = theta # G = 1 P = P + W mean = theta # F = 1 var = P + V means.append(mean) lowers.append(mean - 1.96 * np.sqrt(var)) uppers.append(mean + 1.96 * np.sqrt(var)) return np.array(means), np.array(lowers), np.array(uppers)注意预测区间随h增大而变宽,这是对的——越远越不确定。很多业务方不习惯这种"区间会变宽"的交付方式,得提前沟通清楚,否则会被质疑"为什么预测区间不固定"。
6. 实战里最容易翻车的几个地方
6.1 初始协方差设太小导致"起步迟钝"
P0设太小是我见过最高频的坑。有人图省事把P0设成1或者0.1,结果前几十步模型几乎不响应观测,因为初始信念"太自信",卡尔曼增益被压得很低。表现就是预测曲线前段几乎是条平线,跟真实数据脱节。
正确做法是初始用大方差(无信息先验),让数据自己把初始状态"拉"到位。等滤波跑顺了,P会自然收敛到合理水平。如果你确实有靠谱的先验,那另说,但绝大多数场景下"假装什么都不知道"是更安全的选择。
6.2 把新息自相关误判成"模型没问题"
新息有自相关是模型设定错误的强信号,但很多人只看新息均值接近0就放心了。均值接近0只说明无偏,不代表无自相关。我见过一个案例,新息均值几乎为0,但自相关图在滞后1、2上明显超出置信带,原因是模型漏了趋势项,残差里残留了趋势的动态。补上趋势块后自相关消失。
所以诊断要看全套:均值、方差、自相关、正态性。任何一项明显异常都要追根溯源,别被"均值正常"骗过去。
6.3 状态维度堆太高导致数值不稳
DLM的"积木式"搭建很爽,容易让人不断加成分——水平、趋势、多个季节、多个回归项,状态维度蹭蹭往上涨。维度一高,协方差矩阵求逆的数值误差就上来了,严重时S_t接近奇异,卡尔曼增益爆炸,预测直接飞掉。
我的经验是状态维度控制在10以内,超过就要审视"这些成分真的都必要吗"。另外矩阵求逆尽量用np.linalg.solve而不是显式inv,数值稳定性好一截。如果确实需要高维,考虑用平方根滤波或者信息滤波形式,它们对病态条件更鲁棒。
6.4 用滤波结果做样本内评估的陷阱
拿滤波后的拟合值去算样本内误差,会得到一个过于乐观的数字,因为滤波用了当前时刻的观测来更新当前时刻的状态,等于"偷看答案"。正确的评估必须用一步向前预测(也就是 $\hat{\theta}_{t|t-1}$ 对应的预测观测),或者干脆做滚动origin的交叉验证。
这个坑我在早期项目里踩得很惨,样本内R²高达0.98,上线后一塌糊涂。后来养成习惯,评估一律用预测值不用滤波值,误差立刻回归真实水平。这个区别在DLM里尤其重要,因为滤波和预测的差距比静态模型大得多。
7. 什么时候该从DLM换到更重的武器
DLM不是终点。当关系变成非线性、或者状态转移本身依赖观测,标准线性高斯假设就不够用了。这时候的升级路径大致是:状态空间还在,但观测方程或状态方程换成非线性,滤波从卡尔曼换成扩展卡尔曼(EKF)或粒子滤波;如果连噪声分布都不再高斯,粒子滤波几乎是唯一选择。
另一条路径是贝叶斯结构时间序列(BSTS),它本质上是DLM的贝叶斯扩展,能自动做变量选择、支持稀疏先验、给出完整的后验分布。我在需要"解释哪些因素在起作用"的场景会优先考虑它,因为它的成分选择有概率意义上的依据,不像手动搭DLM那样靠经验试。
但话说回来,大多数业务时序问题的非线性程度没那么夸张,DLM加上合理的成分设计往往就够了。我个人的原则是:先用最简单的DLM跑通、诊断、看新息,确认线性高斯框架撑得住,再考虑升级。上来就上粒子滤波,调参和调试的成本能把人拖垮,收益却未必对得起。
最后分享一个我常用的落地节奏:第一周只搭局部水平模型,把数据管道、滤波循环、似然优化、诊断图全部跑通;第二周根据新息诊断加趋势和季节成分;第三周做滚动交叉验证确认泛化;第四周才考虑自适应噪声或换更重的模型。这个节奏看着慢,但每一步都有诊断依据,比一上来堆复杂结构然后花两周debug要快得多。DLM这东西,结构越简单越容易定位问题,这是我踩了无数坑之后最实在的体会。