量化校准为何在金融时序预测中面临挑战?PTQ与分布漂移解析
2026/8/31 10:24:36 网站建设 项目流程

这两年做深度学习模型部署的同学,对训练后量化(Post-Training Quantization,PTQ)应该不会陌生。模型训练完之后不重新训练,直接在推理阶段把权重甚至激活从 FP32 压缩到 INT8,换来更小的体积、更低的显存占用和更高的吞吐。这个流程放到普通图像模型上,通常很快就能跑通;但放到金融时间序列预测(Financial Time-Series Forecasting)场景里,问题会变得微妙很多。因为 PTQ 里有一个绕不开的环节叫校准(Calibration),而校准依赖的数据必须从历史里选取。模型要预测的却是未来。一旦“历史的分布”和“未来的分布”不一致,量化参数就可能失效,精度甚至会明显下滑。

本文会先拆解 PTQ、校准、时间序列预测这三者的关系,然后解释为什么说“Calibration Bets on the Past”(校准在押注过去),最后用 PyTorch 做一个带分布漂移的合成金融序列实验,对比不同校准策略在量化后的效果差异。整个过程包含完整可复现代码,适合正在做模型压缩、时序预测部署或对量化原理感兴趣的读者。

1. 这个标题到底在说什么

1.1 三个关键词拆开看

先把标题拆成三个部分。

Post-Training Quantization(训练后量化):这是一种模型压缩技术。训练好的浮点模型,不需要重新训练或微调,直接通过量化映射,把权重和激活值从 FP32 转成 INT8。它最大的优点是成本低,适合已经训练好、不方便再动训练流程的模型。

Calibration(校准):在静态量化里,校准是指用一小部分有代表性的数据去跑一遍模型,统计每一层激活值的数值范围,从而确定量化参数。校准集选得好不好,直接决定量化后的精度损失。

Time-Series Forecasting(时间序列预测):用过去一段时间的观测数据,预测未来一段时间的数值。金融领域中常见的例子包括股票收益率预测、波动率预测、成交量预测等。

三者的关系可以这样理解:PTQ 需要校准,校准需要“过去的数据”,而时间序列预测面对的是“未来的数据”。如果过去和未来的统计分布不一致,那么校准得到的量化参数就是过时的。

1.2 金融时序预测为什么需要量化

金融场景对推理延迟和资源消耗非常敏感。一个风控模型可能需要同时在线服务大量请求,一个高频策略可能需要把单次推理延迟压到毫秒级,一个交易终端可能部署在性能有限的边缘设备上。这些都要求模型在保证精度的前提下,尽可能小、尽可能快。

训练后量化正好满足这种需求:

  • 模型体积变小,通常能降到原来的四分之一左右;
  • 推理速度提升,INT8 在支持加速的硬件上有明显的吞吐优势;
  • 显存占用降低,同一块 GPU 可以部署更多模型副本;
  • 部署成本下降,对硬件算力要求更低。

1.3 校准与“过去”之间的冲突

我们常说“用历史数据训练模型,用历史数据做验证”,这在时间序列预测里是常规操作。但量化校准有一个特殊之处:它相当于用一小段历史数据,去给整个模型的数值分布“定格”。

如果未来数据和这段历史数据分布一致,那没问题;但如果市场状态切换、波动率放大、出现极端行情,激活值的分布就会偏离校准时的范围,量化参数就失效了。这就是标题里“Bets on the Past”想表达的意思:校准本质上是在赌历史分布还能代表未来分布。这个赌注在图像分类等分布相对稳定的任务上通常能赢,在金融时间序列上却经常输。

2. 训练后量化(PTQ)的原理拆解

2.1 量化要解决什么问题

量化的大方向,是把连续取值范围的浮点数映射到离散取值范围的整数。最常见的做法是把 FP32 权重映射到 INT8。INT8 只有 256 个离散取值,而 FP32 有约 42 亿个可表示值,所以这个映射过程必然会有精度损失。我们的目标是:让精度损失足够小,小到业务可以接受。

量化之所以有效,是因为神经网络在训练之后,权重和激活值的分布通常集中在一个有限范围内,很多参数实际上是冗余的。我们不需要那么高的数值精度,也能让模型保持较好的预测能力。

2.2 量化参数:scale 与 zero_point

量化常见的公式如下:

非对称量化:

q = clamp(round(x / scale) + zero_point, qmin, qmax)

反量化:

x_approx = (q - zero_point) * scale

其中:

  • scale是缩放因子,决定浮点数值映射到整数时的步长;
  • zero_point是零点偏移,让浮点 0 精确映射到整数 0;
  • qminqmax是量化后的整数范围,INT8 就是 -128 到 127。

对称量化更简单,zero_point固定为 0:

q = clamp(round(x / scale), qmin, qmax)

对称量化实现简单,在权重量化里很常用。scale通常通过统计激活值或权重的绝对值最大值得到:

scale = max(|tensor|) / 127

可以看到,scale的取值完全取决于我们观察到的一组数据范围。这组数据选得好不好,直接影响量化误差。

2.3 PTQ 的三种模式

训练后量化并不是只有一种玩法,按量化范围可以分为三类。

动态量化:只把权重量化成 INT8,激活在推理时仍是浮点,但会根据实际输入动态计算量化参数。它不需要校准数据,实现成本最低,适合 LSTM、Transformer 这类结构。

静态量化:权重和激活都量化成 INT8。激活的量化范围必须在部署前确定,这就需要通过校准数据提前统计激活分布。静态量化通常比动态量化速度更快,但流程更复杂。

混合量化:对精度敏感的层保持 FP16 或 FP32,对不敏感的层用 INT8。这是工业界常见的折中方案。

在本文的实验中,我会先演示动态量化,然后自定义一个校准模拟流程,用来解释静态量化中的校准为什么关键。

2.4 校准:量化前最关键的一步

校准本质上是在回答一个问题:模型每一层激活值的分布范围到底是多少?

校准流程一般是这样:

  1. 准备一小部分有代表性的数据,通常从训练集或验证集里抽取;
  2. 以推理模式跑一遍模型,记录每一层激活值的数值分布;
  3. 根据统计结果,为每一层计算scalezero_point

常用的统计方法包括:

  • MinMax:直接取激活值绝对值的最大值。实现简单,但容易被离群点带偏。
  • Percentile:取绝对值分布的第 99 百分位或 99.9 百分位,抗离群点能力更强。
  • MSE / Entropy:尝试不同的候选scale,选择量化前后误差最小或信息损失最小的那个。

在校准这一步,有两个关键点常常被忽略:

第一,校准不是训练。校准过程不会更新模型参数,只是统计数值范围。

第二,校准集不是越大越好。校准集的“代表性”比“样本量”更重要。如果校准集内部包含了不同分布状态的数据,统计出来的范围可能覆盖过宽,导致正常区间的量化分辨率变差。

3. 为什么说校准是在“押注过去”

3.1 校准数据集天然来自历史

无论你从训练集、验证集还是最近一段真实观测里取校准数据,一个无法回避的事实是:校准数据一定是已经发生过的数据。它不可能来自未来。

这本身不是问题。问题在于,量化参数一旦确定,部署后的模型就会一直用这组参数去处理新数据。如果新数据的分布和校准数据分布不一致,原本“为了这段分布精心设计”的量化参数就失去了意义。

3.2 金融时序数据的非平稳性

图像分类的输入分布相对稳定:猫的图片在不同时间拍出来,像素分布不会有太大差异。所以图像模型的校准集可以几个月不更新。

金融时间序列则完全不同。它有几个典型的非平稳特征:

  • 波动率聚集:高波动往往伴随高波动,低波动往往伴随低波动;
  • 状态切换:市场可能从温和状态突然切换到剧烈波动状态;
  • 结构性变化:政策调整、市场规则变化、突发事件,都会让数据分布发生突变;
  • 极端值:价格数据中经常出现远超正常范围的异常值。

这些特征决定了:校准集里统计到的激活值范围,很可能在部署后的某个时间点被突破。

对比维度图像分类金融时间序列预测
输入分布相对稳定非平稳,存在状态切换
校准集有效期通常可以长期使用可能很快失效
离群值影响大,且离群值本身可能包含关键信息
重校准频率需要高频监控

3.3 校准失效时的直观表现

校准失效的直接后果是量化误差变大。

假如校准集来自一段低波动时期,激活值范围很小,算出来的scale偏小。当未来出现高波动数据时,激活值绝对值很容易超过scale * 127,于是发生截断。被截断的信息无法恢复,模型输出自然偏离预期。

反过来,如果校准集来自一段高波动时期,scale偏大,正常时期的激活值只能落在整数区间的一小部分里,量化分辨率被浪费,同样会引入不必要的误差。

这正是“押注过去”的风险:你赌的是校准集分布能延续到部署时,但金融时序经常会打破这个赌注。

4. 环境准备与实验设计

4.1 运行环境

本文代码基于以下环境:

  • Python 3.9 或更高版本;
  • PyTorch 2.x;
  • NumPy、Pandas。

版本可以根据你的实际环境调整,重点在于理解流程和实验思路。建议在一个干净的虚拟环境里运行:

pip install torch pandas numpy

如果你用的是 CPU 环境,本文实验也能正常运行,因为模型规模很小。

4.2 实验设计思路

为了把“校准押注过去”这个问题可视化,我设计了一个对比实验。

实验用合成数据模拟一段带分布漂移的金融时间序列:前一段平稳,中段波动率放大,后段再次放大。然后训练一个 LSTM 模型做收益率预测。

关键步骤是准备两组不同的校准数据:

  • 校准集 A:从训练集内部选取,代表“陈旧的过去”;
  • 校准集 B:从验证集前端选取,代表“最近已知的新分布”;
  • 验证集:选取分布漂移之后的数据。

最后对比:用校准集 A 和校准集 B 分别计算量化参数,在验证集上评估量化后的预测误差。

这样设计的好处是:两组校准集来自同一模型、同一数据集,唯一区别是“校准数据与验证数据的分布接近程度”,可以很直观地看出校准效果如何受数据分布漂移影响。

5. 完整实战:金融时序模型的 PTQ 校准对比

下面代码建议按顺序保存到同一个脚本文件,例如ptq_calibration_demo.py,然后直接运行。

5.1 生成带分布漂移的金融序列

首先构造一个合成的收益率序列。我故意在中间和末尾注入波动率放大,模拟金融时间序列常见的 regime switch。

import os import numpy as np import pandas as pd import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset # 固定随机种子,保证可复现 np.random.seed(42) torch.manual_seed(42) # 生成 5000 个收益率数据点 n = 5000 returns = np.random.normal(loc=0.0002, scale=0.01, size=n) # 注入波动率漂移: # 800~1600 波动放大 2.5 倍 returns[800:1600] = returns[800:1600] * 2.5 # 2400 之后波动再次放大 3 倍 returns[2400:] = returns[2400:] * 3.0 price = 100 * np.exp(np.cumsum(returns)) df = pd.DataFrame({ "ret": returns, "price": price, "pct_change": np.concatenate([[0], np.diff(price) / price[:-1]]), "vol": pd.Series(returns).rolling(20).std().fillna(returns.std()).values }) print(df.head())

这里的vol是滚动标准差,用来刻画阶段性波动水平;pct_change是价格涨跌幅。这样构造出来的序列具有明显的阶段特征:平稳段、高波动段、再次高波动段。

5.2 构造特征窗口与数据集

时间序列预测一般用过去window个时刻的特征预测下一时刻的收益率。这里窗口大小设为 20。

def build_windows(df, window=20): ret = df["ret"].values vol = df["vol"].values pct = df["pct_change"].values X, y = [], [] for i in range(window, len(df)): X.append(np.stack([ ret[i - window:i], vol[i - window:i], pct[i - window:i] ], axis=-1)) y.append(ret[i]) return np.array(X), np.array(y) WINDOW = 20 X, y = build_windows(df, WINDOW) print("X shape:", X.shape) # (N, 20, 3) print("y shape:", y.shape) # (N,)

接下来划分数据集。训练集取前 2000 个样本;校准集 A 来自训练集内部;校准集 B 来自验证集前端;验证集取漂移后的区间。

train_X, train_y = X[:2000], y[:2000] # 校准集 A:陈旧历史数据,来自训练集内部 calib_a_X, calib_a_y = X[1000:1200], y[1000:1200] # 校准集 B:最近观测数据,来自验证集前端 calib_b_X, calib_b_y = X[2700:2900], y[2700:2900] # 验证集:分布漂移后的数据 val_X, val_y = X[2900:4000], y[2900:4000] def make_loader(X, y, batch_size=64, shuffle=True): dataset = TensorDataset( torch.tensor(X, dtype=torch.float32), torch.tensor(y, dtype=torch.float32) ) return DataLoader(dataset, batch_size=batch_size, shuffle=shuffle) train_loader = make_loader(train_X, train_y, batch_size=64, shuffle=True) calib_a_loader = make_loader(calib_a_X, calib_a_y, batch_size=64, shuffle=False) calib_b_loader = make_loader(calib_b_X, calib_b_y, batch_size=64, shuffle=False) val_loader = make_loader(val_X, val_y, batch_size=64, shuffle=False)

需要注意的是,验证集并不参与训练,校准集 B 与验证集之间存在时间

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询