一屏看懂:一次前向里发生的全部事情
TimesFM 3.0 把输入组织成一张 变量 × 时间 的 token 网格,用 20 层「时间轴注意力 + 变量轴注意力 + FFN」处理它,再让每个 token 写出紧随其后的 64 步、9 个分位数。预测段是接在尾部的占位 token,所以整段 horizon 只需要一次前向。
下面这条流水线是全页的主线。右栏是通用形状和本页运行示例里的实际数字(b=1,3 个变量,上下文 160,预测 96):
- 收集输入目标、过去协变量、未来协变量在变量轴上叠成一摞(b, v, C)(1, 3, 160)
- 左补齐 + 条件去趋势C 补到 32 的倍数;每行独立拟合直线,只有在能把 std 压到一半以下时才减掉(b, v, C)本例三行都不去趋势
- 接上 horizon 占位段目标行与过去协变量行填 0、掩码 = True;未来协变量行填真实未来值(b, v, C + H′)(1, 3, 256)
- 切 patch每 32 步一个 token 位置;n = C/32 + H′/32(b, v, n, 32)(1, 3, 8, 32)
- 因果累积统计量第 i 个位置的 μ, σ 只用 patch 0…i 的有效点μ, σ : (b, v, n)(1, 3, 8)
- 组装 token[自身 32 值 | 未来 64 值 | 自身 32 掩码 | 未来 64 掩码],全部按该位置的 μ, σ 归一化(b, v, n, 192)(1, 3, 8, 192)
- 残差块升维192 → 1280,ReLU,带线性捷径,无 bias(b, v, n, 1280)
- 20 × MixingTransformer时间轴注意力(因果 + RoPE)→ 变量轴注意力(双向、无位置)→ FFN;三明治 RMSNorm(b, v, n, 1280)形状不变
- 输出头Linear 1280 → 64 × 9,每个 token 都写一个 64 步窗口(b, v, n, 64, 9)(1, 3, 8, 64, 9)
- CPM 精修 + 反归一化horizon 位置的 μ, σ 用模型自己的中位数预测更新,再 y·σ + μ(b, v, n, 64, 9)
- 选 token + 缝合取最后一个上下文 token 起的 n_f 个窗口,步长 32、重叠 32,线性交叉淡化(b, v, H, 9)读 #4, #5 → (1, 3, 96, 9)
- 加回趋势,丢掉协变量行,排序分位数中位数(索引 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 权重后的真实中间张量;标 推断 的说法涉及训练,而仓库里没有训练代码,只能从文档字符串和实现细节推断,读的时候请打个折扣。三类变量,以及贯穿全页的例子
参数(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 段的真实值(模型看不到)
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)。
从三条数组到一张 patch 网格
2.1 horizon 要接几个占位 patch
默认 use_stitching=True。每个被读取的 token 贡献 extract_len = min(2p, o) = 64 步,相邻两个被读取的 token 在时间上错开 p = 32 步,因此重叠 overlap = 32。要盖满 H 步,需要:
| 场景 | H (进 decode) | n_f | n_hor | n | 读取的 token |
|---|---|---|---|---|---|
| 本例:有未来协变量,H=96 | 96 | 2 | 3 | 8 | #4, #5 |
| 同样 H=96,但不带协变量(Forecaster 先取整到 128) | 128 | 3 | 4 | 9 | #4, #5, #6 |
| H=64 | 64 | 1 | 2 | C/32+2 | 只读最后一个上下文 token |
多留的 r − 1 = 1 个 patch 不是浪费:最后一个被读取的 token 的「未来槽」要从后面 2 个 patch 卷过来,少一个就会被 wrap_mask(§4.3)削掉一半,未来协变量的尾巴就丢了。
2.2 拼接与掩码
- 左补齐:
C不是 32 的倍数时,在左边补 0,补出来的位置掩码为 True。 - 上下文段:三类变量沿变量轴拼成
(b, v, C),全局maskOR 进每一行;去趋势(§3.1)之后,把掩码位置的值强制置 0。 - horizon 段:目标行和过去协变量行是
zeros,掩码全 True;未来协变量行是真实未来值(减去该行自己的趋势线,若有),不足H′的部分补 0 并掩码。 - 时间轴拼接得到
(b, v, C+H′),reshape 成(b, v, n, 32)。同时构造两个辅助张量:
本例 n_ctx = 5、n = 8:位置 #0–#4 是上下文(第 1–160 天),#5–#7 是占位(第 161–256 天)。这张 3 × 8 的网格就是模型的全部输入,§4.4 会把它的 24 个 token 逐格摊开。
去趋势与因果 RevIN:把量纲和趋势剥掉
3.1 条件线性去趋势(在 patch 之前,逐行)
时间坐标归一化到 t = (−(C−1), …, 0) / 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):
同一个 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 根本没有调用它,这个配置项目前不起作用。
一个 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 1 | 0 |
| 128–191 | 未来窗口掩码 | 永远为 1 | 0(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 的输出窗口严格对齐同一段时间。
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 天,明明是已知的历史):
- 那段正是该 token 的预测目标推断。答案若摆进输入,模型只需要学会复制。
- 信息没有丢:第 65–128 天在 token #2、#3 的自身值里,因果时间注意力让后面的 token 都读得到。
- 只有这样,占位 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 全摊开
wrap_mask 削掉,而被读取的 #4、#5 恰好不受影响。4.5 真实的 192 维输入长什么样
下图是 resblock_input 里的三个真实向量 实测,横轴是 192 个特征维,颜色是归一化后的值:
残差块:192 → 1280
它逐 token 独立作用,是模型里唯一把「时间序列数值」变成「隐藏向量」的地方。没有额外的位置嵌入,也没有变量 ID 嵌入:时间位置只在注意力里通过 RoPE 注入,变量身份则完全不注入。
实测 本例升维后每个 token 的 RMS 约 3.7–4.6。占位目标 token 的输入逐位相同,升维后也完全相同。
一层 MixingTransformer:先看时间,再看变量
20 层结构相同。每层三个子层,每个子层都是三明治 RMSNorm:前置归一化、子层、后置归一化,再加残差。
最后一层之后没有 final norm,残差流直接进输出头。
(v, n) 网格被两个注意力沿不同方向切开。代价分别是 O(b·v·n²·d) 与 O(b·n·v²·d)。6.1 注意力内部:按代码顺序
一个容易得出的结论是「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
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 不使用:一次前向就结束了。
主干实测:注意力到底在看哪
在每层 seq_attn / var_attn 上挂 forward-pre-hook 取输入,按 §6.1 的顺序复算注意力权重,16 个头取平均 实测。只有一个样本,读作「这个模型在这个例子上」,不要读成普遍规律。
7.1 时间轴:日销量行,第 1 / 10 / 20 层
7.2 变量轴:跨变量交流发生在前 15 层
- 日销量
- 客流
- 促销
use_variate_attention 全部置 False 再跑同样的权重,预测值发散到 10⁶ 量级。权重是和变量轴一起训练的,它不是可插拔的附加模块。如果想要「单变量」行为,应该用 TimesFM3Evaluator(univariate=True) 把各行拆成独立样本,而不是拆掉这一子层。输出头、选哪些 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 的窗口
- 缝合结果
- 真实值
use_stitching=False)每隔 2 个 token 取一个完整的 64 步窗口首尾相接,本例与默认结果的最大差 7.4,MAE 7.53 vs 7.38。反归一化与 CPM 精修:唯一带递推的一步
占位位置的 μ, σ 停在上下文末尾(§3.2)。用它们反归一化没问题,但预测段若整体抬升,按照累积统计量的定义,后面位置的 μ 本该跟着变。cpm_iterative_revin_refine 用模型自己的中位数预测把这一点补上。
9.1 算法(沿 n 扫描一遍)
- 非 CPM 位置(上下文):原样使用真实统计量;把该 token 反归一化后的 64 步中位数存为锚点。
- CPM 位置 i:从锚点里取出对应的 32 步(锚点的第
block_offset段),当作一个完全有效的新 patch 并入累积量,得到 μᵢ*, σᵢ*。 block_offset在 0, 1 之间循环。回到 0 时,锚点换成当前位置用 μᵢ*, σᵢ* 反归一化后的预测。- 最后
μ, σ ← where(cpm, μ*, σ*),再做y·σ + μ。
网络前向不重跑,被递推的只有 (b, v, n) 上的两个标量。
| 日销量行 | #4(上下文) | #5 | #6 | #7 |
|---|---|---|---|---|
| μ 冻结值 | 133.6 | 133.6 | 133.6 | 133.6 |
| μ* 精修后 | 133.6 | 135.9 | 136.6 | 138.0 |
| σ 冻结值 | 27.9 | 27.9 | 27.9 | 27.9 |
| σ* 精修后 | 27.9 | 28.3 | 27.4 | 27.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(单个样本)。
代码从未展开这个缩写。可以确定的是它的语义:_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 共用同一套格式。
最终输出与后处理
- 加回趋势(仅对 §3.1 判定去趋势的行):每个分位数都加
m·(1…H)/C + c。 - 丢掉协变量行:
out[:, :u]。客流行其实也被「预测」了,只是不返回。 - 排序分位数(
sort_quantiles=True):沿最后一维np.sort,消除分位数交叉。 - 可选 对称平均:同一 batch 里多推理一份
−x(协变量也取负),合成(y⁺ − reverse_q(y⁻)) / 2,让预测对取负严格反对称。TimesFM3Evaluator默认开。 - 可选 还原 z-score、截断到 ≥ 0(
make_positive:仅当该行上下文全非负;Evaluator 默认开)。 forecast = quantiles[..., 4](q50),quantiles为(u, H, 9)。
- 上下文
- 中位数预测
- q10–q90
- q30–q70
- 真实值
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。
协变量真的在起作用吗
同样的权重、同样的 160 天销量,只改变喂给模型的协变量 实测:
| 输入 | 中位数 MAE | 说明 |
|---|
- 完整输入
- 只给销量
- 真实值
- 原排期
- 挪动后的排期
把未来段促销全部置 0(历史保留)MAE 为 17.1,比完全不给促销(23.0)好。这说明历史里的促销也有用:它让模型把历史高峰「解释」成促销,而不是当成周期去外推。
330,710,976 个参数花在哪里
加载权重后逐模块 numel() 求和 实测,与按配置手算的结果一致。
| 模块 | 构成 | 参数 |
|---|---|---|
| 输入残差块 | 192×1280 + 1280×1280 + 192×1280 | 2,129,920 |
| 每层 · 时间轴注意力 | 4×1280² + 80 + 80 + 80 | 6,553,840 |
| 每层 · 变量轴注意力 | 4×1280² + 80 + 80 + 80 | 6,553,840 |
| 每层 · FFN | 2×1280² | 3,276,800 |
| 每层 · 6 个 RMSNorm | 6×1280 | 7,680 |
| 每层小计 × 20 | 16,392,160 × 20 | 327,843,200 |
| 输出头 | 1280×576 + 576 | 737,856 |
| 合计 | 330,710,976 |
注意力每路那三个 80 是 Q 的 RMSNorm 增益、K 的 RMSNorm 增益和 PerDimScale,合计 240 个数,决定了注意力 logit 的尺度。去掉 20 路变量轴注意力(131.1M),剩下约 199.6M,恰好是 2.5 那一档「200M」的规模。
与 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 激活 | swish | ReLU |
| 输出 patch | 128 步 × 10(均值 + 9 分位);另有 1024 步的连续分位数头 | 64 步 × 9 分位;单个 Linear |
| 长 horizon | 自回归:写一段、喂回、KV cache 续写 | 尾部占位 token,一次前向 + 缝合 |
| 上下文上限 | 16384 | 15360 |
| 归一化 | 逐 patch 累积 RevIN | 同上 + 条件去趋势 + CPM 精修 |
容易踩的坑
过去协变量 ≈ 不返回的目标
它和目标行的输入格式完全相同。模型不知道哪一行是你「真正关心」的,只有排在前 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),再把目标分块多次前向。
常见疑问
它是自回归模型吗?
解码不是。整段 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):每层多了一个不看别人的线性变换,仍然参与计算。
代码索引与复现
| 文件 | 符号 | 对应本页 |
|---|---|---|
| torch/model.py | decode | §2 网格与 horizon 算术、§3.1 去趋势、§8.2 选 token、§10 加回趋势 |
| torch/model.py | _preprocess | §3.2 统计量、§4 token 组装与掩码 |
| torch/model.py | forward | §4.3 cumprod 掩码、§8.1 输出头、§9 精修接入点 |
| torch/util.py | get_running_stats · update_running_stats · revin | §3.2 |
| torch/util.py | get_output_patch_via_roll · stitch_patches | §4.1、§8.3 |
| torch/dense.py | ResidualBlock | §5 |
| torch/transformer.py | MixingTransformer · MultiHeadAttention · RotaryPositionalEmbedding | §6 |
| torch/normalization.py | PerDimScale | §6.1 |
| torch/cpm_revin_refine.py | cpm_iterative_revin_refine | §9 |
| torch/timesfm3_forecaster.py | predict_batch · _Query.format | §1 预处理、§10 后处理 |
| torch/evaluator.py | TimesFM3Evaluator | §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)或改变输入实现。