ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LLM+状态空间模型:文本与数值联合时序预测工程实践

LLM+状态空间模型:文本与数值联合时序预测工程实践

做时序预测这行的人,大概都经历过这么一个阶段:数值序列的模型调到瓶颈了,MAPE 怎么都压不下去,于是开始琢磨把文本信号引进来——新闻、公告、评论、搜索词,什么都想试试。想法本身没毛病,业务上的大事几乎都会先在文本里留下痕迹。但真动手之后,十个人里有八个会得到同一个结果:加了文本特征,验证集误差不降反升,而且区间预测变得又窄又不准。

这两年围绕贝叶斯时序模型的讨论明显热了起来,核心变化不在卡尔曼滤波本身,而在于LLM + 状态空间模型这条组合路线开始被认真对待:用大模型处理文本、输出带不确定性的表征,用状态空间模型承载数值动态,两者在同一个贝叶斯框架下做文本+数值联合预测。这套东西听起来像论文里的漂亮架构,但落到工程上,有几个位置一旦处理错,效果就会全面崩盘。下面我按自己踩过的顺序,把这套架构拆开讲一遍,包括为什么这么设计、参数怎么定、哪里最容易出事。

1. 把新闻塞进预测模型,为什么大多数第一次尝试都会掉点

1.1 一个典型翻车现场:加了文本特征,误差反而涨了

先说个具体场景。某团队做的是按周粒度的销量预测,历史大概三年,一百多个 SKU。数值侧用的是带季节项的结构时间序列,验证集 MAPE 在 11% 左右。后来他们加了一路文本特征:把每周相关的商品评论和行业资讯用大模型编码成向量,PCA 降到 16 维,作为外生回归项塞进模型。

第一版跑完,MAPE 变成 13.4%,区间覆盖率从 92% 掉到 71%。团队的第一反应是"文本没用",第二反应是"降维降多了",于是把维度从 16 调到 64,结果更差,MAPE 14.8%,而且训练集拟合得非常好。

这个现象特别有代表性。问题不在文本没信息,而在于文本的信息密度在时间轴上是极不均匀的。大部分周的文本内容本质上是噪声,只有少数几周——新品发布、促销、突发舆情——文本才真正携带增量信息。当你把 16 维向量无差别地每周都喂进模型,等于给每个时间步都注入了一份高方差的扰动,模型为了拟合这些扰动,会把回归系数压得很小,于是真正的信号周也被一起压掉了。

正确的做法不是调维度,而是让模型自己决定什么时候该听文本的。这就需要一个能表达"系数本身也是随机变量"的框架,也就是贝叶斯的那套语言:给文本回归系数一个稀疏先验(比如 spike-and-slab,或者 horseshoe),让大部分时间步的系数自动收缩到接近零,只在证据足够强的时刻放开。这一改动通常比换编码器、调维度带来的一两个点提升大得多。

1.2 文本噪声和数值噪声的统计性质完全不同

继续往下挖。很多人把文本特征和数值特征平等对待,当成同一张表里的列,这是概念上的混淆。

数值序列的噪声通常是加性的、近似高斯的、时间上弱相关的。销量观测里的误差,大部分来自统计口径、退货延迟这类因素,量级稳定,用观测噪声方差 $R$ 就能刻画。

文本的噪声则完全是另一回事:它是乘性的、稀疏的、强时间聚集的。一条资讯对预测的影响不是"加了一个小扰动",而是"在某几周里,模型的动态结构本身变了"。用统计语言说,文本改变的是状态转移,而不只是观测值。

这就解释了为什么把文本当普通协变量效果差:它被放错了位置。在状态空间模型里,文本至少有两个可选的入口——观测方程的回归项,或者状态转移矩阵的调制项——两者的语义差别很大,选错了就等于用错了工具。第 2 节会详细说这个位置怎么选。

还有一个更隐蔽的问题:文本特征在时间上的自相关性极强。同一件事会被反复报道,向量之间余弦相似度经常在 0.9 以上。如果直接当独立特征用,等于在样本量上做了虚假放大,后验会被过度自信,这也是区间覆盖率掉下来的直接原因之一。

2. 状态空间模型凭什么当这个"混合器"

2.1 观测方程里那一个回归项,决定了文本能发挥多大作用

把状态空间模型写成标准形式,线性高斯情形下是这样:

$$ x_t = F x_{t-1} + B u_t + w_t, \quad w_t \sim \mathcal{N}(0, Q) $$

$$ y_t = H x_t + C z_t + v_t, \quad v_t \sim \mathcal{N}(0, R) $$

其中 $x_t$ 是不可直接观测的隐状态(趋势、季节、周期),$y_t$ 是观测到的数值,$z_t$ 是文本表征,$C$ 就是文本的回归系数矩阵。

文本作为观测方程的回归项(也就是 $C z_t$ 这一项),语义是:文本直接解释了观测中那部分无法被趋势和季节吸收的偏离。适合什么场景?适合文本信号与目标量之间是"即时映射"的关系,比如舆情热度对当日流量的影响、公告措辞对次日波动的冲击。

但如果文本影响的是系统的演化速度,比如持续的政策预期改变了需求的增长斜率,那它就该进状态转移:

$$ x_t = F x_{t-1} + B u_t + G z_t + w_t $$

这个 $G z_t$ 项的意思是"文本推动了隐状态本身的移动"。区别在哪?观测项的影响是瞬时的、当期就衰减;转移项的影响会通过 $F$ 逐期传播,有持续性。

实操上的判断方法很简单:画一下文本信号和目标量的互相关函数。如果峰值出现在滞后 0 附近并且迅速衰减,走观测项;如果峰值滞后几期并且拖尾很长,走转移项。我见过不少项目两个都塞,结果参数不可辨识,MCMC 采样出现明显的双峰,这种就得做模型比较而不是全都要。

2.2 趋势、季节、外生回归:结构拆解比堆特征更管用

结构时间序列的好处是把"预测"这件事拆成几个可以分别检查的部件。常见的三件套:

部件状态维数关键参数常见错误
局部线性趋势2水平方差 $\sigma_\mu^2$、斜率方差 $\sigma_\delta^2$斜率方差给太大,长期预测发散
季节项周期长度季节方差 $\sigma_\gamma^2$周期长度按自然周硬编码,忽略节假日偏移
外生回归(文本)取决于降维后维度系数先验、收缩强度用无信息先验,导致系数乱跳

趋势项的方差参数是最容易被忽视的调参点。$\sigma_\delta^2$ 决定斜率能变多快,给大了模型会对最近几期的波动过度反应,长期外推时斜率一路跑偏;给小了模型跟不上真实的结构变化,表现为连续几期系统性高估或低估。

一个实用的经验做法是给 $\sigma_\delta^2$ 一个较紧的先验,然后看后验是否贴在边界上。如果后验密度堆在接近零的位置,说明这段数据里斜率基本没变,可以退化成一个局部常数模型,参数少一个,收敛也快。反过来如果后验明显远离零,那说明业务上真的发生了结构性变化,这时候应该去文本侧找原因,而不是继续调方差。

季节项上有个具体坑:如果数据是日粒度但业务按周走,直接用 7 天周期会在节假日前后产生系统性偏差。处理办法是加一个"工作日/非工作日"的哑变量,或者把季节状态按日历事件做偏移。这不是贝叶斯特有的问题,但在贝叶斯框架下处理起来更自然——把偏移量也当成随机变量,给它一个离散先验。

2.3 贝叶斯在这里不是信仰,是为了拿到可用的区间

很多讨论把贝叶斯和非贝叶斯对立起来,其实在这个场景里选贝叶斯,动机非常实际:你需要一个能随文本信号强弱而动态变化的预测区间。

点预测用极大似然也能做,但业务方通常要的是"下周的 90% 区间",用来定安全库存或者备货量。频率派的区间依赖渐近正态,在小样本、结构突变的情况下容易失真。贝叶斯后验预测分布则天然把参数不确定性、状态不确定性、观测噪声三层都算进去了。

具体拆一下这三层:

  • 参数不确定性:$F, Q, R, C$ 这些矩阵本身是估出来的,有后验分布
  • 状态不确定性:$x_t$ 是隐变量,滤波分布本身有方差
  • 观测噪声:$v_t$ 那一层,即使参数和状态都已知,未来观测仍然有随机性

三层叠加起来的区间,通常比只考虑观测噪声的区间宽 30% 到 60%。这个差距在业务上非常关键——很多模型的区间之所以总是被突破,就是因为只算了第三层。

不过要提醒一点:贝叶斯不等于自动正确。如果先验给得离谱,后验预测分布一样会失真。我见过给观测噪声 $\sigma_v$ 一个均值为目标量标准差十分之一的先验,结果区间窄得像条线,覆盖率只有六成。先验这东西,宁可宽一点让它被数据拉动,也不要为了"收敛快"给一个强信息先验。

3. LLM 在这套架构里到底该干什么活

3.1 三种接入姿势的利弊对照

大模型在联合预测里的角色,大致有三种,实现的复杂度和收益差别很大。

第一种:当编码器。把文本转成向量,作为 $z_t$ 喂进去,后续全交给贝叶斯回归。优点是实现简单,LLM 可以离线批量跑,成本可控。缺点是向量本身没有语义解释,模型学到的系数你没法跟业务方解释"为什么这周系数是负的"。

第二种:当特征抽取器。让 LLM 输出结构化字段——情感极性、事件类型、影响强度、涉及品类,再把这些离散标签做成哑变量或者有序变量进模型。可解释性好,业务方能看懂,缺点是信息损失大,而且 LLM 的分类稳定性需要单独验证。

第三种:当先验生成器。让 LLM 输出的不是点值,而是一个分布的参数,比如"这条资讯对下周销量的影响,均值 +0.3,标准差 0.8",然后把这个分布作为模型里某个系数的先验。这是三者里最贴合贝叶斯框架的做法,也是"LLM + 状态空间模型"这个组合最有意思的地方。

三种方式的对比:

接入方式实现成本可解释性对区间预测的帮助适用场景
编码器(向量)低差中文本量大、无标注
特征抽取(结构化)中好中需要向业务解释
先验生成(分布参数)高较好高样本少、先验宝贵

如果团队是第一次做,建议从第一种起步把管线跑通,再逐步往第三种迁移。直接上第三种的话,LLM 输出的分布参数本身可能就是错的,而且错得隐蔽——它会让后验看起来收敛得很好,实际上是被先验拽住了。

3.2 文本表征降维:768 维直接进模型是灾难

这一条几乎每个项目都会遇到。现在的 embedding 动辄 768 维、1024 维,直接塞进状态空间模型,参数量会爆炸:$C$ 矩阵是 $1 \times d$,看着不大,但配合贝叶斯采样,$d$ 每增加一维,后验的维数就多一维,NUTS 的采样时间大致呈平方级增长。

更麻烦的是共线性。文本向量在维度之间高度相关,$d=768$ 时协方差矩阵的条件数经常到 $10^6$ 以上,采样器在这种地形上基本走不动。

我的处理流程通常是三步:

  1. 先降到 32 到 64 维,用 PCA 或者随机投影。这一步主要是为了去掉冗余,不是最终形态。
  2. 再做稀疏选择,用 spike-and-slab 或者 horseshoe 先验,让模型自己挑出真正有用的几维。实践中最后真正有非零系数的通常只有 3 到 8 维。
  3. 保留可解释的少量维度,把 LLM 输出的结构化字段(情感强度、事件类型)单独作为低维特征并行输入。
# 示意性的降维与先验配置 import numpy as np def build_text_features(embeddings, n_components=48): # 中心化后做 SVD,不用 sklearn 的 PCA 是为了控制随机性 centered = embeddings - embeddings.mean(axis=0, keepdims=True) U, S, Vt = np.linalg.svd(centered, full_matrices=False) # 只保留累计解释方差达到阈值的成分 explained = np.cumsum(S ** 2) / np.sum(S ** 2) k = min(n_components, np.searchsorted(explained, 0.85) + 1) return centered @ Vt[:k].T, Vt[:k], k

注意这里取了累计解释方差 85% 对应的维数,而不是硬编码 48。文本向量的方差分布很集中,前 20 维往往就能覆盖 80% 以上,硬编码大维数只会增加噪声。

还有个细节:降维的投影矩阵必须在训练集上拟合,然后原样应用到验证集和测试集。我见过直接在全量数据上做 PCA 再切分的,这是标准的泄漏,验证集上的效果会虚高好几个点。

3.3 让 LLM 吐分布参数,而不是一个情感分

第三种接入方式值得单独说,因为它是这套架构真正的增量所在。

传统做法是让 LLM 输出一个情感分数,比如 0.7,然后当成一个数值特征。问题在于这个 0.7 的可信度完全被丢掉了。同样是 0.7,一条明确的产品召回公告和一条含糊的用户吐槽,前者应该被高度重视,后者应该被大幅折扣。

让 LLM 输出分布参数,就能把这个可信度保留下来。具体做法是设计一个输出格式,要求模型给出三件事:影响的方向、影响的均值、以及一个不确定性量级。提示词大致是这样的结构:

给定以下文本,判断它对【目标指标】在未来 1 到 4 周的影响。 输出 JSON: { "direction": "increase | decrease | neutral", "effect_mean": 浮点数,标准化后的效应量,范围 -2 到 2, "effect_std": 浮点数,你对上述判断的不确定程度,范围 0.1 到 3, "reason": 一句话说明 } 如果文本与该指标无关,direction 填 neutral,effect_mean 填 0,effect_std 填 3。

拿到这三个字段后,在模型里构造一个正态先验 $\mathcal{N}(\mu_{\text{prior}}, \sigma_{\text{prior}}^2)$ 作用在文本回归系数上。关键在于 $\sigma_{\text{prior}}$ 与模型自身的数据证据形成竞争:数据强的时候,似然压倒先验,LLM 判断错了也能被纠正;数据弱的时候(比如新品类、样本不足),先验起主导作用,避免系数乱跑。

这套机制在小样本场景下特别值钱。我做过一个对比,历史数据只有 40 周左右的品类,纯数据驱动的模型区间宽得没法用,加上 LLM 先验后区间收窄了大约三分之一,同时覆盖率还维持在名义水平附近。

但这套做法有两个必须验证的点。一是LLM 的校准性:让它在同一批样本上多次输出,看 effect_std 是否与实际的预测误差量级匹配。如果它给的 std 普遍偏小,说明模型过度自信,需要加一个温度系数放大。二是方向一致性:结构化的 direction 和 effect_mean 的符号必须一致,不一致的样本应该被丢弃或者降权,否则先验本身就是矛盾的。

4. 两条链路的工程缝合:从原始数据到可运行的联合模型

4.1 时间对齐:所有泄漏都从这一步开始

文本和数值的联合预测,出问题最多的地方不是模型,是数据管线。具体说是时间戳的语义。

数值侧的时间戳通常是"统计周期",比如 2025-W12 表示第 12 周的数据。文本侧的时间戳是"发布时间",精确到秒。这两者对齐时,最容易犯的错误是把整个周期内发布的文本都算进这一周期的特征。

问题在于:第 12 周的销量数据,是在第 12 周结束之后才能统计出来的;而第 12 周周五发布的资讯,只可能影响第 13 周及以后的销量。把周五的资讯用于预测第 12 周的销量,就是标准的未来信息泄漏。

正确的做法是按决策时点构造特征。假设每周一早上做下周的预测,那么可以用的文本是截至当周周一零点之前发布的所有内容。这个约束要在数据管道里硬编码,不能靠人工注意。

def build_weekly_features(texts, cutoff, window_days=14): """只使用 cutoff 之前发布的文本,取最近 window_days 天""" start = cutoff - pd.Timedelta(days=window_days) mask = (texts["publish_time"] >= start) & (texts["publish_time"] < cutoff) selected = texts.loc[mask] if selected.empty: return np.zeros(EMB_DIM), 0.0 # 按时间衰减加权,越近的权重越高 age = (cutoff - selected["publish_time"]).dt.total_seconds() / 86400 weights = np.exp(-age / 7.0) emb = np.average(selected["embedding"].tolist(), axis=0, weights=weights) return emb, float(weights.sum())

这里做了两件事:一是严格按 cutoff 截断,二是按时间衰减加权。第二点很重要——两周前的资讯和昨天的资讯,对预测的含义完全不同,简单平均会把时效性抹平。衰减系数 7 天是我常用的默认值,实际项目中应该按互相关函数的衰减速度来定。

还有一个更隐蔽的泄漏:实体消歧。如果文本里提到了具体商品,而商品名称在后来被修改过,那么用当前的商品词表去匹配历史文本,会引入信息泄漏。处理办法是在每个时间点保存一份当时的词表快照,或者干脆只用不随时间变化的通用标识。

4.2 一份能跑起来的模型骨架

下面给一个用 NumPyro 写的最小可用骨架,线性高斯状态空间加文本外生回归,完整可跑。

import jax.numpy as jnp import numpyro import numpyro.distributions as dist from numpyro.infer import MCMC, NUTS def joint_model(y, Z, horizon=4): """ y: 观测序列,形状 (T,) Z: 文本特征,形状 (T, d),已做时间对齐 """ T, d = Z.shape # 观测噪声:半柯西先验,避免过窄 sigma_obs = numpyro.sample("sigma_obs", dist.HalfCauchy(1.0)) # 水平与斜率的演化方差 sigma_level = numpyro.sample("sigma_level", dist.HalfNormal(0.3)) sigma_slope = numpyro.sample("sigma_slope", dist.HalfNormal(0.05)) # 文本回归系数:horseshoe 式的全局-局部收缩 tau = numpyro.sample("tau", dist.HalfCauchy(0.1)) lam = numpyro.sample("lam", dist.HalfCauchy(jnp.ones(d))) beta = numpyro.sample("beta", dist.Normal(0.0, tau * lam)) # 初始状态 level0 = numpyro.sample("level0", dist.Normal(y[0], 1.0)) slope0 = numpyro.sample("slope0", dist.Normal(0.0, 0.1)) level, slope = level0, slope0 preds = [] for t in range(T): # 状态转移 level = numpyro.sample(f"level_{t}", dist.Normal(level + slope, sigma_level)) slope = numpyro.sample(f"slope_{t}", dist.Normal(slope, sigma_slope)) mu = level + jnp.dot(Z[t], beta) numpyro.sample(f"y_{t}", dist.Normal(mu, sigma_obs), obs=y[t]) preds.append(mu) # 外推:文本特征用最近 k 期的均值填充,并显式放大不确定性 for h in range(horizon): level = numpyro.sample(f"level_f{h}", dist.Normal(level + slope, sigma_level)) slope = numpyro.sample(f"slope_f{h}", dist.Normal(slope, sigma_slope)) z_future = jnp.mean(Z[-4:], axis=0) mu = level + jnp.dot(z_future, beta) numpyro.sample(f"y_f{h}", dist.Normal(mu, sigma_obs))

几个设计上的取舍值得说一下。

为什么用 horseshoe 而不是普通的正态先验?因为文本特征的有效维度通常很低,而 horseshoe 的局部收缩参数会让无关维度的系数被压到接近零,同时保留少数强信号的维度不被过度压缩。实测下来,在 48 维输入里通常只有 4 到 6 维的系数显著非零。

外推时文本特征怎么处理?上面的代码用最近 4 期的均值,这是一个偏保守的选择。更严谨的做法是对未来文本做单独的预测,但那会引入额外的误差源。我的经验是,短期预测(1 到 4 期)用近几期均值足够,再远就干脆把 $\beta$ 项置零,避免用过期文本误导长期趋势。

为什么把 level 和 slope 逐期 sample?这是为了让后验能捕捉状态轨迹的不确定性。如果只 sample 参数而把状态当确定量,区间会偏窄。代价是采样维度随 $T$ 线性增长,序列长了会很慢,这时候要考虑变分推断或者粒子滤波。

4.3 推断后端的选择:NUTS、ADVI、粒子滤波怎么挑

推断方式的选择直接决定了这套东西能不能上线。

方式速度后验质量适用条件主要风险
NUTS (MCMC)慢,T=200 时几分钟到几十分钟高序列短、模型小、离线场景维度高时混合差
ADVI (变分)快,秒级中,方差常被低估需要频繁重训区间偏窄
粒子滤波中高(非线性时)状态非线性、在线更新粒子退化

序列长度在 200 以内、维度在 60 以下,我会优先用 NUTS,因为它是唯一能让你信任区间的东西。一旦序列超过 500 期,或者需要每天重训,就必须转 ADVI。

ADVI 的问题在于它假设后验是因子化的正态分布,忽略了参数之间的相关性,结果是后验方差系统性偏小。有个简单的补救办法:跑完 ADVI 之后,在验证集上计算实际残差的标准差,与模型给出的预测标准差做比值,得到一个校准系数,直接乘到预测标准差上。这个操作很土,但有效,能把覆盖率从六成多拉回到九成上下。

粒子滤波适合的场景是状态转移非线性,比如需求对文本的响应存在饱和效应(文本影响强度到一定程度后不再增加)。这时候线性高斯假设不成立,NUTS 也会因为地形复杂而采样困难。实现上建议用 bootstrap 粒子滤波配 2000 个粒子起步,然后监控有效样本数 ESS,低于阈值就重采样。

5. 联调阶段最费时间的五个问题

5.1 区间覆盖率不达标:变分后验太窄

这是最常见的症状。名义 90% 的区间,实际覆盖率只有 65% 到 75%。

排查顺序我通常是这样走的:

第一步,先看是不是只算了观测噪声。很多实现里 posterior predictive 只 sample 了 $\sigma_{obs}$,没有把参数后验的不确定性传播进去。检查方法是把训练集的后验预测区间和实际残差对比,如果训练集上覆盖率就不达标,基本可以确诊。

第二步,检查先验是不是给太紧了。半柯西的尺度参数如果设成 0.1 而实际噪声是 1.0,后验会被先验拽住。把尺度放大十倍再跑,看覆盖率是否改善。

第三步,看文本特征的共线性。如果 $Z$ 里有两列相关系数超过 0.95,后验会在两个方向之间来回跳,方差被高估,但预测分布的尾部形状会变形。用方差膨胀因子筛一遍,超过 10 的直接去掉。

第四步,检查时间上是否存在结构突变。如果某几周业务发生了断崖式变化,而模型是全局参数的,那么这段时间的残差会特别大,把整体覆盖率拉低。处理办法是引入变点,或者至少在这几周加一个哑变量。

5.2 文本分支过拟合的典型信号

判断标准很直接:训练集上文本系数的后验明显偏离零,验证集上加上文本反而不如不加。

出现这个情况,通常是三个原因之一。

样本量相对特征维度太小。100 个时间步配 48 维特征,比例已经很不健康了,即使有收缩先验也救不回来。这时候必须降维,降到 8 到 16 维。

文本特征本身在训练期和验证期分布不一致。比如训练期用的 embedding 模型是某个版本,验证期换了新版本,向量的空间结构完全变了,系数自然失效。解决办法是固定 embedding 版本,或者做一次对齐变换。

衰减权重设得太激进。如果时间衰减系数设得很小(比如 1 天),大部分时间步的加权平均实际上只依赖最近一两条文本,特征会非常抖。把衰减周期调到 7 到 14 天通常能缓解。

5.3 冷启动与品类漂移

新品类没有历史文本,但有历史数值——这种情况在电商里太常见了。直接套用全局模型的文本系数,往往效果很差,因为不同品类的文本响应强度差别很大。

一个可行的做法是分层先验:全局给一个文本系数的分布,每个品类在此基础上有一个偏移量,偏移量的先验方差控制收缩强度。品类样本多的时候,偏移量被数据拉动;样本少的时候,退回全局值。这本质上是一种部分池化,实现上在 NumPyro 里就是多一层 sample。

品类漂移是另一个问题。文本的语义会随时间变——同样一句"这个产品很顶",两年前是褒义,现在可能带点调侃。embedding 模型不更新的话,这种漂移会累积。监控办法是定期计算文本向量分布的 MMD(最大均值差异),超过阈值就触发重新训练编码器或者重新拟合系数。

5.4 训练不稳定的排查顺序

NUTS 报 divergences 的时候,别急着调 target_accept_prob。按这个顺序查:

  1. 参数尺度是否差异过大。$\sigma_{obs}$ 可能是 10 的量级,$\sigma_{slope}$ 是 0.01 的量级,同一个采样器在差距 1000 倍的地形上很难走好。做非中心化参数化,或者手动标准化。
  2. 先验是否与数据严重冲突。如果先验说系数应该在 0 附近,数据说应该是 5,后验会被撕成两半,出现双峰。看 trace plot 最直接。
  3. 状态维度是否太高。逐期 sample 状态的话,$T=500$ 就是 1000 个隐变量,NUTS 基本走不动。改用前向滤波后向采样,或者直接上变分。
  4. 数值稳定性。$\log$ 变换后的量再做正态假设,比原尺度上做强得多,尤其是计数类和金额类的数据。

6. 怎么证明这套联合预测是真有用而不是自我感动

6.1 消融实验的最小集合

一个完整的消融至少要跑四组:

  • 基线:纯数值的结构时间序列,无文本
  • 加文本向量:文本作为外生回归项,无 LLM 先验
  • 加 LLM 先验:文本系数带 LLM 生成的先验,但不用向量
  • 全量:文本向量 + LLM 先验

这四组能拆出两个独立的贡献:文本信息本身的价值,以及 LLM 先验带来的增量。我见过不少项目只跑基线和全量,看到提升就归功于架构,实际上可能全部收益都来自文本向量,LLM 那一层纯属增加复杂度。

消融的时候记得固定随机种子,并且至少跑 5 个不同的种子取均值和标准差。贝叶斯模型的种子间波动有时候比预期的大,单次结果不可信。

6.2 概率预测的评估:别只看 RMSE

点预测指标对这套架构不公平。文本带来的最大价值往往体现在"该宽的时候宽",而不是"均值更准"。

必看的三个指标:

指标含义合格线参考
CRPS连续排位概率分,同时考虑精度和锐度相对基线下降 5% 以上
PIT 直方图概率积分变换的分布是否均匀各 bin 偏离均匀不超过 20%
区间覆盖率名义 90% 区间的实际覆盖落在 86% 到 93% 之间

CRPS 是最推荐的单一指标,它对整个预测分布做评分,不像 RMSE 只看均值。PIT 直方图则能暴露系统性偏差——如果直方图呈 U 形,说明区间太窄;呈倒 U 形,说明太宽;单边堆积,说明有偏。

还有个容易被忽略的:分层评估。把样本按文本信号强度分成高、中、低三组,分别算 CRPS。真正有效的模型,应该在高信号组的提升明显大于低信号组。如果三组提升差不多,那说明模型并没有真正利用文本,可能只是多拟合了一些噪声。

6.3 上线之后看什么

模型上线不等于结束。我通常会挂三个监控:

覆盖率滚动监控。按 30 天窗口算实际覆盖率,一旦连续两个窗口低于 85%,触发告警。这是最早能发现分布漂移的信号。

文本分支的贡献度。每次预测时记录文本项 $\beta^\top z_t$ 的绝对值,与整体预测值做比值。如果这个比值长期在 5% 以下,说明文本分支实际上没起作用,可以考虑关掉省算力;如果突然飙到 50% 以上,要检查是不是某条文本的 embedding 异常。

输入特征的分布漂移。对文本向量的每一维做 KS 检验,对比当前窗口和训练集。漂移明显的维度要单独标记,因为它们的系数很可能已经失效了。

最后分享一个我在实际项目里养成的小习惯:每次模型更新后,挑十条对预测影响最大的文本,人工看一遍。这件事花不了十分钟,但能发现很多指标看不出来的问题——比如某条文本其实是重复抓取的、某个事件的日期标注错了、或者某条资讯的语义被 embedding 模型理解反了。指标只能告诉你"变差了",人工看一眼往往能直接告诉你"为什么变差"。这套 LLM 加状态空间的组合,本质上是在用两种完全不同的信息源互相校正,只要有一边的输入是脏的,另一边的努力就白费了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进