Transformer 之所以在时间序列建模中越来越受欢迎,是因为它比循环神经网络(RNN)模型更擅长捕捉长序列中的交互关系。Transformer 中的自注意力机制允许序列中的每个元素直接关注其他所有元素,因此把网络信号传播路径的最大长度降低到了理论最小值 (O(1))。这消除了对循环结构的依赖,也使信息流动更加高效。因此,Transformer 在长序列时间序列预测(LSTF)以及异常检测、时空时间序列预测等其他领域中,都展现出很强的潜力。
许多现实世界中的应用——例如天气预测、交通预测、工业过程控制以及用电量规划——都需要对长序列时间序列进行预测。LSTF 要求模型具备很强的预测能力,能够高效捕捉输入与输出之间精确的长程依赖关系。
此外,概率时间序列预测在很多实际场景中也是一项重要任务,从金融、天气预测到计算机系统性能管理都离不开它。在这些领域中,准确的概率预测对于做出合理决策至关重要。概率时间序列预测给出的不是一个单一、确定的“预测值”,而是所有可能未来结果对应的一组概率分布。
在阅读本章的过程中,你将学习使用时间序列数据时需要关注的一些关键事项。你还会对时间序列的不同应用领域建立基础理解,比如时间序列预测、异常检测和时空时间序列预测。作为本章旅程的最后几站,你将了解这些领域中的不同 Transformer 架构,包括 Chronos、PatchTST 和 AnomalyBERT,并学习如何针对你自己的数据微调前两种模型。
理解时间序列数据的复杂性
在处理时间序列数据时,必须关注一些关键属性,因为它们会显著影响你的分析和预测结果。本节将简要介绍处理时间序列数据时最常见、也最值得优先检查的几个性质。
自相关与偏自相关
自相关(autocorrelation)是指时间序列观测值之间的内部相关性,通常表示为随观测时间间隔变化的函数。滞后 下的自相关 的数学定义如下:
这里, 表示序列的取值, 表示序列的均值,符号 表示期望值。对应的样本统计量可以通过下面这个式子计算:
其中, 是时间序列观测值 的均值。
对于 个滞后的偏自相关函数(partial autocorrelation function),它被定义为 与 之间的自相关,同时把其他变量滞后 个时间步所带来的影响 剔除掉。自相关函数与偏自相关函数通常统称为 correlogram(相关图)。图 2-1 展示了亚马逊股价的一个相关图。
图 2-1. 亚马逊股票的相关图。
自相关图(左图)的横轴表示时间序列元素之间滞后的大小。比如,lag 为 2 的自相关,表示时间序列当前值与前两个时间周期对应观测值之间的相关性。通过观察自相关图可以发现,该序列当前时刻与前一时刻之间存在很高的自相关,而且几乎没有衰减。另外,偏自相关(右图)在第一阶滞后上显著。灰色区域表示置信带,用来判断这些相关性在统计上是否显著。
协整
协整(cointegration)表示两条时间序列之间存在真实关系。一个经典比喻是醉汉和他的狗:如果分别单独看他们的行走轨迹,都会显得随机,但他们彼此之间永远不会走得太远。
在协整场景下,你通常会观察到较高的相关性。难点在于,如何判断两个过程确实是协整的,还是你看到的只是伪相关。图 2-2 就展示了一个有趣的伪相关例子。
这两种状态的关键区别在于:伪相关的情况下,这两个过程之间可能根本没有关系;而协整时间序列之间则存在很强的联系。你大概还记得统计课上那句经典的话:“相关性并不能证明因果关系!”
图 2-2. 伪相关示例。该图来自 Tyler Vigen 的伪相关网站。你还能在他的网站上看到更多有趣的伪相关案例。
交叉相关
在处理多变量时间序列时,理解各条序列之间的(相互)依赖关系非常重要。交叉相关(cross-correlation,也称 cross covariances)用来衡量两个时间序列过程之间线性依赖程度。两个过程 (X_1) 和 (X_2) 的交叉相关如下所示:
不过,特征之间过高的交叉相关会给预测建模带来问题,比如多重共线性(multicollinearity),它会扭曲模型估计结果并降低可解释性。所谓多重共线性,是指模型中的多个自变量彼此之间存在相关性。在这种情况下,删掉某个交叉相关较高的特征,或者构造新的组合特征来整合这些相关信息,往往会更有利。
平稳性
平稳时间序列的统计特征——例如均值、方差和自相关——都与时间区间无关,也就是说,它们不会随时间变化。因此,平稳性意味着时间序列不存在明显趋势或季节性影响,并且均值、标准差等描述性统计量在不同滚动窗口中要么保持恒定,要么变化很小。严格平稳性则意味着,时间序列任意子集观测值的联合分布在所有阶矩上都与时间无关。
趋势与季节性
工业时间序列,比如股票价格序列,通常都会表现出趋势或季节性。为了研究这些性质,你可以使用分解(decomposition)方法,也就是把一个序列拆解为水平、趋势、季节性和噪声等成分。分解对于理解时间序列分析和预测中的难点非常有帮助。Statsmodels 包提供了自动分解时间序列的函数。图 2-3 展示了一个针对亚马逊股票价格数据的分解示例,可以清楚地看到其持续上升趋势以及季节性成分。
图 2-3. 亚马逊股票的季节性分解结果。
通常,在真正开始做时间序列预测之前,你会先进行探索性数据分析(EDA),对数据做充分检查。这一步有助于识别数据中的模式、趋势和潜在异常,为后续更准确的预测打下坚实基础。只有先理解数据的底层结构,你才能更有根据地决定下一步该怎么做,比如删除无关特征,或者处理数据质量问题。
准备数据集
在将数据用于建模之前,数据准备始终是必要步骤。处理时间序列数据时,必须考虑它们的顺序属性;你不能像普通表格数据那样随意打乱顺序。同时,你还需要谨慎处理数据切分和缩放。对数据进行标准化非常重要,这样可以避免模型拟合时出现尺度问题。如果你有多个特征(协变量),缩放处理还能够防止某个特征仅仅因为数值范围更大而压制其他特征。
为了避免在预测中引入前视偏差(look-ahead bias),训练数据在缩放时绝不能利用测试集信息。所谓前视偏差,就是用未来信息去预测过去,从而导致模型表现看起来过于乐观、不真实。要避免这个问题,你应当仅使用训练集自身的均值和标准差对训练集做标准化,而不是用整个数据集的统计量。对验证集和测试集,也应应用相同的归一化参数——即使用训练集的均值和标准差。这样可以避免系统性偏差。所谓系统性偏差,是指由于数据准备不当,模型持续地高估或低估结果。
此外,根据你的数据情况,你还可以考虑做对数变换,以降低数据波动性,并让数据更接近正态分布。通过这些方式准备数据集,可以提升时间序列模型的性能和可靠性。
不同应用领域中的时间序列建模
时间序列建模是机器学习中一个非常重要的应用方向。它支撑着现代运营中的许多环节,涵盖库存控制、客户管理、生产、分销、金融和营销等多个领域。在这些场景中,哪怕预测精度只提高一个点,都可能带来数百万美元级别的财务收益。相关任务可能包括异常检测、概率时间序列建模、时空时间序列预测以及分类任务。本节我会简要介绍这些任务之间的主要区别,并举一些典型应用例子。
时间序列预测
时间序列预测是指基于已经观测到的时间序列数据来预测未来值。它既可以用于短期预测,也可以用于长期预测,而两者各自都有不同的挑战和方法。短期预测关注的是近期未来,因此通常用于库存管理、需求计划等任务。长期预测则更关注跨较长时间范围的趋势和季节模式,这对于金融市场、电力需求和天气预测都非常关键。
时空预测
在时空预测(spatiotemporal forecasting)中,模型需要同时考虑时间依赖和空间依赖,才能做出准确预测。Temporal 指时间;而 spatiotemporal 或 spatial-temporal 指的是数据同时沿着空间和时间两个维度采集。这种方法对于描述某个地点、某个时刻发生的现象非常重要。典型应用包括交通流预测、风速预测、天气与气候预测以及空气质量预测。
事件预测
事件预测(event forecasting)的目标,是基于过去事件的历史,预测未来事件发生的时间以及其属性(marks)。这类任务通常用时间点过程(temporal point processes, TPP)建模。在 TPP 中,目标是根据过去事件序列,预测未来事件的时间和标记。推荐系统是 TPP 近年一个很重要的应用方向,因为它可以利用用户行为中的时间维度,做出时间敏感型推荐,比如为促销找到最佳触达时机。其他应用还包括临床事件预测——即预测患者与医疗系统交互事件的序列;用于辅助生活的人类活动预测;以及需求预测。
异常检测
异常检测旨在识别时间序列中的异常点,是时间序列分析中的一个关键任务。它可以用来发现设备缺陷并预防潜在损害,因此在许多工业环境中都非常重要,比如机器监控、IT 设备监控、航天器和发动机监控等。不过,这项任务非常有挑战性,因为大多数数据集并不会为训练集提供真实标签。也就是说,你并不知道训练数据中的某个点到底是不是异常点。因此,异常检测通常会被视为无监督学习任务来处理。
时间序列分类
时间序列分类是指分析多个带标签的时间序列类别,以判断一个新数据集属于哪一类。这在很多环境中都很重要,例如分析传感器数据或金融数据,以辅助业务决策。比如在医疗场景中,时间序列分类可以用于根据患者随时间变化的数据诊断疾病。在金融场景中,它可以用于分类股票价格走势,例如用 0 和 1 分别表示下跌和上涨,从而支持交易策略制定。
时间序列数据的 Token 化
时间序列预测的核心,是理解不同时间步上的数据点之间的关系。与句子中的单词不同,单个时间步本身并没有天然的语义含义,因此必须提取局部语义信息,才能更有效地分析它们之间的联系。一些 Transformer 模型会把每一个时间步都当作独立 token,逐点处理。这种方法会让模型很难仅靠单个时间步去学习时间依赖关系。
解决这个问题的一种方式是 patching。Patching 的做法,是先把多个时间点分组,再进行 token 化和 embedding。这样会形成子序列级别的 patch,并把它们作为 Transformer 的输入 token。图 2-4 展示了这个过程。
图 2-4. 时间序列 patching 示例。
使用 patch 能够在保持 token 长度不变的前提下,显著扩展输入所覆盖的历史时间范围。这种方法与视觉 Transformer 中处理图像 patch 的方式相似,只不过这里是针对时间序列做的适配。Patching 有三个关键收益:第一,它能在 embedding 中保留局部语义信息;第二,在相同 lookback window 下,它可以显著降低 attention 的计算和内存开销;第三,它允许模型考虑更长的历史上下文。PatchTST 首次引入了这种方法,并显著提升了长期预测精度。
Lag-Llama 的 token 化方案,则是基于时间序列过去的值构造 lagged features(滞后特征),使用预先定义好的 lag 索引(按季度、月、周、日、小时和秒等频率)。给定一个排序后的正 lag 索引集合:
这里, 表示 lag 索引列表, 表示排序列表 中最后一个 lag 索引。对某个时间值 的 lag 操作定义为:
其中, 的第 个元素定义为:
为了给一个 context-length 窗口 构造 lag 特征,需要先采样一个包含额外 个历史点的更大窗口。图 2-5 展示了这种 token 化方案。
图 2-5. 对于一个时间序列,在时间步 的 token 化中,会基于一组示例 lag 索引 构造 lag 特征,其中向量中的每一个值都来自 的过去,同时还会基于时间戳 构造 个可能的时间协变量。图片改编自 Kashif Rasul 等人(2024)。
除了这些 lag 特征之外,还会加入不同频率的日期时间特征,例如 minute 中的 second、day 中的 hour,一直到 year 中的 quarter。虽然这些日期时间特征提供了额外信息,但对于任意一个时间序列来说,除了其中一个日期时间特征外,其余从一个时间步到下一个时间步通常都保持不变,因此模型可以隐式理解该时间序列的频率。如果总共有 (F) 个日期时间特征,那么每个 token 的大小就是:
2024 年,LLMTime 引入了一种新的 token 化方法:在固定数值精度并对数据进行缩放之后,把实值数据转换成数字字符串。
你已经知道,token 化是必要的,因为它会影响 token 序列中模式的形成,也会影响语言模型能够学会哪些操作。然而,像 byte-pair encoding(BPE)这样的常见方法,在处理数字时往往会以一种让算术操作变得困难的方式进行 token 化。比如数字 42235630 可能会被分成:
这会导致仅仅数字略有变化,就可能出现完全不同的 token 化结果。
更新一些的 LLM,比如 LLaMA,默认会把数字拆成一个个独立数字。为了改进 GPT 模型中的数字 token 化,LLMTime 会在数字之间加空格,并用逗号分隔每个时间步。对于固定精度场景,小数点则会被移除。例如:
会变成:
这种编码方式可以防止 GPT 模型产生奇怪的 token。不过在 LLaMA 模型中,加空格反而可能适得其反,因为每个数字和空格本身就已经是一个 token。继续加空格会无谓地拉长序列长度,并且可能让序列偏离训练分布。图 2-6 对比了基于 GPT 和基于 LLaMA 模型的 token 化方式。
图 2-6. GPT-3 和 LLaMA-2 的 token 化方式及其对预测性能的影响。加空格会帮助 GPT-3,因为它能让每个数字对应一个 token,从而提升性能;而 LLaMA-2 本来就会把数字单独 token 化,所以加空格反而会降低性能。图片改编自 Nate Gruver 等人(2024)。
Chronos 的做法,是把时间序列数值先缩放、再量化到一个固定词表中,然后用 Cross-Entropy Loss 在这些 token 化后的序列上训练现有的 Transformer 语言模型。图 2-7 展示了这一过程。
交叉熵损失(Cross-Entropy Loss)衡量的是预测概率分布(也叫 input logits)与目标变量真实分布之间的差异。
图 2-7. 为了生成 token 序列,输入时间序列会先进行缩放和量化。图片改编自 Abdul Fatir Ansari 等人(2024)。
Chronos 是一个把现有语言模型架构和训练流程适配到概率时间序列预测上的框架。要做到这一点,必须对 token 化方式做专门修改,因为虽然语言和时间序列都是序列,但它们的表示形式完全不同:自然语言使用有限词表,而时间序列是连续值。为了更具体一些,考虑如下时间序列:
这里,前 个时间步提供历史上下文,接下来的 个时间步表示预测区间。由于语言模型使用的是有限词表,因此要把它们适配到时间序列数据上,就必须把观测值 转换为有限 token 集合。Chronos 的实现方式,是先对观测值进行缩放,然后把它们量化到固定数量的 bins 中。
现在你已经了解了时间序列 token 化的复杂性,以及一些时间序列 Transformer 是如何处理这一挑战的。接下来,我们进入下一个部分,看看不同 Transformer 如何把这些设计整合到自身整体架构中。
Chronos:学习时间序列的语言
传统上,预测任务一直由统计模型主导,比如 ARIMA(自回归积分滑动平均)模型。然而,LLM 的 zero-shot 学习能力激发了人们对时间序列基础模型的兴趣。Chronos 在不修改模型架构的前提下,直接用时间序列训练标准语言模型。前一节中介绍的那些 token 会被输入到语言模型中,这个模型既可以是 encoder-decoder,也可以是 decoder-only。
训练使用的是 Cross-Entropy Loss。下面的方程给出了单条 token 化时间序列(包含 EOS token)的损失函数:
这里, 是由参数 所定义模型预测得到的类别分布。 是由 参数化的 Cross-Entropy Loss 函数。外层求和沿着预测区间展开,内层求和则遍历时间序列 token 词表 。训练时,这个损失会在一个 batch 的时间序列上取平均。
在推理阶段,模型会以自回归方式生成 token,并把它们映射回数值空间(反量化),这些数值是从预测分布中采样得到的:
为了得到预测分布,通常会采样多条轨迹。图 2-8 展示了这个过程。
图 2-8. 左侧是 Chronos 的训练过程,右侧是 Chronos 在推理阶段输出概率预测的过程。图片改编自 Abdul Fatir Ansari 等人。
Chronos 在来自多个应用领域的 13 个数据集上进行了训练,这些领域包括自然、能源、交通和 Web,采样频率从 5 分钟到按月不等。Chronos 基于 T5,一种 text-to-text 的 encoder-decoder Transformer。Chronos 可以执行 zero-shot 时间序列预测。图 2-9 展示了一个性能示例。
图 2-9. 航空旅客时间序列的历史数据与 zero-shot 预测。数据来自《Practical Time Series Analysis》(O’Reilly)。
当然,具体到你的数据,通常还是希望在自己的数据集上对模型做微调,下一节你就会开始做这件事。
微调 Chronos
为了增加一点趣味性,我这里使用的是亚马逊的历史股价数据,毕竟这个模型本身就是亚马逊开发的。Chronos 的训练和微调代码是公开可用的,不过我推荐你使用我修改过的版本,因为它修复了一些错误,并补充了 requirements.txt 文件。这里我不会展示如何下载股票价格数据,不过你可以在配套 notebook 里找到全部代码。
如果你想让 Chronos 用于你自己的时间序列数据,你需要先把数据转换成 Arrow 格式。示例 2-1 展示了一个将 DataFrame 转换为 Arrow 格式的 Python 函数。
示例 2-1. 将 DataFrame 转为 Chronos 所需的 Arrow 格式
def convert_to_arrow(
path: Union[str, Path],
time_series: Union[List[np.ndarray], np.ndarray],
start_times: Optional[Union[List[np.datetime64], np.ndarray]] = None,
compression: str = "lz4",
):
assert len(time_series) == len(start_times)
dataset = [
{"start": start, "target": ts} for ts, start in zip(time_series, start_times)
]
ArrowWriter(compression=compression).write_to_file(
dataset,
path=path,
)
接下来,你就可以像示例 2-2 那样,调用这个函数来转换时间序列数据。
示例 2-2. 将时间序列数据转换成 Arrow 格式
convert_to_arrow("./amazon_train_data.arrow",
time_series=train_time_series_list, start_times=train_start_times)
convert_to_arrow("./amazon_test_data.arrow",
time_series=test_time_series_list, start_times=test_start_times)
完成数据转换之后,你就可以开始配置训练参数。用于指定训练配置的 yaml 文件位于 GitHub 仓库的 configs 文件夹中。你可以在这里定义训练参数,例如:
context_length
以及训练数据路径等内容。或者,你也可以像示例 2-3 那样,在终端命令里直接覆盖这些参数。
示例 2-3. 微调 Chronos 的命令行参数
CUDA_VISIBLE_DEVICES=0 python /chronos-forecasting/scripts/training/train.py
--config /chronos-forecasting/scripts/training/configs/chronos-t5-base.yaml \
--model-id amazon/chronos-t5-base \
--no-random-init \
--max-steps 44600 \
--learning-rate 0.001
这条命令会使用一张 GPU 来训练 T5 base 模型。在 Google Colab 上,大约训练 1 小时左右,训练好的模型会保存到名为 output 的文件夹中,这个路径你也可以在 yaml 文件里修改。
如果你是在自己的数据上训练模型,通常还会希望把训练好的模型推送到 Hugging Face,这样之后可以更方便地用于预测。示例 2-4 展示了具体做法。
示例 2-4. 将微调后的模型推送到 Hugging Face Hub
os.environ["HF_TOKEN"] = "your_token"
pipeline = ChronosPipeline.from_pretrained(
"/content/output/run-3/checkpoint-final")
pipeline.model.model.push_to_hub("your-model-name"
, use_auth_token=os.getenv("HF_TOKEN"))
设置 Hugging Face token。
填入你的 Chronos 模型路径。
填入你的模型名称。
现在,你就可以像示例 2-5 那样,直接从 Hugging Face 加载模型并用于预测。
示例 2-5. Chronos pipeline
pipeline = ChronosPipeline.from_pretrained(
"your-model-name",
use_auth_token=os.getenv("HF_TOKEN"),
device_map="cuda",
torch_dtype=torch.bfloat16,
)
如果你只是想先在股票价格数据集上测试一下模型,也可以直接使用我已经微调好的 Chronos 版本。模型路径是 nicolepcx/chronos-t5-base-fine-tuned-AMZN-EOD。
这个模型的微调上下文长度为 21 天,预测区间为 5 天。预测结果如图 2-10 圈出的部分所示。
总体来说,对于一个基于 LLM 的时间序列预测模型,这个结果已经相当不错了。
图 2-10. 5 天股票价格预测结果图。
PatchTST:一个时间序列值 64 个词
PatchTST 不仅引入了 patching,也引入了 channel independence(通道独立性)。这意味着,对于多变量时间序列,每个通道都被视为一条独立的一元时间序列,并在不同序列之间共享同一套 embedding 和 Transformer 权重。多变量时间序列本质上是一个多通道信号,而每个 Transformer 输入 token 可以表示来自单个通道或多个通道的数据。
以往处理多变量时间序列的方法之一,是 channel mixing(通道混合):把所有时间序列特征组成的向量作为输入 token,再把它投影到 embedding 空间中,让信息发生混合。而在 channel independence 的设定下,每个输入 token 只包含单个通道的信息。数学上可以表示为:
这里, 是 lookback window,即 ,其中每个时间步 上的 都是一个维度为 的向量。接着,第 条一元序列可以表示为:
这里,序列从时间索引 1 开始,长度为 ,因此有:
这意味着,你会把输入 拆分成 条一元序列,并分别送入 Transformer。Transformer 随后会给出如下形式的预测结果:
这个过程如图 2-11 所示。
图 2-11. PatchTST 中通道独立性的整体示意图。图片改编自 Yuqi Nie 等人(2023)。
PatchTST 使用 Transformer encoder 将观测信号映射到潜在表示空间中。各个 patch 会通过可训练的线性投影以及可学习的加性位置编码,映射到维度为 (D) 的 Transformer 潜在空间中,从而保留 patch 之间的时间顺序。之后,每个多头注意力 head 会把这些输入转换为 query、key 和 value 矩阵。多头注意力模块使用 BatchNorm、带残差连接的前馈网络。整体架构如图 2-12 所示。
接下来的一节中,你将用自己的数据跑一次 PatchTST,看看它具体是怎么工作的。
图 2-12. PatchTST 架构总览。图片改编自 Yuqi Nie 等人(2023)。
在 IBM 历史股价上微调 PatchTST
这次我还是想让实验更有趣一点,于是用 IBM 的股票价格来做 PatchTST 预测,毕竟这个模型最初就是 IBM 主要推动的。这里我会省略数据集下载和准备过程,不过相关内容都能在本节配套 notebook 中找到。我使用了如下配置来初始化模型参数、设置 Optuna 超参数搜索以及构建 trainer。
示例 2-6. PatchTST 配置
context_length = 32
forecast_horizon = 10
patch_length = 8
num_workers = 16
batch_size = 128
下一步是做超参数搜索。你需要先初始化 Optuna 的 study 对象,并指定优化方向。Optuna 是一个超参数优化框架。
示例 2-7. Optuna 超参数试验空间设置
def optuna_hp_space(trial: optuna.Trial):
return {
"learning_rate": trial.suggest_loguniform(
"learning_rate", 1e-8, 1e-2),
"per_device_train_batch_size": trial.suggest_categorical(
"per_device_train_batch_size", [16, 32, 64, 128]),
"num_train_epochs": trial.suggest_int(
"num_train_epochs", 50, 300, step=20),
"dataloader_num_workers": trial.suggest_int(
"dataloader_num_workers", 0, 16, step=4),
"weight_decay": trial.suggest_float(
"weight_decay", 0.0, 0.3, step=0.05),
"per_device_eval_batch_size": trial.suggest_categorical(
"per_device_eval_batch_size", [16, 32, 64, 128]),
}
然后需要初始化 trial。
示例 2-8. 为 trial 初始化模型
def model_init(trial):
return PatchTSTForPrediction(config)
接着,你就可以像平时使用 Hugging Face Trainer 一样,初始化 training arguments。
示例 2-9. 初始化训练参数与 Trainer
training_args = TrainingArguments(
output_dir="./checkpoint/output_dir",
overwrite_output_dir=True,
do_eval=True,
evaluation_strategy="epoch",
save_strategy="epoch",
logging_strategy="epoch",
save_total_limit=3,
logging_dir="./checkpoint/logging_dir",
load_best_model_at_end=True,
metric_for_best_model="eval_loss",
greater_is_better=False,
num_train_epochs=200,
label_names=["future_values"],
)
trainer = Trainer(
model=None,
args=training_args,
train_dataset=train_dataset,
eval_dataset=valid_dataset,
model_init=model_init,
callbacks=[EarlyStoppingCallback(early_stopping_patience=30,
early_stopping_threshold=0.00001)]
)
最后一步,就是像示例 2-10 那样,启动 Optuna trial。
示例 2-10. 开始超参数搜索
best_run = trainer.hyperparameter_search(
backend="optuna",
n_trials=30,
direction="minimize",
)
超参数搜索完成之后,你就可以取出找到的最佳超参数,并据此在自己的数据上微调 PatchTST。示例 2-11 展示了这个过程。
示例 2-11. 使用最佳超参数进行微调
best_hyperparameters = best_run.hyperparameters
training_args = TrainingArguments(
output_dir="./checkpoint/output_dir",
overwrite_output_dir=True,
learning_rate=best_hyperparameters['learning_rate'],
per_device_train_batch_size=int(
best_hyperparameters['per_device_train_batch_size']),
do_eval=True,
evaluation_strategy="epoch",
save_strategy="epoch",
logging_strategy="epoch",
save_total_limit=3,
logging_dir="./checkpoint/logging_dir",
load_best_model_at_end=True,
metric_for_best_model="eval_loss",
greater_is_better=False,
num_train_epochs=200,
label_names=["future_values"],
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_dataset,
eval_dataset=valid_dataset,
callbacks=[EarlyStoppingCallback(early_stopping_patience=30,
early_stopping_threshold=0.00001)]
)
trainer.train()
现在你就可以评估模型并打印结果了,如示例 2-12 所示。
示例 2-12. 评估并打印结果
results_valid_dataset = trainer.evaluate(valid_dataset)
print("Valid Results:", results_valid_dataset)
results_test_dataset = trainer.evaluate(test_dataset)
print("Test Results:", results_test_dataset)
我得到的结果如示例 2-13 所示。
示例 2-13. 验证结果
Valid Results: {'eval_loss': 0.01785971038043499, 'eval_runtime': 1.0838,
'eval_samples_per_second': 1384.057, 'eval_steps_per_second': 173.468,
'epoch': 34.0}
Test Results: {'eval_loss': 0.04794887453317642, 'eval_runtime': 2.8294,
'eval_samples_per_second': 1086.082, 'eval_steps_per_second': 136.069,
'epoch': 34.0}
这里的结果使用的是均方误差(MSE)指标。对于股票这种高噪声数据,在 10 天预测区间上取得 0.0479 的测试集 MSE,可以算是一个不错的结果。
TimesFM:一个纯 Decoder 的时间序列基础模型
我相信你一定听说过 scaling law。Scaling law 指的是:随着数据量、参数量以及使用计算资源的增加,模型性能会以可预测的幂律形式提升。这意味着,仅仅依靠海量数据本身,模型就能在不额外借助目标时间序列数据的情况下,生成准确预测。
正是这一点,促使 TimesFM 背后的研究者提出了一个关键问题:“在海量时间序列数据上训练的大型预训练模型,能否学到那些可迁移到从未见过的数据集上的时间模式?”
我先不卖关子,直接告诉你答案:可以。TimesFM 展现出了很强的 zero-shot 能力。在一些场景下,只需要极少量微调,它就能达到与 SOTA 时间序列模型相当的效果。本节你就会做这件事:在一个按小时采样的能耗数据集上微调 TimesFM。不过在那之前,我先更详细地解释一下它的架构。
研究者提出了两个关键想法。第一,他们构建了一个大规模、异构的时间序列语料库,其中既包含真实世界数据源,比如 Google Trends 和 Wikipedia 页面浏览量,也包含合成数据集。这里所谓异构(heterogeneous),是指数据在类型、来源或时间模式上存在差异,比如不同序列的采样频率、单位或模态不同。这种多样性对于学习跨领域时间模式、以及在不同粒度之间泛化都非常重要,比如从 10 分钟级别的天气日志泛化到按年统计的零售销售数据。第二,他们使用了一个带输入 patching 的 decoder-only Transformer,它能够在保持训练效率的同时,扩展到不同的上下文长度和预测长度。这个 decoder-only 架构的关键组成包括:
- 基于 patch 的时间序列输入 token 化
- 用于 embedding 的残差多层感知机(MLP)模块
- 用于保留时间顺序的位置编码
- 用于自回归预测的 causal self-attention
- 解耦的输入 patch 长度与输出 patch 长度,使模型能够以更少步数进行长区间预测
下面我会把这些部分逐一拆开解释。
在读完 “PatchTST:一个时间序列值 64 个词” 那一节之后,这一部分你会觉得已经很熟悉了。正如你所学到的,PatchTST 把连续多个时间步当成一个 token,类似于视觉 Transformer 把图像 patch 当成 token 的做法。这使 PatchTST 能够捕捉局部时间结构、降低 attention 复杂度,并允许更长的历史回看窗口。TimesFM 正是在这个思路上继续往前走。
和 PatchTST 一样,TimesFM 也把时间序列片段视为 patch。区别在于,PatchTST 使用的是 encoder-based Transformer,而 TimesFM 采用的是 decoder-only 架构,更接近 GPT 或 Llama 这类语言模型。也就是说,每一条输入时间序列都会被切分成长度为 的连续、不重叠 patch。
这些 patch 会先经过一个带单隐层、且有 skip connection 的残差 MLP 模块,然后被映射为模型维度 上的定长向量。接着,再往这些 patch embedding 上加入位置编码,以保留它们在时间上的顺序。于是,第 个输入 token 进入后续 Transformer 层时,可写为:
其中,() 是第 个输入 patch,定义为:
是对应的 mask,符号 表示逐元素乘法,用来把输入 patch 中被 mask 的位置清零,从而在训练时模拟不同上下文长度。这一点对于 zero-shot 泛化和鲁棒性非常关键。 是加到 patch token 上的位置编码,用于保留时间顺序。
这样一来,长度为 的时间序列就会被转换成
个 token,这一点与 PatchTST 一样。不过,与 PatchTST 的通道独立和 encoder 映射不同,TimesFM 会用 causal attention 以自回归方式(从左到右)处理这些 token。
示例 2-14 和示例 2-15 中的代码展示了各个类是如何协同工作的,从而支撑 TimesFM 在 patching 和输入层上的这些架构创新。
示例 2-14. TimesFM 中的残差模块
class ResidualBlock(nn.Module):
def __init__(self, input_dims, hidden_dims, output_dims):
super(ResidualBlock, self).__init__()
self.input_dims = input_dims
self.hidden_dims = hidden_dims
self.output_dims = output_dims
# Hidden nonlinear transformation
self.hidden_layer = nn.Sequential(
nn.Linear(input_dims, hidden_dims),
nn.SiLU(),
)
# Output projection
self.output_layer = nn.Linear(hidden_dims, output_dims)
# Residual connection to project input directly to output_dims
self.residual_layer = nn.Linear(input_dims, output_dims)
def forward(self, x):
hidden = self.hidden_layer(x)
output = self.output_layer(hidden)
residual = self.residual_layer(x)
return output + residual
把输入 patch(例如 32 个时间步)投影到隐藏表示空间。
使用非线性激活增强学习能力。
把隐藏向量映射到模型的 Transformer 维度 。
直接的残差路径确保原始 patch 信息可以绕过中间变换。
把每个 patch 转换成可学习的高维 embedding,既用于输入,也用于自回归输出。
非线性路径用于建模复杂的局部依赖关系。
保证即使是较短输入 patch 或稀疏数据,也能提供有效信号。
通过把残差分支和 MLP 分支相加,使模型能在更少自回归步数下解码更长输出 patch。
这个模块会在两个地方使用:
- 把输入 patch \tildey}_j 转换成 Transformer token
- 把 decoder 输出 token 映射成多步预测的输出 patch(例如用 32 步上下文预测 128 步未来)
示例 2-15. TimesFM 中的 MLP 模块
class TransformerMLP(nn.Module):
def __init__(
self,
hidden_size: int,
intermediate_size: int,
):
super().__init__()
self.gate_proj = nn.Linear(hidden_size, intermediate_size)
self.down_proj = nn.Linear(intermediate_size, hidden_size)
self.layer_norm = nn.LayerNorm(normalized_shape=hidden_size, eps=1e-6)
def forward(self, x, paddings=None):
gate_inp = self.layer_norm(x)
gate = self.gate_proj(gate_inp)
gate = F.relu(gate)
outputs = self.down_proj(gate)
if paddings is not None:
outputs = outputs * (1.0 - paddings[:, :, None])
return outputs + x
把归一化后的输入 token 投影到中间特征空间。
把扩展后的表示压缩回模型原始维度 。
Layer normalization 保证即使面对变长 patch 序列,也能保持稳定学习。
在将 token 送入 MLP 前先做归一化(pre-norm 变体)。
第一层线性变换用于扩展模型表达能力。
ReLU 引入非线性,以建模更复杂的 token 内部模式。
将输出重新投影回原始 embedding 空间。
基于 padding 的 masking 保证被 mask 或 padding 的 patch(例如部分窗口)不会干扰 MLP 计算。
最后把输入残差加回输出,保证层间信息传递。
这个 MLP 被放在每一个 decoder layer 中,在 causal attention 之后对每个 token 做进一步处理,使得 TimesFM 能够处理:
- 训练时被 mask 的输入 patch(从而泛化到不同上下文长度)
- 解码时较长的输出 patch(尽量避免逐步生成)
这两个模块共同帮助模型避免完全逐步的自回归生成,同时保留 decoder 风格的泛化能力,并兼容变长、带 mask 的 patch 输入。这些输入 token 会通过 (n_l) 个 Transformer 层堆叠处理,并使用 causal self-attention。这保证了每个输出 token 只能关注过去或当前的 patch,而不能看到未来。其数学形式可写为:
这种设计使得模型能够进行自回归预测,也就是每一步预测只依赖此前已经观测到的数据,这一点与 LLM 风格的解码方式一致。
经过处理之后,每个输出 token 会通过第二个残差 MLP 模块映射成一个预测 patch。值得注意的是,TimesFM 支持输入 patch 长度和输出 patch 长度不同(比如输入长度 = 32,输出长度 = 128),这使得它能够用更少的步数预测更长序列。这种关系可以写成:
这种结构使长区间预测更加高效:模型不需要一次只生成一步,而是可以按更大的窗口向前跳跃。为了让模型在不同上下文长度下都具备鲁棒性,训练中会使用随机 patch masking 策略。这样模型必须学会从不完整历史中进行学习,从而提升其在 zero-shot 场景下的泛化能力。
举个例子,如果 patch 长度 (p=32),最大上下文长度是 512,那么训练时模型可能会随机 mask 掉第一个 patch 的前 4 个点,于是它看到的不是完整 32 步,而是 28 步。对所有窗口重复做这种处理之后,模型就能见到从 1 到 512 的各种可能上下文长度。
在按小时能耗数据上微调 TimesFM
TimesFM 论文配套了一个非常完整的代码仓库,甚至还在 Hugging Face 上提供了不同模型规模的 checkpoint。我克隆了这个仓库,并对其中提供的微调代码为你做了一些调整。下面来看示例 2-16 中最关键的步骤。
示例 2-16. 在按小时能耗数据上微调 TimesFM
def finetune_model():
model, hparams, tfm_config = get_model(load_weights=True)
config = FinetuningConfig(
batch_size=64,
num_epochs=5,
learning_rate=1e-4,
use_wandb=True,
freq_type=1,
log_every_n_steps=10,
val_check_interval=0.5,
use_quantile_loss=True
)
train_dataset, val_dataset = get_data(
context_len=192,
horizon_len=tfm_config.horizon_len,
freq_type=config.freq_type
)
finetuner = TimesFMFinetuner(model, config)
print("Starting finetuning...")
results = finetuner.finetune(
train_dataset=train_dataset,
val_dataset=val_dataset
)
print(f"Finetuning completed after {len
(results['history']['train_loss'])} epochs.")
mae, ctx, future, preds = evaluate_model(model, val_dataset)
plot_forecast(ctx, future, preds, save_path="timesfm_predictions.png")
print(f"Validation MAE: {mae:.4f}")
加载预训练好的 TimesFM 模型及其超参数和配置。把 load_weights=True 打开,表示从 foundation model checkpoint 开始训练。
启用 Weights & Biases 记录日志(可选,用于实验跟踪)。
指定时间序列的频率编码类型(例如按小时、按天)。
使用 quantile loss 而不是 MSE,这在概率预测任务中很常见。
加载训练集和验证集,并指定上下文长度(lookback)和预测区间。
初始化 finetuner 对象,它负责封装模型和训练循环逻辑。
用指定的数据集与配置启动微调循环,并返回包含训练统计信息的 history 对象。
在验证集上评估微调后的模型,并计算 MAE(平均绝对误差)。
对最后一个验证样本绘制预测图,并将结果保存到磁盘中,便于人工检查。
图 2-13 展示了训练结果。
图 2-13. 训练 5 个 epoch 后的微调结果。
考虑到我只在一张 A100 GPU 上训练了不到 30 分钟,这个微调结果已经相当不错了。
用于自监督异常检测的 AnomalyBERT
AnomalyBERT 能够检测复杂时间序列数据中的异常。这个模型的灵感来自 NLP 里的 BERT,但它改造了 masked language modeling 任务:不是随机遮蔽一部分输入,而是用 degraded data(退化数据)替换输入中的随机部分,然后训练模型识别出这些被退化的部分。图 2-14 展示了几种退化方式,包括 soft replacement、uniform replacement、peak noise 和 length adjustment。
Soft replacement 指的是用窗口外提取的一段序列替换当前序列,它表示原始片段与外部片段的加权和。Uniform replacement 是用常数值替换一段序列;length adjustment 指的是把序列拉长或缩短;peak noise 则是在序列中加入一个单独的峰值。通过这种数据退化技术,模型能够学习识别现实世界时间序列中各种不自然模式。
图 2-14. AnomalyBERT 训练数据中的退化样例。图片改编自 Yungi Jeong 等人(2023)。
AnomalyBERT 包含三个组成部分:线性 embedding 层、Transformer 主体和 prediction block。一个多变量时间序列窗口
会被送入模型,线性 embedding 层会把窗口 中每个数据 patch 投影为一个 embedding 特征。Transformer 主体会处理来自 的所有 embedding 特征,生成能够共享信息并反映时间上下文的潜在特征。Prediction block 则输出窗口中各个数据点的 anomaly score,分数越高表示该点越异常。该架构如图 2-15 所示。
图 2-15. AnomalyBERT 架构,其中 Transformer 主体由使用 1D relative position bias 的 Transformer 层组成。图片改编自 Yungi Jeong 等人(2023)。
Transformer encoder 是模型的主体,每一层都包含一个 multi-head self-attention(MSA)模块和一个 MLP 模块。每个模块前面都有一个 LayerNorm(LN)层,激活函数使用 Gaussian Error Linear Unit(GELU)。GELU 通过高斯累积分布来平滑输出:它会保留接近 0 的输入值,并以非线性方式缩放更大的输入值。AnomalyBERT 不使用正弦位置编码或绝对位置 embedding,而是在每个 attention 矩阵中加入一维相对位置偏置,以考虑不同特征之间的相对位置关系。每个 head 中带相对偏置的 self-attention 可以写成:
这里, 分别是输入特征的 query、key 和 value, 是某个 attention head 中特征的维度。 是相对位置偏置:
其中每个元素 ,来自一个可学习偏置表。每个 MSA 模块都会使用不同的位置偏置。
AnomalyBERT 在 5 个广泛使用的基准数据集上进行了测试,这些数据集都包含无标签训练集和带标签测试集:
- Secure water treatment(SWaT)
SWaT 是一个来自水处理测试平台的数据集,用于模拟真实世界的水处理过程,包含各种传感器和执行器数据。 - Water distribution(WADI)
WADI 是从一个供水分配测试平台采集的数据集,记录了供水系统随时间变化的行为。和 SWaT 一样,它也包含传感器和执行器数据。 - Soil moisture active passive(SMAP)
SMAP 是 NASA 土壤湿度主被动卫星任务产生的数据集,包含与土壤湿度相关的卫星测量时间序列数据。 - Mars science laboratory(MSL)
MSL 是从 NASA 火星车采集的数据集,记录了各种遥测数据点,用于监控火星车健康状态并检测异常。 - Server machine dataset
Server machine dataset 是从服务器中采集的数据集,记录了 CPU 使用率、内存使用率和网络流量等指标,用于模拟真实世界中的服务器性能问题。
AnomalyBERT 在所有这些数据集上都优于以往方法。
总结
在本章中,你了解了 Transformer 在时间序列建模中的变革性作用,也看到了它在捕捉长序列交互方面,相比传统 RNN 模型的优势。
你深入学习了时间序列中的关键概念,例如自相关、协整、交叉相关和平稳性,并理解了它们在时间序列分析中的重要性。除此之外,你还学习了处理趋势和季节性的技术,并认识到正确的数据准备对于避免偏差、提升模型性能有多么关键。
你还考察了时间序列建模的多种应用场景,从预测和分类,到异常检测和时空预测。沿着这条路径,你也理解了 Transformer 是如何通过不同方式适配这些挑战的:例如使用 lagged features(Lag-Llama)、量化词表(Chronos)或基于 patch 的分段方式(PatchTST、TimesFM)来对连续输入进行 token 化。
你学习了如何微调 Chronos、PatchTST 和 TimesFM,也了解了 AnomalyBERT 如何通过退化数据学习出适用于无监督异常检测的鲁棒表示。
从自回归 decoder,到带 mask 的 Transformer encoder;从量化后的数字 token,到连续 patch 表示——这一章帮助你深入理解了,基于 Transformer 的模型是如何被改造,用来处理多种领域中的结构化、序列化、且强时间属性的数据的。
在第 3 章中,你将学习用于视觉任务的 Transformer。视觉也是一个极大受益于基础模型及其 few-shot 和 zero-shot 能力的领域。