TimesFM简介

长期以来时间序列预测还停留在”一个任务一套模型”的阶段,每次换数据集都要重新来过。2024年,Google Research推出了TimesFM(Time Series Foundation Model),将大语言模型”预训练+通用泛化”的范式迁移至时间序列预测,标志着该领域终于迎来了属于自己的”GPT时刻”。

传统时序预测的痛点

传统时间序列预测技术分为统计学模型与深度学习模型两大路线:

  • 统计学模型(ARIMA、Prophet、ETS):在复杂非线性时序、多变量关联场景下预测精度不足,且强依赖人工特征工程,跨场景需重复适配。
  • 深度学习模型(LSTM、Temporal Fusion Transformer、PatchTST):需要海量标注样本训练、依赖高端GPU算力,小业务场景下企业无法承担算力与数据标注成本,同时模型泛化能力较差,更换业务数据集需要重新训练拟合。

TimesFM的诞生

TimesFM(Time Series Foundation Model)是 Google Research 推出的开源时间序列基础模型(Foundation Model)。它借鉴了大语言模型(LLM)的训练思想,在海量时间序列数据上进行预训练,使模型能够学习各种时间序列中的共性规律,例如:

  • 趋势(Trend)
  • 周期性(Seasonality)
  • 波动性(Volatility)
  • 突变(Change Point)
  • 长期依赖关系

与传统模型不同,TimesFM 不需要针对每个业务重新训练,就能够直接进行预测(Zero-shot Forecasting),并在许多公开数据集上取得接近专门训练模型的效果。

TimesFM 到底是什么?

TimesFM 是谷歌研究院(Google Research)于 2024 年提出、并持续迭代的开源时间序列基础模型,论文《A decoder-only foundation model for time-series forecasting》发表于机器学习顶会 ICML 2024。它的核心定位一句话就能说清:一个 Decoder-Only(仅解码器)Transformer,把时间序列切成连续值的 patch 当作 token,在 1000 亿+ 真实世界时间点上自回归「续写」预训练,从而对任意领域、任意频率的序列实现零样本预测。

它借鉴了大型语言模型(LLM)的成功经验,但关键差异在于:自然语言有离散的「词表」,而时间序列每个时刻是连续浮点数——所以 TimesFM 把连续的时间点分组为 patch,每个 patch 就是一个「词元」。这让模型既能复用 Transformer 的成熟工程能力,又贴合时序数据本身的结构。

为什么这件事重要

时间序列预测是金融、能源、零售、供应链的底层基础设施,但传统做法有个长期痛点:每个数据集都要单独调参、单独训练。ARIMA 的 p/d/q、Transformer 的层数/头数、PatchTST 的 patch size……一个场景一套参数,迁移成本极高。

在 Monash 等权威基准上,TimesFM 的零样本表现已经超越 ARIMA、ETS 等传统统计方法,并与 DeepAR、PatchTST 等专为数据训练的监督深度学习模型相当。它把时序预测的门槛,从「机器学习工程」降到了「调一个 API」。

TimesFM的架构简介

Patch 化的 Decoder-Only Transformer

TimesFM 不是简单套用 LLM,而是针对时序做了精准改造。它的推理流程可以拆成四步:把历史序列切成 patch → 用带残差的 MLP 编码成向量 → 因果自注意力的 Transformer 解码 → 自回归生成未来的 patch。

几个值得记住的设计取舍

  • 输入/输出 patch 长度:公开 checkpoint 默认 input=32、output=128。input 太小会”看不见”局部形态,output 太小则长预测累计误差变大。
  • PerDimScale:用可学习的逐维注意力缩放,替代固定 1/√d,自动聚焦关键时序特征。
  • 翻转不变性:保证 TimesFM(aX+b) = a·TimesFM(X)+b,对正负缩放都成立,相当于无成本的数据增强。
  • 归一化:默认对输入做归一化,预测值再乘以对应 patch 的归一化因子回到原始量纲。

版本演进:从 1.0 到 2.5

TimesFM 经历了快速迭代,v2.5(当前稳定版)是一次有意思的”反向瘦身”——参数从 500M 砍到 200M,但上下文从 2048 拉长到 16K,还新增了连续分位数头。

概率化预测:不只给一个点,还给区间

v2.5 通过可选的 30M 连续分位数头(quantile head),输出 10%–90% 共 9 个分位数 + 均值,给出完整的概率分布而非单点估计。这意味着你能直接量化预测的不确定性,支撑库存备货、金融风控等风险决策。

优势与局限:它不是银弹

适用 / 优势

  • 快速原型验证的数据科学家
  • 缺乏大量标注数据的中小业务
  • 百万级 SKU / 传感器批量预测
  • 需要置信区间的不确定性量化
  • SQL 团队无 ML 设施(BigQuery)
  • 200M 轻量,消费级 GPU 可跑

局限 / 不适用

  • 需要强解释性的合规报告场景
  • 极短序列(< 10 个时间点)
  • 多变量因果联合建模(可选 MOIRAI-2)
  • 极低延迟实时流式推理(需额外框架)
  • 高度专业化领域(罕见病、量子物理)
  • 黑盒模型,训练数据不透明

TimesFM的使用

本教程基于官方仓库(google-research/timesfm,最新 timesfm==2.0.2)与 timesfm-forecasting/ 示例整理,所有代码均对应 TimesFM 2.5 的 PyTorch 接口。先装好环境,再逐节照做即可。

安装与环境

TimesFM 同时提供 PyTorch(GPU/CPU/Apple Silicon) 与 Flax/JAX(TPU/GPU 高速推理) 两种后端,协变量功能需要额外依赖。

# 基础预测
pip install timesfm[torch]

# 需要 XReg 协变量时
pip install timesfm[xreg]

# TPU / GPU 上的高速推理
pip install timesfm[flax]

本地开发建议

用 uv 管理虚拟环境:uv venv && source .venv/bin/activate && uv pip install -e .[torch]。若你机器上没有 GPU,PyTorch CPU 版也能跑通示例,只是速度较慢。

微调(LoRA)还需额外装:pip install transformers accelerate peft pandas pyarrow scikit-learn。

加载模型与编译

v2.5 的模型类以显式命名暴露,常用的是 TimesFM_2p5_200M_torch(对应 HuggingFace 权重 google/timesfm-2.5-200m-pytorch)。先用 from_pretrained 加载,再用 compile(ForecastConfig(…)) 固化推理配置。

import torch, numpy as np, timesfm

# Ampere 及以上 GPU 建议开启,提升矩阵乘精度/速度
torch.set_float32_matmul_precision("high")

# 1) 加载预训练权重
model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(
    "google/timesfm-2.5-200m-pytorch"
)

# 2) 编译:固化推理配置(只需一次)
model.compile(timesfm.ForecastConfig(
    max_context=1024,
    max_horizon=256,
    normalize_inputs=True,
    use_continuous_quantile_head=True,
    force_flip_invariance=True,
    infer_is_positive=True,
    fix_quantile_crossing=True,
))

# 3) 预测:返回 (点预测, 分位数)
point, quantiles = model.forecast(horizon=12, inputs=[
    np.linspace(0, 1, 100),
    np.sin(np.linspace(0, 20, 67)),
])
point.shape        # (2, 12)       两条序列各 12 步
quantiles.shape    # (2, 12, 10)   均值 + 10th~90th 共 10 个分位

先跑系统检查(可选但推荐)

官方 Agent 技能要求在首次加载前运行 python scripts/check_system.py,检查内存/显存/磁盘/Python≥3.10,避免大模型把机器拖垮。

ForecastConfig 参数速查

compile() 接受的 ForecastConfig 决定了模型行为。下表列出常用字段:

参数 默认值/建议 作用
max_context 1024 最大历史上下文长度(2.5 最高 16384)。越长越能捕捉季节结构,但更耗显存。
max_horizon 256 单次最大预测步长(分位数模式最高 1000)。
normalize_inputs True(建议) 输入实例归一化,防止量纲不稳定。建议保持开启。
use_continuous_quantile_head False 开启连续分位数头(30M),输出 10%~90% 概率区间。长 horizon 更准。
force_flip_invariance False 强制翻转不变性 f(-x)=-f(x),对称分布数据更稳。
infer_is_positive False 当输入全为正时,把预测裁剪到 ≥0(销量、价格等)。
fix_quantile_crossing True 修复分位数交叉(如 q10>q20),保证单调。建议开启。
per_core_batch_size 32 每核批大小,按显存/内存调。
return_backcast False 开启后额外返回历史重建(backcast),用于回测/协变量工作流。

功能①:基础点预测

最简单的用法:把一条一维序列丢进 inputs,指定 horizon,拿到 point 点预测。即使不开分位数头,也会返回点预测(取均值)。

import numpy as np, timesfm
model = timesfm.TimesFM_2p5_200M_torch.from_pretrained("google/timesfm-2.5-200m-pytorch")
model.compile(timesfm.ForecastConfig(max_context=512, max_horizon=52,
                            normalize_inputs=True))

series = np.sin(np.linspace(0, 20, 200))  # 任意 1-D 数组
point, _ = model.forecast(horizon=24, inputs=[series])
point.shape   # (1, 24)

输入要求

inputs 是一个「序列列表」,每条序列是 1-D 数组(list 或 numpy)。单条序列也要包成 [series]。历史长度建议 ≤ max_context;超过会自动截断到上下文窗口。

功能②:分位数概率预测

把 use_continuous_quantile_head=True 编进配置,forecast 会返回第二个值 quantiles,形状为 (N, H, 10):第 0 维均值,第 1~9 维依次是 10th、20th、…、90th 分位数。

model.compile(timesfm.ForecastConfig(
    max_context=1024, max_horizon=256,
    normalize_inputs=True,
    use_continuous_quantile_head=True,   # ← 关键开关
    fix_quantile_crossing=True,
))

point, q = model.forecast(horizon=24, inputs=[series])
mean    = q[0, :, 0]   # 均值
lower90 = q[0, :, 1]   # 10th 分位(下界)
upper90 = q[0, :, 9]   # 90th 分位(上界)
# 上界 - 下界 = 80% 预测区间宽度,量化不确定性

概率预测的价值

单点预测无法告诉你「有多大把握」。分位数区间可用于库存安全库存设定、金融风控的 VaR、异常告警阈值等需要量化不确定性的决策。

功能③:外生协变量 XReg

概率预测的价值

单点预测无法告诉你「有多大把握」。分位数区间可用于库存安全库存设定、金融风控的 VaR、异常告警阈值等需要量化不确定性的决策。

功能③:外生协变量 XReg

v2.5 重新引入了协变量支持(pip install timesfm[xreg])。用 forecast_with_covariates() 接入已知的未来信息——比如促销、价格、节假日,显著提升复杂场景精度。

point, quantiles = model.forecast_with_covariates(
    inputs=inputs,                                  # 历史序列列表
    dynamic_numerical_covariates={"price": price_arrays},     # 动态数值协变量(随步变化)
    dynamic_categorical_covariates={"holiday": holiday_arrays},# 动态类别协变量
    static_categorical_covariates={"region": region_labels},  # 静态类别协变量(每条序列一个标签)
    xreg_mode="xreg + timesfm",            # 或 "timesfm + xreg"
)
  • 动态协变量:必须同时覆盖历史上下文窗口和预测窗口两段,不能有缺口。
  • 静态协变量:每条序列一个标签(如 region/品类),不随时间变化。
  • xreg_mode:控制协变量与时序主干的融合顺序;数据质量好时 “xreg + timesfm” 常用,可都试一遍对比。

功能④:批量多序列预测

把多条序列放进同一个 inputs 列表即可一次批量预测——非常适合「百万级 SKU / 传感器」场景。返回结果的第 0 维就是序列索引。

import pandas as pd, json
df = pd.read_csv("all_stores.csv", parse_dates=["date"], index_col="date")

# 每列是一条时间序列
inputs = [df[col].dropna().values.astype("float32") for col in df.columns]

point, quantiles = model.forecast(horizon=30, inputs=inputs)

results = {col: {"forecast": point[i].tolist(),
                 "lower_80": quantiles[i, :, 1].tolist(),
                 "upper_80": quantiles[i, :, 9].tolist()}
           for i, col in enumerate(df.columns)}
with open("batch_forecasts.json", "w") as f:
    json.dump(results, f, indent=2)

功能⑤:回测 backcast

把 return_backcast=True 编入配置,forecast 会额外返回对历史区间的重建(backcast)。它常用于:① 评估模型对历史拟合得好不好;② 协变量工作流的前处理;③ 与真实值对比做「拟合优度」检查。

model.compile(timesfm.ForecastConfig(
    max_context=1024, max_horizon=256,
    normalize_inputs=True,
    return_backcast=True,     # ← 开启回测
))

point, quantiles, backcast = model.forecast(horizon=24, inputs=[series])
# backcast: 模型对输入历史的重建,形状与输入序列对应
# 可对比 backcast 与真实历史,检查异常/漂移

和「效果评估」区分

backcast 是对历史的重建;真正的预测效果评估是把序列末尾切出来当 ground truth,看未来预测准不准。

功能⑥:异常检测

基于分位数区间即可做轻量异常检测:真实值掉出 80% 预测区间(10th~90th)即判为异常。无需训练,开箱即用。

point, q = model.forecast(horizon=H, inputs=[values])

lower90 = q[0, :, 1]   # 10th 分位
upper90 = q[0, :, 9]   # 90th 分位

actual   = test_values
anomalies = (actual < lower90) | (actual > upper90)   # 布尔数组

功能⑦:预测效果评估

把序列末尾 H 步切出当真值,用常见指标量化准确率;分位数头还能算区间覆盖率(CRPS 的近似代理)。

H = 24
train, actual = values[:-H], values[-H:]
point, quantiles = model.forecast(horizon=H, inputs=[train])
pred = point[0]

mae  = np.mean(np.abs(actual - pred))
rmse = np.sqrt(np.mean((actual - pred) ** 2))
mape = np.mean(np.abs((actual - pred) / actual)) * 100
coverage = np.mean((actual >= quantiles[0, :, 1]) &
                   (actual <= quantiles[0, :, 9])) * 100

print(f"MAE: {mae:.2f} | RMSE: {rmse:.2f} | "
      f"MAPE: {mape:.1f}% | 80% PI Coverage: {coverage:.1f}%")

怎么算「准」

点预测看 MAE/RMSE/MAPE;概率预测看区间覆盖率(理想约 80% 对应 80% PI)。覆盖率明显偏离说明不确定性被低估/高估。

功能⑧:性能调优

200M 模型很轻,但批量大时仍需调优。核心是 per_core_batch_size 与「分块预测」。

批大小经验值

# GPU 8GB  : 64
# GPU 16GB : 128
# CPU 8GB  : 8
# CPU 16GB : 32
model.compile(timesfm.ForecastConfig(
    per_core_batch_size=128, ...))

显存不够就分块

CHUNK = 50
results = []
for i in range(0, len(inputs), CHUNK):
    p, q = model.forecast(
        horizon=H, inputs=inputs[i:i+CHUNK])
    results.append((p, q))
  • 精度开关:Ampere+ GPU(A100/RTX3090+)务必set_float32_matmul_precision(“high”)。
  • 后端选择:GPU/CPU 用 PyTorch;TPU 或追求极致推理速度用 Flax/JAX。
  • 量化/混合精度:部署时可结合 BF16、Flash Attention 进一步降显存、提速度(依后端支持)。

功能⑨:LoRA 微调

零样本够用,但遇到强领域特性(特定零售节律、专业传感器)时,可用 HuggingFace Transformers + PEFT(LoRA) 做参数高效微调。2.5 已作为标准 Transformers 模型发布(google/timesfm-2.5-200m-transformers),原生支持 PEFT 适配器与 Flash Attention。

安装

pip install transformers accelerate peft pandas pyarrow scikit-learn

一键训练(官方脚本)

python finetune_lora.py \
  --epochs 20 --batch_size 64 \
  --lr 5e-5 --lora_r 8 \
  --lora_alpha 16 \
  --context_len 64 \
  --horizon_len 13 \
  --output_dir my-adapter

关键要点

  • 不要外部归一化:TimesFM 2.5 自带实例归一化(RevIN),直接喂原始值,别自己先标准化。
  • 随机窗口采样:每个训练样本是从序列里随机切的 (context, horizon) 窗口,比固定窗口更高效。
  • LoRA 目标:target_modules=”all-linear”把 LoRA 挂到所有线性层;r=4 仅增加约6% 可训练参数(~1.4M / ~232M)就能有效适配新领域。
  • 保存与复用:走标准save_pretrained / from_pretrained 工作流;评估用 python finetune_lora.py –eval_only –output_dir <adapter>。

功能⑩:命令行与脚本

仓库自带 CLI 脚本,无需写代码即可对 CSV 做预测或做资源预估。

# 对 CSV 直接预测(自动按列批量)
python scripts/forecast_csv.py input.csv \
    --horizon 24 \
    --date-col date \
    --value-cols sales,revenue \
    --output forecasts.csv

# 系统/资源检查
python scripts/check_system.py \
  --num-series 1000 --context-length 1024 \
  --horizon 24 --batch-size 32
# 仅估算显存(不查机器)
python scripts/check_system.py \
  --num-series 5000 --context-length 2048 \
  --estimate-only

功能⑪:Agent 技能集成

TimesFM 自带符合 Agent Skills 规范的 SKILL.md,任何支持该开放标准的 AI Agent(Claude Code、Cursor、Codex、OpenCode 等)都能直接调用它做预测。

# 安装技能到 Agent 的 skills 目录
cp -r timesfm-forecasting/ ~/.claude/skills/
# 或
cp -r timesfm-forecasting/ ~/.cursor/skills/

装好后,Agent 就能用自然语言触发「读取 CSV → 零样本预测 → 画图/告警」,把 TimesFM 嵌进自动化数据工作流。

功能⑫:Google 产品集成

不想写代码?TimesFM 已落地到谷歌多款产品中,SQL/表格团队零门槛可用:

  • BigQuery ML 企业级 SQL 调用,规模化与可靠性兼备,适合百万序列批量预测。
  • Google Sheets 在日常表格里直接「预测数据」,运营/分析师免开发。
  • Vertex AI Model Garden Docker 化端点,方便被 Agent 编排调用。
  • 开源库(本教程)PyTorch/Flax 双后端,可嵌入自有工程栈。

常见问题

  • Q:预测长度超过 max_horizon 怎么办?把长 horizon 拆成多段自回归,或调大 max_horizon(分位数模式最高 1000)。
  • Q:序列太短(<10 点)能用吗?零样本对极短序列效果有限,建议凑够至少数十个点,或做微调。
  • Q:需要 GPU 吗?不需要,CPU 可跑;但批量/长上下文建议 GPU。消费级显卡(如 RTX 4090)流畅运行。
  • Q:多变量联合建模?当前为单变量基础模型,多变量因果联合建模可关注 MOIRAI-2 等方案;TimesFM 用 XReg 接入外生变量作为近似。

TimesFM实战代码

from datetime import timedelta

import numpy as np
import pandas as pd

# ---------------------- 1. 配置 ----------------------
CSV_PATH     = "data.csv"
HORIZON      = 90
MAX_CONTEXT  = 2048
EXCLUDE_TODAY = True

OUT_CSV   = "forecast.csv"
OUT_HTML  = "forecast.html"
OUT_PNG   = "forecast.png"
OUT_ANOM  = "forecast_anomalies.csv"

Z_THRESHOLD = 3.5
ROLL_WINDOW = 30
HIST_DAYS   = None

# !!按实际业务修改,或设为 None 表示不过滤
PLAT    = "your_platform"      # 例如 "天猫"
PRODUCT = "your_product"       # 例如 "手机"


def _read_csv_auto(path):
    """自动探测文件编码"""
    for enc in ("utf-8-sig", "utf-8", "gbk"):
        try:
            return pd.read_csv(path, encoding=enc)
        except (UnicodeDecodeError, UnicodeError):
            continue
    return pd.read_csv(path)


def detect_anomalies(s, window=ROLL_WINDOW, threshold=Z_THRESHOLD):
    """
    基于滚动中位数 + MAD 的稳健 z-score 异常检测。
    返回:异常布尔序列、z 值、滚动期望值
    """
    s = s.astype(float)
    min_periods = max(3, window // 3)

    roll_med = s.rolling(window, center=True, min_periods=min_periods).median()
    roll_mad = (s - roll_med).abs().rolling(window, center=True, min_periods=min_periods).median()
    scaled = roll_mad * 1.4826  # MAD -> 近似标准差

    with np.errstate(divide="ignore", invalid="ignore"):
        z = (s - roll_med) / scaled.replace(0, np.nan)

    z = z.replace([np.inf, -np.inf], np.nan)
    flags = z.abs() > threshold

    return flags.fillna(False).astype(bool), z, roll_med


def main():
    # ---------------------- 2. 读取并过滤目标序列 ----------------------
    df = _read_csv_auto(CSV_PATH)
    df["ds"] = pd.to_datetime(df["ds"]).dt.normalize()

    # 剔除“当天”不完整数据
    if EXCLUDE_TODAY:
        today = pd.Timestamp.today().normalize()
        n_before = len(df)
        df = df[df["ds"] < today]
        print(f"[信息] 已剔除当天({today.date()})不完整数据,移除 {n_before - len(df)} 行")

    # 过滤平台/产品
    sub = df.copy()
    if PLAT and "plat_type" in sub.columns:
        sub = sub[sub["plat_type"] == PLAT]
    if PRODUCT and "product_type" in sub.columns:
        sub = sub[sub["product_type"] == PRODUCT]

    if len(sub) == 0:
        raise ValueError("过滤后数据为空,请检查 PLAT / PRODUCT 或 CSV 列名")

    # 按天聚合:min_count=1 让缺失日期保持 NaN,而不是变成 0
    series = sub.set_index("ds")["y"].astype(float).sort_index()
    daily = series.resample("D").sum(min_count=1)

    # 补成连续日序列并插值
    daily = daily.interpolate(method="linear").ffill().bfill()

    # 截断到最近 MAX_CONTEXT 天
    values = daily.values.astype(np.float32)[-MAX_CONTEXT:]
    last_date = daily.index[-1]
    print(f"[信息] {PLAT}/{PRODUCT} 历史点数={len(daily)},送入模型点数={len(values)},"
          f"日期范围 {daily.index[0].date()} ~ {last_date.date()},均值≈{values.mean():.0f}")

    # ---------------------- 3. 加载 TimesFM 2.5 并预测 ----------------------
    import torch
    import timesfm

    torch.set_float32_matmul_precision("high")

    model = timesfm.TimesFM_2p5_200M_torch.from_pretrained("google/timesfm-2.5-200m-pytorch")
    model.compile(timesfm.ForecastConfig(
        max_context=MAX_CONTEXT,
        max_horizon=HORIZON,
        normalize_inputs=True,
        use_continuous_quantile_head=True,
        force_flip_invariance=True,
        infer_is_positive=True,
        fix_quantile_crossing=True,
    ))

    # forecast 返回 (point, quantiles)
    # quantiles 形状 (1, H, 10):[0]=均值,[1]~[9]=10th~90th 分位
    point, quantiles = model.forecast(horizon=HORIZON, inputs=[values])
    point = point[0]
    quantiles = quantiles[0]

    # 80% 预测区间
    lower_80 = quantiles[:, 1]   # 10th
    upper_80 = quantiles[:, 9]   # 90th

    point_int = np.clip(np.round(point), 0, None).astype(int)
    lower_int = np.clip(np.round(lower_80), 0, None).astype(int)
    upper_int = np.clip(np.round(upper_80), 0, None).astype(int)

    # ---------------------- 4. 组装未来日期与结果 ----------------------
    future_dates = pd.date_range(
        start=last_date + pd.Timedelta(days=1),
        periods=HORIZON,
        freq="D"
    )
    print(f"[信息] 预测起始日={future_dates[0].date()},结束日={future_dates[-1].date()}(共 {HORIZON} 天)")

    result = pd.DataFrame({
        "plat_type": PLAT,
        "product_type": PRODUCT,
        "ds": future_dates.strftime("%Y-%m-%d"),
        "y_pred": point_int,
        "lower_80": lower_int,
        "upper_80": upper_int,
    })
    result.to_csv(OUT_CSV, index=False, encoding="utf-8-sig")
    print(f"[完成] 已写出预测结果 -> {OUT_CSV}")
    print(result.head(5).to_string(index=False))

    # ---------------------- 5. 历史数据异常检测 ----------------------
    anom_flags, anom_z, anom_exp = detect_anomalies(daily)

    # 0 / 负值强制判为异常
    zero_flags = daily.astype(float) <= 0
    anom_flags = anom_flags | zero_flags.fillna(False)

    anom_dates = daily.index[anom_flags]
    if len(anom_dates):
        print(f"\n[异常检测] 发现 {len(anom_dates)} 个异常日(|稳健z|>{Z_THRESHOLD} 或 值<=0):")

        # 期望值缺失时用整体中位数兜底,而不是填 0
        median_fallback = daily.median()
        anom_df = pd.DataFrame({
            "ds": anom_dates.strftime("%Y-%m-%d"),
            "y_actual": daily[anom_flags].astype(int).values,
            "y_expected": anom_exp[anom_flags].fillna(median_fallback).round(0).astype(int).values,
            "robust_z": anom_z[anom_flags].round(2).fillna(0).values,
        })
        anom_df["direction"] = np.where(
            anom_df["robust_z"] > 0,
            "偏高(突刺)",
            "偏低(缺口/异常)"
        )
        print(anom_df.to_string(index=False))
        anom_df.to_csv(OUT_ANOM, index=False, encoding="utf-8-sig")
        print(f"[完成] 异常日明细 -> {OUT_ANOM}")
    else:
        print("\n[异常检测] 未发现明显异常日(|稳健z|均在阈值内,且无非正值)")

    # ---------------------- 6. 可视化 ----------------------
    try:
        import plotly.graph_objects as go

        hist = daily if HIST_DAYS is None else daily.iloc[-HIST_DAYS:]

        fig = go.Figure()
        fig.add_trace(go.Scatter(
            x=hist.index, y=hist.values, mode="lines",
            name="历史单量", line=dict(color="#2563eb", width=1.2)
        ))

        # 预测区间
        fig.add_trace(go.Scatter(
            x=future_dates, y=upper_int, mode="lines",
            line=dict(width=0), showlegend=False, hoverinfo="skip"
        ))
        fig.add_trace(go.Scatter(
            x=future_dates, y=lower_int, mode="lines",
            line=dict(width=0), fill="tonexty",
            fillcolor="rgba(245,158,11,0.25)",
            name="80% 预测区间", hoverinfo="skip"
        ))

        # 点预测
        fig.add_trace(go.Scatter(
            x=future_dates, y=point_int, mode="lines",
            name=f"预测({HORIZON}天)", line=dict(color="#f59e0b", width=1.6)
        ))

        # 异常日
        if len(anom_dates):
            fig.add_trace(go.Scatter(
                x=anom_dates, y=daily[anom_flags].values, mode="markers",
                name="异常日",
                marker=dict(color="#dc2626", size=7, symbol="x"),
                hovertemplate="异常日 %{x}<br>实际=%{y}<extra></extra>"
            ))

        title_suffix = f"{PLAT}·{PRODUCT}" if PLAT and PRODUCT else "业务"
        fig.update_layout(
            title=f"{title_suffix} 未来 {HORIZON} 天单量预测(TimesFM 2.5 零样本)",
            xaxis_title="日期",
            yaxis_title="单量",
            font=dict(family="Microsoft YaHei, SimHei, PingFang SC, sans-serif"),
            hovermode="x unified",
            legend=dict(orientation="h", y=1.02),
            template="plotly_white",
            height=520,
            margin=dict(t=60),
        )
        fig.update_xaxes(
            hoverformat="%Y-%m-%d",
            tickformat="%Y-%m",
            tickangle=-45,
        )

        fig.show()
        fig.write_html(OUT_HTML, include_plotlyjs="inline")
        print(f"[完成] 已保存交互式预测图 -> {OUT_HTML}")

        try:
            fig.write_image(OUT_PNG, width=1400, height=520, scale=1.5)
            print(f"[完成] 已保存静态预测图 -> {OUT_PNG}")
        except Exception as pe:
            print(f"[提示] 未生成 PNG(如需请 pip install kaleido):{pe}")
    except Exception as e:
        print(f"[提示] 绘图跳过(不影响预测结果):{e}")


if __name__ == "__main__":
    main()

 

参考资料

0