google/timesfm-3.0-pytorch · 推理实现逐行核对 · 本地权重实测

TimesFM 3.0 架构导览

沿一次 decode 调用的真实数据流,从三条原始数组一路走到 (b, u, H, 9) 的分位数:每一步的张量形状、掩码规则、归一化统计量、注意力的数学细节、窗口选择与缝合、CPM 统计量精修。所有结构和常数都对照过 src/timesfm3/torch/;所有数据图都是用本地 3.0 权重在同一个示例上实际跑出来的中间张量,不是示意。

参数
330.7M(实测)
主干
20层 × 3 子层
宽度
128016 头 × 80
token
32→64步 · 192 维输入
输出
9分位数 q10–q90
上下文
15360步上限
解码
1次前向,非自回归
§0 · 总览

一屏看懂:一次前向里发生的全部事情

TimesFM 3.0 把输入组织成一张 变量 × 时间 的 token 网格,用 20 层「时间轴注意力 + 变量轴注意力 + FFN」处理它,再让每个 token 写出紧随其后的 64 步、9 个分位数。预测段是接在尾部的占位 token,所以整段 horizon 只需要一次前向。

下面这条流水线是全页的主线。右栏是通用形状和本页运行示例里的实际数字(b=1,3 个变量,上下文 160,预测 96):

  1. 收集输入目标、过去协变量、未来协变量在变量轴上叠成一摞
    (b, v, C)(1, 3, 160)
  2. 左补齐 + 条件去趋势C 补到 32 的倍数;每行独立拟合直线,只有在能把 std 压到一半以下时才减掉
    (b, v, C)本例三行都不去趋势
  3. 接上 horizon 占位段目标行与过去协变量行填 0、掩码 = True;未来协变量行填真实未来值
    (b, v, C + H′)(1, 3, 256)
  4. 切 patch每 32 步一个 token 位置;n = C/32 + H′/32
    (b, v, n, 32)(1, 3, 8, 32)
  5. 因果累积统计量第 i 个位置的 μ, σ 只用 patch 0…i 的有效点
    μ, σ : (b, v, n)(1, 3, 8)
  6. 组装 token[自身 32 值 | 未来 64 值 | 自身 32 掩码 | 未来 64 掩码],全部按该位置的 μ, σ 归一化
    (b, v, n, 192)(1, 3, 8, 192)
  7. 残差块升维192 → 1280,ReLU,带线性捷径,无 bias
    (b, v, n, 1280)
  8. 20 × MixingTransformer时间轴注意力(因果 + RoPE)→ 变量轴注意力(双向、无位置)→ FFN;三明治 RMSNorm
    (b, v, n, 1280)形状不变
  9. 输出头Linear 1280 → 64 × 9,每个 token 都写一个 64 步窗口
    (b, v, n, 64, 9)(1, 3, 8, 64, 9)
  10. CPM 精修 + 反归一化horizon 位置的 μ, σ 用模型自己的中位数预测更新,再 y·σ + μ
    (b, v, n, 64, 9)
  11. 选 token + 缝合取最后一个上下文 token 起的 n_f 个窗口,步长 32、重叠 32,线性交叉淡化
    (b, v, H, 9)读 #4, #5 → (1, 3, 96, 9)
  12. 加回趋势,丢掉协变量行,排序分位数中位数(索引 4)即点预测
    (b, u, H, 9)(1, 1, 96, 9)

同一件事写成伪代码(名字都是源码里的真实符号,省略了 dtype 与边界处理):

# ---- TimesFM3Torch.decode ----
x, m     = concat_v(target, past_only, past_future[:, :, :C])    # (b, v, C),左补到 32 的倍数
x, trend = conditional_detrend(x, m)                               # 每行独立,std_det < 0.5·std 才生效
x, m     = concat_t(x, horizon_block)                              # (b, v, C+H′)
P, M     = x.reshape(b, v, n, 32), m.reshape(b, v, n, 32)
# ---- TimesFM3Torch.forward ----
μ, σ     = get_running_stats(P, M)                                 # (b, v, n) 因果累积
own      = revin(P, μ, σ).masked(M)                                # (b, v, n, 32)
fut      = revin(roll(P, +1, +2 patch), μ, σ).masked(M_fut)        # (b, v, n, 64)
h        = ResidualBlock([own, fut, M, M_fut])                     # 192 → 1280
for L in range(20):
    h = h + N(SeqAttn(N(h)))       # 视作 (b·v, n, d):因果、RoPE
    h = h + N(VarAttn(N(h)))       # 视作 (b·n, v, d):双向、无位置编码
    h = h + N(FFN(N(h)))           # 1280 → 1280 → 1280, ReLU
y        = output_head(h)                                          # (b, v, n, 576),归一化空间
μ, σ     = cpm_iterative_revin_refine(y, μ, σ)                     # 只改 horizon 位置
y        = (y·σ + μ).view(b, v, n, 64, 9)
# ---- 回到 decode ----
out      = stitch_patches(y[:, :, n_ctx-1 : n_ctx-1+n_f])[:, :, :H] + trend   # (b, v, H, 9)
return out                                                        # Forecaster 再切 [:, :u]、排序分位数

五个值得先记住的设计

二维 token 网格

隐藏状态是 (v, n) 网格。每层先让每个变量沿时间看自己的历史,再让同一时刻的所有变量互相看一眼。

token 自带「未来槽」

每个 token 的输入里预留了它要预测的那 64 步的位置。目标行永远留空;未来协变量行填真实值,这是已知未来进入模型的唯一入口。

占位 token,一次写完

horizon 就是尾部几个全掩码的 patch。输出窗口 64 步、步长 32,相邻窗口重叠 32 步做线性缝合。

统计量也讲因果

RevIN 的 μ, σ 逐位置累积计算;horizon 位置再用模型自己的预测递推更新(CPM 精修),这是全模型唯一带递推的一步。

行的角色只靠内容区分

变量轴注意力没有位置编码,模型分辨各行的唯一依据是槽位里有没有值。过去协变量和目标变量喂进去的样子完全一样,它们的「预测」也照样被算出来。

本页的事实来源与标注。结构、常数、公式来自 src/timesfm3/torch/ 的推理实现和 HF 快照里的 config.json;标 实测 的数字来自在本地 RTX 4080 上加载 google/timesfm-3.0-pytorch 权重后的真实中间张量;标 推断 的说法涉及训练,而仓库里没有训练代码,只能从文档字符串和实现细节推断,读的时候请打个折扣。
§1 · 输入(b,u,C) · (b,v_po,C) · (b,w,C+H)

三类变量,以及贯穿全页的例子

参数(decode)形状未来段在模型里的待遇
target(b, u, C)未知行 0 … u−1;未来槽留空;其输出就是返回值
past_only_covariates(b, v_po, C)未知行 u … u+v_po−1;与 target 完全同等处理,输出被丢弃
past_future_covariates(b, w, C+H)已知最后 w 行;未来槽和 horizon 段都填真实值
target_mask / … / mask同上 / (b, C)—True = 无效;全局 mask 会 OR 到每一行
返回值(b, v, H, 9)—含全部 v 行;Forecaster 只保留前 u 行

给了 past_future_covariates 时,horizon 参数会被忽略,直接用 pf.shape[-1] − C。变量总数 v = u + v_po + w。

运行示例:便利店 合成数据

一家便利店,用最近 160 天预测未来 96 天。三条序列:日销量(目标)、到店客流(过去协变量,只有历史)、促销排期(未来协变量,日历早已排好)。数据是用固定种子合成的:客流 = 缓慢上涨的基线 × 周内节律 × 促销期 +25%,加噪声;销量 = 0.42 × 客流 × 促销期 +18%,加噪声。每 6 周一次、每次 7 天的促销,horizon 里还有两次(第 181–187 天、第 223–229 天)。

  • 日销量(目标)
  • 到店客流(过去协变量)
  • 促销日(未来协变量 = 1)
  • horizon 段的真实值(模型看不到)
图 1 · 示例输入。两个量纲不同的序列分成上下两张图,不共用 y 轴。竖线右边是要预测的 96 天:销量和客流在那里对模型不可见(虚线只是答案),促销排期则整段可见。悬停可读逐日数值。

Forecaster 在调 decode 之前做的事

TimesFM3Forecaster.predict_batch 接收的是 numpy 数组列表,它先做这些处理,再把张量交给模型:

  • 掐掉开头的全 NaN 列(按目标变量判断,协变量同步裁剪)。
  • 内部 NaN 线性插值,整行全 NaN 则填均值。注意:NaN 被插值,而不是被掩码。经过 Forecaster 的调用里,掩码只来自左补齐和 horizon。
  • 对齐 batch 的上下文长度:取该 batch 最长序列,向上取整到 32 的倍数,封顶 15360。短的左侧补 0 并把掩码置 True,长的只保留最近的部分。
  • horizon 取整:先算 ceil(H/64)·64 传给 decode,最后再截回 H。但只要给了未来协变量,decode 就改用协变量的实际宽度(默认 padding_mode="none" 时就是 H)。
  • 可选:use_znorm(逐行 z-score,预测后还原)、use_symmetric_averaging(同时推理 x 与 −x,见 §10)。
§2 · 网格(b,v,C) → (b,v,C+H′) → (b,v,n,32)

从三条数组到一张 patch 网格

2.1 horizon 要接几个占位 patch

默认 use_stitching=True。每个被读取的 token 贡献 extract_len = min(2p, o) = 64 步,相邻两个被读取的 token 在时间上错开 p = 32 步,因此重叠 overlap = 32。要盖满 H 步,需要:

n_f = max( ceil((H − overlap) / p), 1 ) # 读几个 token 的输出 n_hor = n_f + r − 1 # 接几个占位 patch,r = o/p = 2 H′ = n_hor · p # 占位段总长 n = C/p + n_hor # 网格的时间长度
场景H (进 decode)n_fn_horn读取的 token
本例:有未来协变量,H=9696238#4, #5
同样 H=96,但不带协变量(Forecaster 先取整到 128)128349#4, #5, #6
H=646412C/32+2只读最后一个上下文 token

多留的 r − 1 = 1 个 patch 不是浪费:最后一个被读取的 token 的「未来槽」要从后面 2 个 patch 卷过来,少一个就会被 wrap_mask(§4.3)削掉一半,未来协变量的尾巴就丢了。

2.2 拼接与掩码

  1. 左补齐:C 不是 32 的倍数时,在左边补 0,补出来的位置掩码为 True。
  2. 上下文段:三类变量沿变量轴拼成 (b, v, C),全局 mask OR 进每一行;去趋势(§3.1)之后,把掩码位置的值强制置 0。
  3. horizon 段:目标行和过去协变量行是 zeros,掩码全 True;未来协变量行是真实未来值(减去该行自己的趋势线,若有),不足 H′ 的部分补 0 并掩码。
  4. 时间轴拼接得到 (b, v, C+H′),reshape 成 (b, v, n, 32)。同时构造两个辅助张量:
patch_is_target : (b, v, n) bool 前 u + v_po 行全为 True # 「这一行的未来在推理时拿不到」 horizon_cpm_mask : (b, n) bool 位置 ≥ n_ctx 为 True # 「这些位置是被遮住的目标段」

本例 n_ctx = 5、n = 8:位置 #0–#4 是上下文(第 1–160 天),#5–#7 是占位(第 161–256 天)。这张 3 × 8 的网格就是模型的全部输入,§4.4 会把它的 24 个 token 逐格摊开。

§3 · 数值μ, σ : (b,v,n)

去趋势与因果 RevIN:把量纲和趋势剥掉

3.1 条件线性去趋势(在 patch 之前,逐行)

时间坐标归一化到 t = (−(C−1), …, 0) / C(C 为补齐后的长度),只用未掩码点做最小二乘:

m, c = argmin Σ_valid (y_t − m·t − c)² y_det = y − (m·t + c) apply = std(y_det) < 0.5 · std(y) # linear_detrending_threshold = 0.5,逐 (batch, 变量) 判断 horizon 上 : 预测 += m·(1…H)/C + c # 仅对 apply 的行 未来协变量 : 未来段 −= m·(1…H)/C + c # 与上下文段保持同一坐标系

门槛很高:只有「趋势解释了 75% 以上方差」的行才会去趋势。实测 本例三行都没过门槛:销量 std 从 27.9 只降到 26.3(比值 0.94),客流 0.90,促销 1.00。作为对照,一条 40 + 0.9t + 周期 + 噪声 的序列,std 从 41.6 降到 4.6(比值 0.11),会被去趋势。趋势线在整个上下文上拟合,所以这一步不是因果的;但它对每行只减一条直线,并在输出端原样加回。

3.2 逐位置累积的统计量

RevIN 的 μ, σ 不是整段一个,而是每个 (变量, 位置) 一组:第 i 个位置用 patch 0…i 中所有未掩码点。实现是沿 n 的扫描,用并行方差合并公式把新 patch 并进累积量(util.update_running_stats):

n' = n + n_p μ' = (n·μ + n_p·μ_p) / n' σ'² = [ n·σ² + n_p·σ_p² + n·(μ − μ')² + n_p·(μ_p − μ')² ] / n' revin(x) = (x − μ) / σ # σ < 1e-6 时分母取 1

同一个 token 的自身值和未来窗口都用该位置自己的 μᵢ, σᵢ 归一化;输出头写出的 64 步,也用同一组(或 §9 精修后的一组)反归一化。

日销量行#0#1#2#3#4#5#6#7

实测 上下文里 μ 随累积逐步变化;进入 horizon 后,因为目标行没有任何有效点,μ, σ 自动停在最后一个上下文位置的值(133.6 / 27.9)。所以 use_frozen_running_stats(默认关)只对未来协变量行有实际影响:只有这些行在 horizon 里还有真实数据会继续被累积。

为什么逐位置累积

每个上下文 token 同时也是一个「给定前缀、预测后 64 步」的样本推断。如果用全段统计量归一化,早期 token 的数值里就混进了它本不该知道的后续信息。累积统计量让归一化本身也满足因果性,和时间轴注意力的因果掩码是同一个原则。

另外两个数值保护:进网络前 nan_to_num 并夹到 ±value_clip = 1e20,反归一化后再夹一次。input_transform(signed_log / signed_sqrt)在 transformations.py 里有注册,但 torch 版的 forward/decode 根本没有调用它,这个配置项目前不起作用。

§4 · Token(b,v,n,32) → (b,v,n,192)

一个 token 是 192 个数

每个 (变量, 位置) 的输入向量由四段在特征维上拼成:自身 32 个值、紧随其后的 64 个值、这两段各自的掩码。token 数 n 不变。

维度内容目标 / 过去协变量行未来协变量行
0–31自身值:patch j 的 32 个点,按 μⱼ, σⱼ 归一化,掩码处为 0上下文:实值;horizon:0实值(掩码处为 0)
32–95未来窗口:patch j+1、j+2 的 64 个点,同样按 μⱼ, σⱼ 归一化永远为 0实值(尾部被 wrap 削掉的除外)
96–127自身掩码,1.0 = 无效上下文 0,horizon 10
128–191未来窗口掩码永远为 10(wrap 处为 1)
# model.py · _preprocess(省略 CPM 那一行,见 4.2)
values_bvnp       = torch.where(masks, 0.0, revin(values, μ, σ))              # (b,v,n,32)
values_fcov, wrap = get_output_patch_via_roll(values, rolls=2)                 # (b,v,n,64)
values_fcov       = revin(values_fcov, μ, σ)                                   # 用的是位置 j 的统计量
masks_fcov        = roll(masks) | patch_is_target[..., None] | wrap
values_fcov       = torch.where(masks_fcov, 0.0, values_fcov)
resblock_input    = cat([values_bvnp, values_fcov, masks, masks_fcov], dim=-1)  # (b,v,n,192)

4.1 未来窗口是怎么「卷」出来的

get_output_patch_via_roll 沿 n 做两次 torch.roll(shifts=-1),把 patch j+1 和 j+2 接起来放到位置 j。它的文档字符串写的是 “Creates labels of output_patch length”:同一个函数既产出输入里的未来槽,也产出推断训练时的监督标签。所以未来槽和该 token 的输出窗口严格对齐同一段时间。

时间轴(以日销量 token #4 为例) 第 129–160 天 第 161–224 天(要预测的 64 天) 与 token #4 无关 192 维输入 自身值 32 未来窗口 64(目标行:留空) 掩码 32 = 0 掩码 64 = 1 03296128192 输出 64 步 × 9 分位数 输出窗口与未来槽同址:都是第 161–224 天
图 2 · 槽位与输出窗口对齐。对目标行,未来槽永远是空的,模型要把它「填」出来;对未来协变量行,同一个槽里装的是真实的未来促销。两者都指向第 161–224 天。

4.2 掩码由四项 OR 而成

来源作用范围含义
masks(原始)自身值 + 卷入未来槽左补齐、用户提供的缺失;horizon 里目标 / 过去协变量行全为 True
patch_cpm_mask ∧ patch_is_target自身值被遮住的目标段。推理时恰好就是 horizon,和上一项重合,不产生新效果
patch_is_target未来槽整行一刀切:目标行与过去协变量行的未来槽在所有位置上都为空
wrap_mask未来槽roll 是循环移位,最后几个位置会卷回序列开头;source_patch = j + 1 + k//32 ≥ n 的点作废

为什么上下文 token 的未来槽也要清空(例如 token #1 的未来是第 65–128 天,明明是已知的历史):

  1. 那段正是该 token 的预测目标推断。答案若摆进输入,模型只需要学会复制。
  2. 信息没有丢:第 65–128 天在 token #2、#3 的自身值里,因果时间注意力让后面的 token 都读得到。
  3. 只有这样,占位 token 才和上下文 token 格式同构:两者的未来槽都为空,区别只在自身值有没有。

patch_is_target 这个名字有误导性:过去协变量行也被置为 True。它真正的语义是这一行的未来在推理时拿不到。于是在模型眼里,过去协变量就是一个「只是不需要你返回的目标」。

4.3 注意力用的 patch 掩码

若一个 token 的 96 个掩码位(自身 32 + 未来 64,即 masks_cat)全为 True,它就是「全掩码 token」。horizon 里的目标 token 正是如此。但 forward 在送进主干前做了:

effective_patch_mask = torch.cumprod(patch_mask.int(), dim=2).bool()   # 只保留「开头连续」的全掩码

所以只有左补齐造成的前导全掩码 token会作为 key 被屏蔽。跟在有效上下文之后的占位 token 虽然也是全掩码,却留在注意力里,要作为 query 去读历史,也要作为 key 被同一时刻的其它变量读到。

4.4 本例 24 个 token 全摊开

图 3 · 示例的 token 网格。每格上半是自身值,下半是未来槽。几条规则在图里同时可见:目标行和客流行的未来槽全空;占位位置 #5–#7 的自身值只有促销行有;促销行的未来槽只在尾部被 wrap_mask 削掉,而被读取的 #4、#5 恰好不受影响。

4.5 真实的 192 维输入长什么样

下图是 resblock_input 里的三个真实向量 实测,横轴是 192 个特征维,颜色是归一化后的值:

图 4 · 三个 token 的输入向量。日销量 #4:自身值有内容(第 146–152 天促销拉高到 +3.7σ),未来槽全 0、掩码全 1。日销量 #6(占位):前 96 维全 0、后 96 维全 1。所有占位目标 token 的输入逐位相同,它们只能靠 RoPE 位置和注意力区分彼此。促销 #4:自身值与未来槽都是真实排期(μ₄ = 0.175,σ₄ = 0.38,所以 0 → −0.46,1 → +2.17),第 181–187 天的促销就在未来槽里。
§5 · 升维(b,v,n,192) → (b,v,n,1280)

残差块:192 → 1280

h = W_out · ReLU(W_hid · x) + W_res · x W_hid : 192 → 1280 W_out : 1280 → 1280 W_res : 192 → 1280(线性捷径) 无 bias,无 pre-norm,dropout = 0 参数 2,129,920

它逐 token 独立作用,是模型里唯一把「时间序列数值」变成「隐藏向量」的地方。没有额外的位置嵌入,也没有变量 ID 嵌入:时间位置只在注意力里通过 RoPE 注入,变量身份则完全不注入。

实测 本例升维后每个 token 的 RMS 约 3.7–4.6。占位目标 token 的输入逐位相同,升维后也完全相同。

§6 · 主干(b,v,n,1280) × 20 层

一层 MixingTransformer:先看时间,再看变量

20 层结构相同。每层三个子层,每个子层都是三明治 RMSNorm:前置归一化、子层、后置归一化,再加残差。

h₁ = x + RMSNorm_post( SeqAttn( RMSNorm_pre(x) ) ) # reshape (b·v, n, d) h₂ = h₁ + RMSNorm_post( VarAttn( RMSNorm_pre(h₁) ) ) # permute → (b·n, v, d) y = h₂ + RMSNorm_post( W₂·ReLU(W₁·RMSNorm_pre(h₂)) ) # W₁, W₂ : 1280 → 1280

最后一层之后没有 final norm,残差流直接进输出头。

x : (b, v, n, 1280) RMSNorm(前) 时间轴注意力 · 因果 (b·v, n, d) · RoPE · QK-norm RMSNorm(后) + RMSNorm(前) 变量轴注意力 · 双向 (b·n, v, d) · 无位置编码 RMSNorm(后) + RMSNorm(前) FFN · ReLU · 不扩张 1280 → 1280 → 1280 RMSNorm(后) + × 20
时间轴:每一行是一条序列 b·v 条独立序列,长度 n,因果 销量客流促销 n(时间)→ #5 只能看 #0…#5 变量轴:每一列是一组 b·n 个集合,大小 v,双向、无位置 销量客流促销 同一时刻 n 的 v 个 token 互看 · 时间轴:v 个变量共享同一套权重 · 变量轴:n 个位置共享同一套权重 · 变量轴没有 RoPE → 对行的排列等变 · 两者都屏蔽前导补齐 token 作为 key
图 5 · 层结构与两种切法。左:一个层的三个子层,虚线是残差通路。右:同一张 (v, n) 网格被两个注意力沿不同方向切开。代价分别是 O(b·v·n²·d) 与 O(b·n·v²·d)。

6.1 注意力内部:按代码顺序

q, k, v = W_q·x, W_k·x, W_v·x # 各 1280→1280,无 bias,拆成 16 头 × 80 q, k = RoPE(q, pos), RoPE(k, pos) # 仅时间轴;pos = 0…n−1;80 维前后两半配对旋转,timescale 1…10⁴ q, k = RMSNorm₈₀(q), RMSNorm₈₀(k) # QK-norm,逐头、带可学习增益 q = q · (1.4427 / √80) · softplus(s) # PerDimScale,s ∈ ℝ⁸⁰ 可学习 logits = (q · kᵀ) · √80 # SDPA(scale=√d):use_memory_efficient_attention=True = Σ_d q_d k_d · 1.4427·softplus(s_d) # √80 恰好抵消 out = W_o · softmax(logits + mask) · v # v 不做归一化
容易误读的一点

一个容易得出的结论是「PerDimScale 在初值时等价于标准的 1/√d 缩放」,但并不成立。按发布配置(use_memory_efficient_attention: true),代码在 SDPA 里用 scale = √d,恰好抵消 PerDimScale 里的 1/√d。最终 logit 是 QK-RMSNorm 后的内积乘以逐维温度 1.4427·softplus(s),没有 1/√d。由于 q、k 都被 RMS 归一化到每维约 1,内积上限约为 80 × 增益²,相当于一个温度可学的余弦相似度。实测 各层逐维温度的均值在 0.64–0.98 之间,单维范围约 0.22–1.24。

6.2 两种注意力的掩码

子层视作因果位置编码被屏蔽的 key
时间轴 seq_attn(b·v, n, 1280)是RoPE(use_rope_seq)前导补齐 token
变量轴 var_attn(b·n, v, 1280)否无(发布配置 use_rope_var: false)该位置上是前导补齐的变量
FFN逐 token———

组合起来:第 L 层的 (v, n) 只依赖输入中 n′ ≤ n 的 token(任意变量),因为变量轴只在同一 n 内混合,时间轴只向左看。任何比 n 更晚的信息,只能通过未来协变量行的「未来槽」进来。

6.3 信息怎样流进一个占位 token

#0#1#2#3#4#5#6#7 日销量客流促销 时间轴:读自己行 #0…#5 变量轴:同一时刻 #5 161–192193–256 促销 #5 的自身值 + 未来槽:第 161–256 天的真实排期 日销量 #5 自己的输入全空,它的全部信息来自:本行历史(时间轴)、同时刻各行(变量轴)。 这两条路径在 20 层里反复交替,所以历史与已知未来可以在任意层交织。
图 6 · 占位 token 的信息来源。淡色格是这一层看不到、但更早的层已经汇进来的内容:例如促销 #3 的信息先经时间轴进入促销 #5,再经变量轴进入销量 #5。没有任何路径能读到 #6、#7。

6.4 几个「没有」

没有 bias

残差块、Q/K/V/O、FFN 全部 use_bias=False。全模型只有输出头带 bias(576 个)。

FFN 不扩张

hidden_dims = model_dims = 1280,而不是常见的 4×。每层两路注意力 13.1M 参数,FFN 只有 3.3M。

没有变量嵌入

变量轴无位置编码、输入无变量 ID。交换两行,输出就跟着交换,模型对行的顺序是等变的。

没有 KV cache 路径

MultiHeadAttention 支持 decode_cache,但 decode 不使用:一次前向就结束了。

§7 · 实测hook 抓取 20 层注意力权重

主干实测:注意力到底在看哪

在每层 seq_attn / var_attn 上挂 forward-pre-hook 取输入,按 §6.1 的顺序复算注意力权重,16 个头取平均 实测。只有一个样本,读作「这个模型在这个例子上」,不要读成普遍规律。

7.1 时间轴:日销量行,第 1 / 10 / 20 层

图 7 · 时间轴注意力(query 行 → key 列)。上三角为因果屏蔽。第 1 层里,#1–#3 有 46–70% 的权重落在 #0 上(类似 attention sink);占位 token #5 / #6 / #7 分别把 82% / 73% / 66% 的权重投向上下文 #0–#4。越往深层对角线越强:到第 20 层,各 token 有 60–73% 看自己,#5 / #6 / #7 看上下文的份额降到 37% / 24% / 17%。读取历史主要发生在前中层。

7.2 变量轴:跨变量交流发生在前 15 层

  • 日销量
  • 客流
  • 促销
图 8 · 每层每行把多少注意力分给「别的变量」(1 − 自注意力权重,对 8 个位置取平均)。第 1–14 层,日销量行有 43–72% 的变量轴注意力投向客流和促销;第 15 层开始骤降,第 18–20 层几乎为 0,变量轴退化为恒等映射。可以粗略理解为:前 3/4 的层负责融合,后几层各行独立地「解码」自己的预测。
图 9 · token #4(最后一个上下文位置)的 3×3 变量轴注意力。第 1 层,日销量把 38% 的注意力给了促销行;那一行的未来槽里正装着第 161–224 天的排期。
关掉变量轴会怎样?把 20 层的 use_variate_attention 全部置 False 再跑同样的权重,预测值发散到 10⁶ 量级。权重是和变量轴一起训练的,它不是可插拔的附加模块。如果想要「单变量」行为,应该用 TimesFM3Evaluator(univariate=True) 把各行拆成独立样本,而不是拆掉这一子层。
§8 · 输出(b,v,n,1280) → (b,v,n,64,9) → (b,v,H,9)

输出头、选哪些 token、怎么缝起来

8.1 输出头

output_head = nn.Linear(1280, 64 * 9, bias=True)     # 737,856 参数,全模型唯一的 bias
raw = output_head(h)                                   # (b, v, n, 576),仍在「位置 j 的归一化空间」
y   = revin⁻¹(raw, μ*, σ*).clamp(±1e20).view(b, v, n, 64, 9)   # 下标 = t·9 + q:时间在外,分位数在内

它对网格里每一个 token 都写出一个 64 步 × 9 分位数的窗口:本例是 24 个窗口,其中只有 2 个会被用到。没有单独的均值头,点预测就是 q50。

8.2 读哪几个 token

forecast_indices = arange(n_f) + (n_ctx − 1)          # 本例:[4, 5]
patch_preds      = y[:, :, forecast_indices, :64, :]   # (b, v, n_f, 64, 9)

从最后一个上下文 token 开始连续取 n_f 个。#4 的窗口是第 161–224 天,#5 的窗口是第 193–256 天。

8.3 缝合

# util.stitch_patches,overlap = 32
w       = linspace(1, 0, 32)
out[  0: 32] = win₀[0:32]
out[ 32: 64] = w·win₀[32:64] + (1−w)·win₁[0:32]       # 交叉淡化
out[ 64: 96] = win₁[32:64]
# 一般地:长度 n_f·32 + 32,再截到 H
  • token #4 的窗口(中位数)
  • token #5 的窗口
  • 缝合结果
  • 真实值
图 10 · 真实的两个窗口与缝合 实测。阴影是重叠的第 193–224 天:两个窗口在这里的平均差异只有 1.8,缝合只是抹平接缝。对照:不缝合模式(use_stitching=False)每隔 2 个 token 取一个完整的 64 步窗口首尾相接,本例与默认结果的最大差 7.4,MAE 7.53 vs 7.38。
§9 · 反归一化μ*, σ* : (b,v,n)

反归一化与 CPM 精修:唯一带递推的一步

占位位置的 μ, σ 停在上下文末尾(§3.2)。用它们反归一化没问题,但预测段若整体抬升,按照累积统计量的定义,后面位置的 μ 本该跟着变。cpm_iterative_revin_refine 用模型自己的中位数预测把这一点补上。

9.1 算法(沿 n 扫描一遍)

  1. 非 CPM 位置(上下文):原样使用真实统计量;把该 token 反归一化后的 64 步中位数存为锚点。
  2. CPM 位置 i:从锚点里取出对应的 32 步(锚点的第 block_offset 段),当作一个完全有效的新 patch 并入累积量,得到 μᵢ*, σᵢ*。
  3. block_offset 在 0, 1 之间循环。回到 0 时,锚点换成当前位置用 μᵢ*, σᵢ* 反归一化后的预测。
  4. 最后 μ, σ ← where(cpm, μ*, σ*),再做 y·σ + μ。

网络前向不重跑,被递推的只有 (b, v, n) 上的两个标量。

日销量行#4(上下文)#5#6#7
μ 冻结值133.6133.6133.6133.6
μ* 精修后133.6135.9136.6138.0
σ 冻结值27.927.927.927.9
σ* 精修后27.928.327.427.2
并入的估计段—#4 预测的 161–192+ #4 预测的 193–224+ #6 预测的 225–256

实测 μ₅* = 135.9 意味着 #4 对第 161–192 天的中位数均值约为 (135.9 × 192 − 133.6 × 160) / 32 ≈ 147,高于上下文均值:第 181–187 天有促销。本例只读 #4、#5,所以真正起作用的只有 #5 这一格。关掉精修(use_iterative_cpm_revin=False),预测最大变化 3.1,MAE 从 7.38 变为 8.27(单个样本)。

CPM 指什么 推断

代码从未展开这个缩写。可以确定的是它的语义:_preprocess 里的注释是 “mask target variates at CPM positions”,也就是把目标行的若干 patch 故意遮住;精修函数的文档字符串又提到 “all preceding CPM patches in the same block (and all estimates from earlier blocks in the segment)”。这说明训练时被遮住的可能是序列中间的若干连续块,而推理时的 horizon 只是「恰好在末尾的一个块」。这也解释了为什么 horizon 占位 token 与上下文 token 共用同一套格式。

§10 · 返回(b,v,H,9) → (b,u,H,9)

最终输出与后处理

  1. 加回趋势(仅对 §3.1 判定去趋势的行):每个分位数都加 m·(1…H)/C + c。
  2. 丢掉协变量行:out[:, :u]。客流行其实也被「预测」了,只是不返回。
  3. 排序分位数(sort_quantiles=True):沿最后一维 np.sort,消除分位数交叉。
  4. 可选 对称平均:同一 batch 里多推理一份 −x(协变量也取负),合成 (y⁺ − reverse_q(y⁻)) / 2,让预测对取负严格反对称。TimesFM3Evaluator 默认开。
  5. 可选 还原 z-score、截断到 ≥ 0(make_positive:仅当该行上下文全非负;Evaluator 默认开)。
  6. forecast = quantiles[..., 4](q50),quantiles 为 (u, H, 9)。
  • 上下文
  • 中位数预测
  • q10–q90
  • q30–q70
  • 真实值
图 11 · 本例最终输出 实测。两次未来促销(阴影)都被预测出来了。中位数 MAE 7.38;96 天中有 87 天(90.6%)落在 q10–q90 区间内,名义覆盖率是 80%。区间宽度从前 8 天的约 18 增到末 8 天的约 32。
from timesfm3 import TimesFM3Forecaster
fc  = TimesFM3Forecaster.from_pretrained("google/timesfm-3.0-pytorch", device="cuda")
out = fc.predict(sales[:160], horizon=96,
                 past_only_covariates=traffic[None, :160],     # (1, 160)
                 past_future_covariates=promo[None, :256],     # (1, 160+96)
                 return_quantiles=True)
out.forecast.shape, out.quantiles.shape                        # (96,), (96, 9)   一维输入时去掉变量维

实测时直接调 model.decode 与走 Forecaster.predict 的结果(排序后)逐位一致,最大绝对差 0.0。

§11 · 证据

协变量真的在起作用吗

同样的权重、同样的 160 天销量,只改变喂给模型的协变量 实测:

输入中位数 MAE说明
A · 有无促销日历
  • 完整输入
  • 只给销量
  • 真实值
B · 把未来促销挪到第 201–207 天
  • 原排期
  • 挪动后的排期
图 12 · 协变量消融。A:没有促销日历时,模型只能靠历史的周内节律外推,两次促销高峰都漏掉,MAE 从 7.4 升到 23.4。B:保持历史不变,只把未来促销挪到第 201–207 天(深色阴影),预测高峰就跟着挪过去,原来的第 181、223 天附近的高峰消失。未来信息只可能经由未来协变量行的未来槽进入(§6.2),这组对照直接证实了这条通路。

把未来段促销全部置 0(历史保留)MAE 为 17.1,比完全不给促销(23.0)好。这说明历史里的促销也有用:它让模型把历史高峰「解释」成促销,而不是当成周期去外推。

§12 · 参数

330,710,976 个参数花在哪里

加载权重后逐模块 numel() 求和 实测,与按配置手算的结果一致。

模块构成参数
输入残差块192×1280 + 1280×1280 + 192×12802,129,920
每层 · 时间轴注意力4×1280² + 80 + 80 + 806,553,840
每层 · 变量轴注意力4×1280² + 80 + 80 + 806,553,840
每层 · FFN2×1280²3,276,800
每层 · 6 个 RMSNorm6×12807,680
每层小计 × 2016,392,160 × 20327,843,200
输出头1280×576 + 576737,856
合计330,710,976

注意力每路那三个 80 是 Q 的 RMSNorm 增益、K 的 RMSNorm 增益和 PerDimScale,合计 240 个数,决定了注意力 logit 的尺度。去掉 20 路变量轴注意力(131.1M),剩下约 199.6M,恰好是 2.5 那一档「200M」的规模。

§13 · 对比

与 TimesFM 2.5 的差异

下表两列都取自本仓库的代码:2.5 见 src/timesfm/timesfm_2p5/,3.0 见 src/timesfm3/torch/ 与 HF 配置。

2.5 (200M)3.0
主干20 层,1280 维,16 头同上,每层多一路变量轴注意力
变量单变量;协变量靠外挂 XReg 线性回归原生多变量;协变量就是网格里的行
token 输入64 = 32 值 + 32 掩码192 = 32 + 64 值 + 32 + 64 掩码
tokenizer残差块,swish,带 bias残差块,ReLU,无 bias
FFN 激活swishReLU
输出 patch128 步 × 10(均值 + 9 分位);另有 1024 步的连续分位数头64 步 × 9 分位;单个 Linear
长 horizon自回归:写一段、喂回、KV cache 续写尾部占位 token,一次前向 + 缝合
上下文上限1638415360
归一化逐 patch 累积 RevIN同上 + 条件去趋势 + CPM 精修
§14 · 踩坑

容易踩的坑

过去协变量 ≈ 不返回的目标

它和目标行的输入格式完全相同。模型不知道哪一行是你「真正关心」的,只有排在前 u 行的会被返回。

NaN 被插值,不是被掩码

Forecaster 会先对内部缺失做线性插值,模型看到的是插值出来的点。若要让模型「知道」缺失,只能直接调 decode 并传掩码。

horizon 会被取整

不带未来协变量时,H 先取整到 64 的倍数。前 H 步的因果依赖不变,但缝合方式会随 n_f 改变,所以最后 32 步可能和「给了协变量、H 不取整」时略有差别。

input_transform 是空配置

配置里有、注册表里有,但 torch 版的 forward/decode 从不调用。设成 signed_log 不会改变任何输出。

本地文件加载时 RoPE 配置不同

从本地 .safetensors 文件构建模型时,_make_torch_model 默认 use_rope_var=True,而发布的 config.json 是 false。RoPE 没有参数,权重能正常加载,但变量轴的行为会变。用目录或 HF repo ID 加载就没有这个问题。

32 路变量的上限在 Evaluator

max_variates=32 只是配置字段,模型本身不检查。只有 TimesFM3Evaluator 会在超过 32 路时先随机抽样协变量(种子 42),再把目标分块多次前向。

§15 · 答疑

常见疑问

它是自回归模型吗?

解码不是。整段 horizon 通过尾部的占位 token 一次前向写完。时间轴注意力是因果的(像 decoder-only),但生成时不回喂。唯一带递推的是 §9 的 CPM 精修,它递推的是统计量,不是网络。

在尾部多接几个占位 token,会改变前面的预测吗?

不会改变前面位置的输出。时间轴因果,变量轴只在同一位置内混合,统计量是累积的,CPM 精修也是从左到右扫描。唯一的变化在尾部:原先被 wrap_mask 削掉的未来协变量槽会变完整,但受影响的只是那几个尾部位置本身。对最终结果而言,多接 token 只会改变缝合时用到哪些窗口。

模型怎么知道哪一行是目标、哪一行是协变量?

只能从内容看:未来槽是否有值、占位位置的自身值是否有值。变量轴没有位置编码,也没有变量嵌入。目标行和过去协变量行在输入上无法区分,这正是它们被同等对待的原因。

为什么每个 token 预测 64 步,却只前进 32 步?

o = 2p 让每个窗口与下一个窗口重叠一半。重叠处有两份独立预测,线性交叉淡化后接缝更平滑。代价是每覆盖 32 步就要读一个 token,而在这种设计下读 token 几乎是免费的。

未来协变量为什么不能「越过」horizon?

未来槽只覆盖 patch j+1、j+2。最后一个被读取的 token 的未来槽必须完整,所以要多接 r−1 个占位 patch(§2.1)。超出 C+H′ 的部分被补 0 并掩码。

单变量输入时变量轴注意力在做什么?

v = 1 时它是对单个 token 的 self-attention,softmax 恒为 1,子层输出退化为 W_o·W_v·RMSNorm(h):每层多了一个不看别人的线性变换,仍然参与计算。

附录 A

代码索引与复现

文件符号对应本页
torch/model.pydecode§2 网格与 horizon 算术、§3.1 去趋势、§8.2 选 token、§10 加回趋势
torch/model.py_preprocess§3.2 统计量、§4 token 组装与掩码
torch/model.pyforward§4.3 cumprod 掩码、§8.1 输出头、§9 精修接入点
torch/util.pyget_running_stats · update_running_stats · revin§3.2
torch/util.pyget_output_patch_via_roll · stitch_patches§4.1、§8.3
torch/dense.pyResidualBlock§5
torch/transformer.pyMixingTransformer · MultiHeadAttention · RotaryPositionalEmbedding§6
torch/normalization.pyPerDimScale§6.1
torch/cpm_revin_refine.pycpm_iterative_revin_refine§9
torch/timesfm3_forecaster.pypredict_batch · _Query.format§1 预处理、§10 后处理
torch/evaluator.pyTimesFM3Evaluator§14 变量分块、univariate 模式

实测数据是怎么来的

  • 权重:HF 快照 google/timesfm-3.0-pytorch@43046b8,config.json 与本页所列一致(use_rope_var=false、use_memory_efficient_attention=true 等)。
  • 代码:本仓库 src/timesfm3/torch/,PyTorch 2.5.1,单卡 RTX 4080,fp32。
  • 数据:§1 所述合成序列,numpy.random.default_rng(7)。
  • 方法:forward hook 抓取 resblock_input、各层注意力输入与层输出;包装 cpm_iterative_revin_refine 抓取精修前后的统计量;消融通过修改模型属性(use_stitching、use_iterative_cpm_revin、use_variate_attention)或改变输入实现。