土法炼钢兴趣小组的算法知识备份

【量化交易】时间序列深度学习:TCN、Transformer 在量化的实践与陷阱

文章导航

分类入口
quant
标签入口
#deep-learning#tcn#transformer#time-series#finance

目录

把时序深度学习塞进量化系统,是过去几年里被反复尝试又反复失败的工程。在工业界做过两轮以上 ML 项目的工程师都见过类似的循环:研究员从 NeurIPS、ICLR 上挑一个看起来在 ETT、Electricity、Traffic 这类公开时序数据集上 MSE 降低 8% 到 15% 的新模型,把它直接搬到分钟级行情或者日频量价上,第一周回测看起来比 LightGBM 略好,第二周开始衰减,第三周怀疑数据,第四周发现是标签里混进了未来函数,半年后这个模型被静悄悄地从生产环境下线。问题极少出在模型架构本身,绝大多数出在三件事:标签和窗口的口径错了、训练-推理之间的统计分布不对齐、模型对随机种子和初始化的敏感度被严重低估。

这篇文章不打算介绍 LSTM 是什么、Self-Attention 是怎么算的——这类内容在任何一本深度学习教材里都比我写得好。我把视角放在「在量化场景里,用时序深度学习要解决的真正工程问题」上:什么时候它真的比 GBDT 强、TCN 的三件套(因果卷积、扩张卷积、残差)在分钟线上要怎么调、Transformer 的位置编码和稀疏注意力在金融数据上要怎么改、IC loss 和 Quantile loss 在选股和择时上要怎么选、过拟合与种子敏感性怎么靠集成压住、与传统因子怎么做残差融合而不是直接替换、上线时的 ONNX/TorchScript/延迟监控要怎么落地。代码用 PyTorch 2.x 写,评估用 vectorbt,实验环境是单卡 RTX 4090,CUDA 12.4,Python 3.11,所有依赖都来自标准生态库。

风险提示:本文出现的所有模型结构、超参、损失函数、回测指标均用于说明工程方法,不构成任何投资建议。时序深度学习在金融数据上的过拟合风险显著高于截面 GBDT;不同市场、不同周期、不同样本上的表现差异极大。任何把本文示例直接搬到实盘的尝试都需要结合所在市场的交易规则、合规约束、自身风险承受能力做完整评估。文中提到的 IC、Sharpe、回撤等指标在多重检验、未来函数、统计分布漂移没有被妥善处理时,都可能严重高估真实表现。


一、深度学习在量化中的位置

把深度学习放回量化系统的层级图里,它的位置比初学者想象的要克制得多。第十二篇里我把量化系统拆成了五层(数据、因子、信号、组合、执行);时序深度学习不是这五层之一,而是嵌入在「因子」和「信号」两层里、承担一类被传统模型做得不够好的具体任务。先把这件事讲清楚,下文所有讨论才有锚点。

与第十二篇的边界

第十二篇讨论的是「截面 + GBDT」的机器学习选股,主战场是日频 alpha;本文讨论的是「时序 + 深度网络」,主战场在更高频段或者另类数据。两类工作不是替代关系,而是互补关系。具体来说:

什么时候真的有用

时序深度学习真正比传统方法(线性、GBDT、HMM、状态空间模型)强的场景,工程经验里基本只有四类。

第一类是高频微结构特征的端到端学习。 订单簿前若干档的量价、撤单率、主动单占比、订单到达间隔,这些特征本身就是时序结构,且彼此之间存在强非线性交互。手工写因子永远写不全;用 TCN 或者一维卷积+小型 Transformer 直接从原始 Tick 学习一个微秒级到分钟级的预测信号,是被多家自营/做市商验证过有效的路径。在这种场景里,深度网络的优势来自「特征工程一体化」,而不是「模型本身有多神」。

第二类是日内择时上的非线性时序模式。 比如某些股票在开盘前 30 分钟和收盘前 15 分钟存在结构性反转,或者在特定波动率分位上存在均值回归。如果手工写规则,需要枚举大量条件分支;如果用线性回归,会被波动率与方向之间的非线性关系限制。一个浅 TCN 配合 Quantile loss 通常可以把这类模式压成一个稳定的方向信号。

第三类是另类数据上的时序对齐和延迟预测。 比如卫星图像的车流变化对某个零售公司的财报指引、电力负荷数据对工业品价格的领先关系、新闻文本情感对个股波动的延迟传导。这些场景里「领先关系」本身是模型要学习的对象,传统方法很难处理变长的延迟结构。

第四类是宏观/利率/期限结构上的多变量时序联合建模。 比如把美债期限结构、利差、隐含波动率、商品价格当成一个多变量序列,用 N-BEATS 或 TFT 这类模型同时预测多个目标。在这种场景里,深度模型的优势主要来自「联合建模」而不是「时序特征学习」。

什么时候是花架子

剩下的大量场景,时序深度学习都属于「能跑出来但不一定真的好」的范畴。最容易被误用的两类。

第一类是日频选股。 截面上每天大约 5000 个标的,每个标的有几十到几百个因子,标签是未来 5 至 10 日排名收益。这是 GBDT 的主场。把它转成「序列建模」——比如对每只股票取过去 30 天的因子序列丢进 LSTM——通常没有显著收益。原因是日频选股的核心信息在「截面相对关系」而不是「单标的时序结构」,序列模型反而把截面信号稀释了。第十二篇用的 LightGBM + Purged CV 在这个场景里几乎是上限。

第二类是把单只资产收益率当成预测目标。 文献里有大量「LSTM 预测股票收益率,MSE 比 ARIMA 低 X%」的论文。这类工作绝大多数在样本外不可复现,原因是单标的收益率序列的信噪比极低(年化波动 30% 对应日波动约 1.9%,但日均收益只有几个 bp),加上非平稳,模型学到的几乎都是噪声。如果非要做单资产的时序预测,先问一个问题:用一个简单的「过去 5 日均值 + 行业 beta」基线模型,新模型相对它的提升是多少?提升小于 10% 时,深度学习几乎一定不是答案。

工程上的三个判据

我把决策简化成三个问题,问完再决定要不要上时序深度学习:

  1. 任务里是否有显著的时序结构? 如果用「截面排序 + 行业市值中性 + 等权打分」就能解决,不需要深度学习。
  2. 样本量是否足够? 时序深度网络的等价独立样本比 GBDT 更难估计。一个 5 年分钟级单标的序列大约 30 万样本,但相邻样本的相关极强,等价独立样本可能只有几千。在这种规模上跑一个有几百万参数的 Transformer,过拟合是几乎必然的。
  3. 是否有充分的算力和工程预算? 时序深度学习的端到端工程成本(数据管道、训练框架、模型监控、上线部署)通常是 GBDT 的 3 到 5 倍。如果团队不到 3 个工程师,绝大多数时候应当先把 GBDT 用透。

这三个问题里只要有一个回答是「否」,结论就是先不用。


二、序列模型家族

把过去十年用于金融时序的主流模型按「核心机制」分成五类,各取一个代表,列出它们的工程取舍。这一节不展开数学,重点是工程师在选型时需要知道的「这个模型在金融数据上会踩到什么坑」。

RNN / LSTM / GRU

最早被引入金融的序列模型,结构是循环单元加上门控。LSTM 和 GRU 解决了原始 RNN 的梯度消失问题,理论上可以处理任意长的序列。

工程上的优点:参数少、推理时延可以做到逐步增量、对短序列(≤128 步)拟合稳定。缺点也很明确:训练时无法并行,长序列训练极慢;对超参(hidden_size、层数、dropout)敏感;在分钟级以上的长序列上,「记忆衰减」让它变得几乎和 5 日均值差不多。

我个人在 2018-2020 年做过相当多 LSTM 的工程化尝试。结论是:除非你的目标场景是 Tick 级低延迟逐步预测(这种场景下 LSTM 的「上一步隐藏状态可缓存」是真的有用),否则在 2024 年以后,LSTM 几乎都被 TCN 或者轻量 Transformer 替代了。

TCN(Temporal Convolutional Network)

把一维卷积加上「因果」(卷积核只看过去)和「扩张」(dilation 指数级扩大感受野)两个约束,再叠上残差连接。Bai 等人 2018 年的论文系统比较了 TCN 和 LSTM,在多个序列建模任务上 TCN 不落下风甚至更好。

工程上的优点:完全可并行训练(速度比 LSTM 快 5 至 20 倍)、感受野通过 dilation 显式控制、模型行为可解释、对超参不敏感。缺点:长序列下显存随感受野线性增长(虽然慢于 Transformer 的平方)、对「全局上下文」的捕捉不如注意力机制。

在分钟级行情、订单簿、日频量价等大多数金融场景里,TCN 是当前最被低估的工程选择。它的训练速度快、调参简单、推理时延可控,是把深度学习引入量化的最佳起点。本文第三节会详细展开。

Transformer

注意力机制在 NLP 上的成功被搬到时序后,最早是 Informer(AAAI 2021)和 Autoformer(NeurIPS 2021)。这一支的核心改造是「稀疏注意力」和「位置编码适配时序」。

工程上的优点:长程依赖捕捉强、并行性好、扩展性好。缺点:注意力计算的 O(L²) 复杂度让长序列必须稀疏化、对位置编码极度敏感、需要的样本量比 TCN 大一个数量级、随机种子敏感性高。

在金融数据上,Transformer 不是「直接拿来就比 TCN 好」的模型。第四节会详细讨论它在金融上的三个适配点:位置编码改造、注意力稀疏化、长序列与显存。

Informer / Autoformer / FEDformer

针对原版 Transformer 在长时序上的复杂度问题做的改造。Informer 引入 ProbSparse 注意力(只计算 query 的 top-u 个 key),Autoformer 引入序列分解和自相关注意力,FEDformer 引入频域注意力。这一支的工作在公开数据集(ETT、Electricity)上的指标普遍优于原版 Transformer,但在金融数据上的提升不稳定。

我的工程经验:这类模型在「平稳、周期性强」的数据(电力、交通、气象)上效果好,因为它们的设计假设里隐含了「周期性 + 趋势 + 残差」的分解结构。金融数据的非平稳和无明显周期让这些假设失效,所以指标提升经常不可复现。如果你的具体场景里时序确实有强周期(比如日内的开盘-午休-收盘节奏),可以试 Autoformer;否则优先 TCN 或 PatchTST。

PatchTST

ICLR 2023 的工作。核心是把时序按固定长度切成 patch(类似 ViT 把图像切成 patch),然后在 patch 之间做注意力。这个简单的改动让模型在多个时序基准上超过了 Informer/Autoformer 系列。

PatchTST 的工程价值在金融上比绝大多数 Former 变体大。原因是「patch」这个操作天然契合金融数据的「子区间统计量」直觉——一个 16 分钟的 patch 可以被看作是一个聚合后的时间块。把 1024 步的分钟序列变成 64 个 16 分钟 patch,不仅注意力复杂度从 1024² 降到 64²,模型也更难过拟合到分钟级噪声。

N-BEATS / NHITS / TimesNet

更近的「纯 MLP / 纯频域」尝试。N-BEATS 完全用堆叠的全连接 + 残差实现时序预测;NHITS 在此基础上加了多尺度池化;TimesNet 把序列折成 2D 张量做 2D 卷积。这三个模型的共同特点是「绕开 RNN 和 Transformer 的固有缺陷」。

在金融数据上,N-BEATS 的 trend-seasonality 分解假设和 Autoformer 类似,对周期性弱的数据帮助有限。TimesNet 的 2D 折叠思路在「日内 × 跨日」的双周期数据上有意外效果,可以试。

选型小结

把上述模型在金融数据上的工程取舍总结一下:

关于「新模型必然更好」的认知偏差

补一句工程经验:研究员每年都会从顶会上找到「最新最强的时序模型」,然后说服团队替换掉现有架构。这种替换在金融场景里成功的比例极低,原因有三。

第一,时序基准数据集(ETT、ECL、Traffic、Weather)和金融数据的统计性质差异很大。这些数据是平稳的、有明显周期的、信噪比高的;金融数据是非平稳的、无明显周期的、信噪比极低的。在前者上 MSE 降低 10% 不代表在后者上 IC 会提升。

第二,新模型的论文实验通常用的是「lookback=336,predict=96」这种短期预测设定;金融场景里我们关心的是「lookback=512,predict=1」(只预测下一步),两者的最优结构差异巨大。

第三,新模型的训练协议(学习率、warmup、scheduler)是为它的论文实验调过的,搬到金融数据上要重新调,调不好就显得「不如旧模型」。

我个人的工程默认是「先把 TCN 用透,做出来 IC=0.05 再考虑换 Transformer」。这条路径上踩到的工程坑远小于「上来就用最新的 X-Former」。


三、TCN 详解

TCN 是这一节的主角。它的三件套——因果卷积、扩张卷积、残差连接——结构简单到可以在一页代码里写完,但每一件都对金融时序的工程稳定性至关重要。

因果卷积

普通一维卷积在时间步 \(t\) 上的输出依赖窗口 \([t-k/2, t+k/2]\) 的输入。在金融场景里,这种「看到未来」是绝对不允许的——任何用未来信息训练出来的模型在样本外都会立刻失效。

因果卷积的定义是:时间步 \(t\) 的输出只依赖 \([t-k+1, t]\) 的输入。工程实现是把卷积核左对齐,再在序列前端 padding \(k-1\) 个零(或者前向 padding,不在后端 pad)。PyTorch 的 nn.Conv1d 没有原生的「causal」参数,但可以通过先 F.pad(x, (k-1, 0)) 再做无 padding 的卷积实现。

代码上看:

import torch
import torch.nn as nn
import torch.nn.functional as F


class CausalConv1d(nn.Module):
    """因果一维卷积:输出 t 只依赖输入 [t-(k-1)*d, t]。"""

    def __init__(self, in_ch: int, out_ch: int, kernel_size: int, dilation: int = 1):
        super().__init__()
        self.kernel_size = kernel_size
        self.dilation = dilation
        self.pad = (kernel_size - 1) * dilation
        self.conv = nn.Conv1d(in_ch, out_ch, kernel_size, dilation=dilation)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # x: (B, C, L)
        x = F.pad(x, (self.pad, 0))
        return self.conv(x)

这段代码看起来平凡,但工程上有两个细节经常被忘掉:

  1. 输入张量布局必须是 (B, C, L),不是 (B, L, C)。后者是 RNN/Transformer 的常见布局,TCN 用错布局会让卷积在错误的维度上滑动,模型还能训练但学到的是噪声。
  2. dilation 越大,需要 pad 的数量越多。在多层 TCN 里,最深层 padding 的总长度是 \(\sum (k-1) \cdot d_l\),必须确保输入序列长度大于这个数,否则有效感受野会被截断。

扩张卷积

普通因果卷积每层的感受野只增加 \(k-1\),要覆盖 256 步的历史窗口需要 256/(k-1) 层,太深。扩张卷积让卷积核在时间维度上跳着采样,第 \(l\) 层的 dilation 取 \(2^l\),使得 \(L\) 层的感受野是 \(1 + (k-1) \cdot (2^L - 1)\)

举个具体的例子:kernel_size=3,4 层 TCN,dilation 依次为 1、2、4、8,感受野 \(1 + 2 \cdot (1+2+4+8) = 31\)。如果再深一层(dilation=16),感受野直接到 63。指数增长意味着用 8 层就能覆盖 511 步。

工程上的两个建议:

残差连接

深度 TCN 不加残差几乎不能训。残差块的标准结构是:两层因果扩张卷积 + 权重归一化 + 激活函数 + Dropout,再加一条从输入到输出的 skip connection。如果输入输出通道数不匹配,skip 上加一个 1×1 卷积做投影。

class TemporalBlock(nn.Module):
    """TCN 的一个残差块:两层 CausalConv + 残差。"""

    def __init__(
        self,
        in_ch: int,
        out_ch: int,
        kernel_size: int,
        dilation: int,
        dropout: float = 0.1,
    ):
        super().__init__()
        self.conv1 = nn.utils.weight_norm(
            nn.Conv1d(in_ch, out_ch, kernel_size, dilation=dilation)
        )
        self.conv2 = nn.utils.weight_norm(
            nn.Conv1d(out_ch, out_ch, kernel_size, dilation=dilation)
        )
        self.pad = (kernel_size - 1) * dilation
        self.dropout = nn.Dropout(dropout)
        self.act = nn.GELU()
        self.downsample = (
            nn.Conv1d(in_ch, out_ch, 1) if in_ch != out_ch else nn.Identity()
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        out = F.pad(x, (self.pad, 0))
        out = self.act(self.conv1(out))
        out = self.dropout(out)
        out = F.pad(out, (self.pad, 0))
        out = self.act(self.conv2(out))
        out = self.dropout(out)
        return out + self.downsample(x)

激活函数我用的是 GELU 而不是原版 TCN 的 ReLU。在金融时序里 GELU 比 ReLU 稳定一些,原因是 ReLU 在「负方向输入」上完全死区,而金融特征经常是有符号的(动量、价差、成交量异常),死区会让一部分特征被永久屏蔽。

权重归一化(nn.utils.weight_norm)比 BatchNorm 更适合 TCN。BatchNorm 在小 batch 或者多任务训练里经常出问题,且推理时还要维护 running mean/var;weight_norm 没有这些问题。LayerNorm 也可以但稍慢。

完整 TCN 网络

把残差块堆叠起来:

class TCN(nn.Module):
    """标准 TCN:多层 TemporalBlock + 输出投影。"""

    def __init__(
        self,
        in_ch: int,
        channels: list[int],
        kernel_size: int = 3,
        dropout: float = 0.1,
    ):
        super().__init__()
        layers = []
        prev = in_ch
        for i, ch in enumerate(channels):
            layers.append(
                TemporalBlock(
                    prev,
                    ch,
                    kernel_size,
                    dilation=2 ** i,
                    dropout=dropout,
                )
            )
            prev = ch
        self.net = nn.Sequential(*layers)
        self.head = nn.Conv1d(prev, 1, 1)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # x: (B, in_ch, L)
        h = self.net(x)
        y = self.head(h)
        return y.squeeze(1)  # (B, L)

这个实现一共大约 60 行 PyTorch 代码,参数量在 50K 到 500K 之间(取决于 channels 配置),在 RTX 4090 上 batch=256、L=512 的训练吞吐大约 40K 样本/秒。

在分钟级行情上的实践

把上面的 TCN 用在「分钟级方向性预测」任务上,工程上还有几个不写出来就会踩坑的细节。

输入特征构造。我推荐的最小特征集是 9 维:分钟收益率、收益率绝对值、波动率(5/15/60 分钟滚动)、成交量对数差分、买卖压力差(如果有 Level-2 数据)、当日时间编码(开盘后第几分钟,归一化到 [0,1])。所有特征都做截面 z-score(按当天样本截面归一化),不要做时序 z-score(会用未来均值方差泄漏)。

标签构造。标签是未来 \(h\) 分钟的对数收益减去同期市场(或行业)平均,再做 cross-sectional rank。\(h\) 取 5、15、30 分钟三档,分别训练三个模型,最后做加权集成。每只股票每天产生 240 个左右样本(A 股一天 240 分钟),但相邻样本标签强相关,等价独立样本远小于此。

训练-验证切分。切忌用 sklearn 的随机 K-Fold。必须按时间切,且训练集和验证集之间要留 embargo 期(至少 \(h+1\) 分钟,最好一整天),否则验证集 IC 会被严重高估。具体做法参考第十二篇的 Purged CV。

推理时的状态管理。如果生产环境是流式推理(每分钟来一根新 K 线就要出一次预测),不要每次都把过去 512 分钟全部重新跑一遍——这是 O(L²) 的重复计算。正确做法是缓存中间层的特征图,每次只增量计算最新的 1 分钟。TCN 的「缓存逐步推理」实现可以参考 NVIDIA 的 PyTorch TCN 推理优化文档。

多标的批量训练的内存布局。生产里通常是「全市场 5000 只股票 × 每天 240 分钟 × 256 步窗口」,朴素张量化要 5000×240×256×9×4 字节 ≈ 11GB,单 batch 装不下。两条解法:第一,把 dataset 改成按需加载(__getitem__ 时再切窗口),磁盘 IO 可能成为瓶颈,建议用 memmap;第二,把 batch 按时间分组(同一分钟的所有股票一起做一个 batch),既支持截面 IC loss 又能控制显存。

停牌、涨跌停、ST 的处理。停牌日的「价格不变、成交量为零」绝不能当作正常样本喂进模型——它会让模型学到「成交量为零→收益为零」的伪规律。具体做法:在 dataset 构造时把停牌日打上 mask,loss 计算时跳过这些样本。涨跌停日的预测要单独评估,因为这些样本的「下一根 K 线收益」被强制截断,模型预测的精度天然较低。

下面这段是一个最小可复现的训练入口,把上面的网络、特征、标签拼起来:

import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
import pandas as pd
import numpy as np


class MinuteSeqDataset(Dataset):
    """分钟级窗口数据集。每个样本是 (L 步特征, 末位标签)。"""

    def __init__(self, panel: pd.DataFrame, window: int = 256, horizon: int = 15):
        # panel 形如 (date, minute, symbol) -> features + label
        self.window = window
        self.horizon = horizon
        self.X, self.y, self.idx = self._build(panel)

    def _build(self, panel: pd.DataFrame):
        feats = panel.filter(regex="^f_").values.astype("float32")
        label = panel["label"].values.astype("float32")
        valid = panel["valid"].values.astype("bool")
        # idx 仅保留 window 长度满足且 horizon 内未停牌的样本
        ok = np.where(valid)[0]
        ok = ok[ok >= self.window - 1]
        return feats, label, ok

    def __len__(self):
        return len(self.idx)

    def __getitem__(self, i):
        end = self.idx[i]
        x = self.X[end - self.window + 1 : end + 1]  # (L, F)
        y = self.y[end]
        return torch.from_numpy(x.T), torch.tensor(y)  # (F, L), scalar


def make_loader(panel, window, horizon, batch_size, shuffle):
    ds = MinuteSeqDataset(panel, window=window, horizon=horizon)
    return DataLoader(
        ds,
        batch_size=batch_size,
        shuffle=shuffle,
        num_workers=4,
        pin_memory=True,
        drop_last=shuffle,
    )

注意 __getitem__ 里的 x.T:上面强调过 TCN 期望 (B, C, L),所以从 (L, F) 转置到 (F, L)。这种小细节是真实工程里 bug 的高发点。


四、Transformer 在金融的适配

Transformer 不是「拿原版结构 + 金融数据」就能用的模型。它的位置编码、注意力 mask、归一化策略在金融数据上都需要做适配。下图列出了三种典型注意力 mask 在金融时序上的取舍。

Transformer 在金融时序上的三种注意力 Mask

位置编码改造

原版 Transformer 用正弦/余弦位置编码:\(PE(t, 2i) = \sin(t / 10000^{2i/d})\)。这个设计在 NLP 上 work 是因为「token 的相对位置」是核心信息。金融时序上情况不同:

第一,绝对时间位置(开盘后第几分钟、当周第几天、当月第几日)经常本身就是信号。 A 股开盘集合竞价后的 15 分钟和午后开盘的 15 分钟有显著不同的微结构特征,这种「日内时钟」必须显式编码。建议在原版正弦编码之外,再加一个「日内分钟数」的 embedding,把 0-239 当作离散 token 嵌入到一个可学习的向量。

第二,相对位置(间隔多少步)在时序上经常比绝对位置更重要。 为此可以用 RoPE(Rotary Position Embedding)或者 ALiBi(Attention with Linear Biases)。在我的经验里,ALiBi 在金融时序上的训练稳定性最好,原因是它的相对位置偏置是固定的不可学习参数,不会被噪声样本带偏。

第三,节假日、停牌、停止交易日要打断位置编码的连续性。 如果序列里包含跨周末的 K 线,「上周五收盘」和「本周一开盘」的相邻位置距离应该被显式告诉模型——比如插入一个特殊的「market closed」token,或者把位置编码改成「累计交易分钟数」而不是「累计时钟分钟数」。

注意力稀疏化

原版自注意力的复杂度是 \(O(L^2 d)\),序列长 1024 时 attention map 占用约 4MB 显存(fp32),1024² 次乘加。看起来不大,但 batch=128 加上多头多层就会爆显存。金融数据的两个特点让稀疏化几乎是必须的:

第一,远程依赖在金融数据上往往是「跳跃式」而不是「连续式」。 比如今天的开盘价对昨天收盘价的依赖、本月初对上月末的依赖。这意味着 LogSparse(Informer 的核心思想)天然适配:每个 query 只看过去 \(\log L\) 个跳跃位置(\(t-1, t-2, t-4, t-8, ...\)),既保留了长程依赖,又把复杂度降到 \(O(L \log L)\)

第二,局部窗口注意力对于微结构特征足够。 订单簿的微观演化通常只在过去几十秒内有强相关,超过这个窗口就是噪声。对应的 mask 是「滑动窗口」:每个 query 只看过去 \(w\) 步,complexity 降到 \(O(L w)\)

PyTorch 实现稀疏 attention 不需要从头写,torch.nn.functional.scaled_dot_product_attention 在 PyTorch 2.x 里已经支持 attn_mask 参数,传入 bool 张量就行。代码骨架:

def make_logsparse_mask(L: int, device) -> torch.Tensor:
    """生成 LogSparse + 局部窗口的因果 mask。True 表示允许注意。"""
    mask = torch.zeros(L, L, dtype=torch.bool, device=device)
    for i in range(L):
        # 局部窗口:j ∈ [i-2, i]
        for j in range(max(0, i - 2), i + 1):
            mask[i, j] = True
        # 跳跃位置:j = i - 2^k
        k = 1
        while 2 ** k <= i:
            mask[i, i - 2 ** k] = True
            k += 1
    return mask

实际工程里我会把这个 mask 缓存到模型里,作为 buffer 而不是每次前向重算。一个 1024×1024 的 bool mask 占用 1MB,可以接受。

长序列与显存

把序列长度 \(L\) 从 256 推到 1024 以上,显存压力来自三处:

  1. 激活值:每层都要存 \((B, L, d)\) 的张量用于反向传播。\(L=1024\)\(d=256\)、12 层、batch=64 时约占 0.7GB。
  2. attention map:每层每头一份 \((B, H, L, L)\)\(H=8\)、其它同上时占用 2GB。
  3. 优化器状态:Adam 需要 2 倍于参数量的额外显存,AdamW 同。

工程上的应对:

训练稳定性

Transformer 在金融数据上的训练稳定性比 TCN 差一个量级,主要表现在两方面:

对学习率敏感。Adam 默认 lr=1e-3 在 Transformer 上经常发散。建议用 AdamW,lr=1e-4 起,配合 warmup(前 1000 步线性升温)和 cosine decay。

对随机种子敏感。同一份代码、同一份数据、不同种子,验证集 IC 可能从 0.05 到 0.10 不等。这不是 bug,是 Transformer 的固有性质(loss landscape 多极小值)。应对办法是多种子集成,第六节展开。

Pre-LN 与 Post-LN

原版 Transformer 用 Post-LN(注意力之后再 LayerNorm),训练初期梯度大,必须 warmup。后续工作(GPT-2 等)改用 Pre-LN(注意力之前 LayerNorm),训练稳定性大幅提升,warmup 可以省掉。

在金融时序上,Pre-LN 几乎是必选项。原因是金融数据的输入分布本身就有重尾,Post-LN 在前几个 batch 会出现梯度爆炸;Pre-LN 把归一化前置,输入再异常梯度也不会失控。PyTorch 的 nn.TransformerEncoderLayer 默认是 Post-LN,要显式传 norm_first=True 切到 Pre-LN,这个参数经常被忽略。

一个可运行的稀疏 Attention 模块

把上面的 LogSparse mask 和 Pre-LN Transformer 拼成一个完整的 encoder block:

class SparseTransformerBlock(nn.Module):
    def __init__(self, d_model: int, n_heads: int, dropout: float = 0.1):
        super().__init__()
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.attn = nn.MultiheadAttention(
            d_model, n_heads, dropout=dropout, batch_first=True
        )
        self.ffn = nn.Sequential(
            nn.Linear(d_model, 4 * d_model),
            nn.GELU(),
            nn.Dropout(dropout),
            nn.Linear(4 * d_model, d_model),
        )
        self.dropout = nn.Dropout(dropout)

    def forward(self, x: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
        # Pre-LN
        h = self.norm1(x)
        a, _ = self.attn(h, h, h, attn_mask=~mask, need_weights=False)
        x = x + self.dropout(a)
        h = self.norm2(x)
        x = x + self.dropout(self.ffn(h))
        return x

attn_mask=~mask 是因为 PyTorch 的 attn_mask 约定是「True 表示屏蔽」,而前文我们的 mask 约定是「True 表示允许」,取反一下。这个语义反转不熟悉的话很容易搞错。


五、训练技巧

模型架构选定后,损失函数和数据增强是决定模型质量的下一道关。这一节专门讨论金融时序上的训练细节。

损失函数:MSE / Huber / Quantile / IC loss

MSE 是最朴素的回归损失:\(\mathcal{L} = \frac{1}{N}\sum (y_i - \hat{y}_i)^2\)。在金融上有两个问题:第一,对极端值过于敏感(涨跌停日的标签会主导梯度);第二,最小化 MSE 不等于最大化 IC(信息系数)。

Huber loss\(|y - \hat{y}| \le \delta\) 时是 MSE,在外面是 L1。它压住了极端样本的影响,是 MSE 的稳健替代。\(\delta\) 的取值经验上是标签的 1 到 1.5 倍标准差。

Quantile loss(分位数损失)用于让模型预测某个分位数而不是均值。形式是 \(\rho_\tau(u) = u \cdot (\tau - \mathbb{1}[u<0])\),其中 \(u = y - \hat{y}\)\(\tau \in (0,1)\)。在量化场景里,预测中位数(\(\tau=0.5\))可以避免被肥尾标签带偏;同时预测 \(\tau=0.1, 0.5, 0.9\) 三个分位数(multi-quantile)可以输出预测区间,用于风险控制。

IC loss 是金融场景特有的损失。IC 的定义是预测和实际收益的截面相关系数;最大化 IC 等价于最小化负的相关系数。但相关系数对预测的「绝对量级」无关,只关心「排序」,所以梯度行为和 MSE 完全不同。

下面是一个可微的 IC loss 实现:

def ic_loss(pred: torch.Tensor, target: torch.Tensor, eps: float = 1e-8) -> torch.Tensor:
    """截面 Pearson IC 的负数。pred 和 target 形状均为 (B,)。"""
    pred_c = pred - pred.mean()
    targ_c = target - target.mean()
    num = (pred_c * targ_c).sum()
    den = torch.sqrt((pred_c ** 2).sum() * (targ_c ** 2).sum() + eps)
    return -(num / den)

IC loss 的工程注意点:

  1. 必须按截面(同一时间点)计算,不能跨时间。如果 batch 里混合了多个时间点的样本,要先 group by 时间,再分别算 IC,最后取均值。
  2. batch_size 要足够大。截面 IC 的方差是 \(1/N\) 级别,N 太小(比如 < 64)时梯度噪声非常大,模型几乎学不动。建议每个截面至少 256 个样本。
  3. 和 MSE 加权混合。纯 IC loss 训练初期不稳定,常用的策略是 \(\mathcal{L} = \alpha \cdot \text{MSE} + (1-\alpha) \cdot \text{IC}\)\(\alpha\) 从 1.0 线性退火到 0.3。

Rank IC loss 是 IC loss 的变体,用 Spearman 相关代替 Pearson。Spearman 不可微,但可以用 soft-rank 近似。fast-soft-sort 库提供了一个高效实现。在排序型选股任务里,Rank IC loss 通常比 Pearson IC loss 好一点,但实现复杂度更高。

数据增强

金融数据的样本量限制让数据增强变得格外重要,但绝大多数图像/语音里的增强方法(旋转、翻转、加噪声)都不能直接用,原因是它们破坏了金融数据的因果和时序结构。可以用的几类:

窗口 bootstrap。同一只股票同一时段的窗口,在训练时随机取窗口起点(而不是固定 stride)。这相当于在「窗口对齐」这一维上采样,不破坏因果。要点是起点必须是日内有效交易时间,不能跨过停牌或者节假日。

Mixup(特征混合)。对两个同时刻不同标的的样本,按系数 \(\lambda \sim \text{Beta}(0.2, 0.2)\) 混合特征和标签:\(\tilde{x} = \lambda x_i + (1-\lambda) x_j\)\(\tilde{y} = \lambda y_i + (1-\lambda) y_j\)。Mixup 在选股任务上有 0.5% 到 1% 的 IC 提升,且实现简单。

特征 dropout。随机把一部分特征通道置零,强迫模型不依赖单一特征。dropout 比例建议 5% 到 15%,再大就破坏信息太多。

时间 jitter。对窗口结束位置做 ±1 步的随机扰动。这相当于告诉模型「同样的预测目标在 t-1 和 t+1 上都应该有相似的输出」,提升时间泛化。

禁用的增强:高斯噪声叠加、时间反转、振幅缩放——前者破坏特征之间的协方差结构,第二个直接违反因果,第三个改变收益的尺度但不改变排序,对 rank 任务没意义。

标签的稳健化

数据增强讨论的是输入侧,标签侧也有几个被忽视的工程细节。

Winsorize。原始未来收益里,每年总有几个涨停或跌停的极端样本。这些样本会主导 MSE 梯度,让模型「过度学习极端事件」。建议把标签按截面分位数(1% 和 99%)做 winsorize,再喂给模型。winsorize 不应该用全样本分位数,必须按截面(同一日内)分位数,否则会泄漏未来信息。

Rank 变换。对排序型任务(IC 最大化),直接把标签从「连续收益」变成「截面 rank(归一化到 [-1, 1])」,再用 MSE 损失。这等价于在标签空间里做了非线性归一化,让模型聚焦排序而不是绝对量级。

多目标学习。同时预测「未来 5 日 rank」「未来 10 日 rank」「未来 20 日 rank」三个标签,模型用一个共享主干 + 三个输出头。多目标可以让模型学到的特征更稳定,因为单一目标上的过拟合不容易在三个目标上同时发生。

训练循环

把上面的元素拼成一个最小可运行的训练循环:

import torch
import torch.nn as nn
from torch.optim import AdamW
from torch.optim.lr_scheduler import CosineAnnealingLR
from torch.amp import autocast, GradScaler


def train_one_epoch(model, loader, optimizer, scaler, scheduler, device, alpha=0.5):
    model.train()
    total_loss = 0.0
    n_batches = 0
    for x, y in loader:
        x = x.to(device, non_blocking=True)
        y = y.to(device, non_blocking=True)

        optimizer.zero_grad(set_to_none=True)
        with autocast(device_type="cuda", dtype=torch.bfloat16):
            pred = model(x)[:, -1]  # 取末位时间步预测
            mse = nn.functional.mse_loss(pred, y)
            ic = ic_loss(pred, y)
            loss = alpha * mse + (1 - alpha) * ic

        scaler.scale(loss).backward()
        scaler.unscale_(optimizer)
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        scaler.step(optimizer)
        scaler.update()
        scheduler.step()

        total_loss += float(loss.detach())
        n_batches += 1
    return total_loss / max(n_batches, 1)


def fit(
    model,
    train_loader,
    valid_loader,
    device,
    epochs: int = 30,
    lr: float = 1e-4,
    weight_decay: float = 1e-4,
):
    optimizer = AdamW(model.parameters(), lr=lr, weight_decay=weight_decay)
    scheduler = CosineAnnealingLR(optimizer, T_max=epochs * len(train_loader))
    scaler = GradScaler()

    best_ic = -float("inf")
    best_state = None
    patience = 5
    bad_epochs = 0

    for epoch in range(epochs):
        alpha = max(0.3, 1.0 - 0.05 * epoch)  # 退火权重
        tr_loss = train_one_epoch(
            model, train_loader, optimizer, scaler, scheduler, device, alpha
        )
        val_ic = evaluate_ic(model, valid_loader, device)
        if val_ic > best_ic:
            best_ic = val_ic
            best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
            bad_epochs = 0
        else:
            bad_epochs += 1
            if bad_epochs >= patience:
                break

    if best_state is not None:
        model.load_state_dict(best_state)
    return model, best_ic


@torch.no_grad()
def evaluate_ic(model, loader, device):
    model.eval()
    preds, targs = [], []
    for x, y in loader:
        x = x.to(device)
        p = model(x)[:, -1].float().cpu()
        preds.append(p)
        targs.append(y)
    preds = torch.cat(preds)
    targs = torch.cat(targs)
    pc = preds - preds.mean()
    tc = targs - targs.mean()
    num = (pc * tc).sum()
    den = torch.sqrt((pc ** 2).sum() * (tc ** 2).sum() + 1e-8)
    return float(num / den)

这段代码大约 70 行,覆盖了:AdamW + cosine schedule、bfloat16 AMP、梯度裁剪、损失退火、early stopping、IC 评估。在 RTX 4090 上训练一个 8 层 TCN(参数 200K,input shape (F=9, L=256),batch=512,30 epoch)大约 25 分钟。


六、过拟合与稳定性

时序深度学习在金融上的过拟合比 GBDT 严重得多。第十二篇里我提过 GBDT 的过拟合控制依靠 num_leaves、min_child_samples、L2 正则;深度网络的过拟合控制工具完全不同,且更难诊断。

训练-推理偏移

最隐蔽的过拟合来自训练-推理统计分布偏移。三个最常见的来源:

第一,截面归一化的口径错误。训练时用「整段历史的全市场截面 z-score」做归一化,推理时只能用「当前时点的截面 z-score」。如果训练时用的是「未来某天的均值方差」,相当于在训练里偷偷塞入了未来信息。正确做法是:归一化必须在「同一截面内」完成,且训练和推理的归一化函数一字不差。

第二,特征的滞后窗口不对齐。比如训练时某个因子是「过去 20 日平均」,但实际生产里这个因子的取数延迟 1 天,导致推理时拿到的是「过去 19 日 + 1 个空值」。这种偏差在回测里完全看不到,上线第一天就显形。

第三,非交易时段的处理不一致。训练时跨周末的两根 K 线被当成相邻时间步,推理时如果改成中间插入「market closed」token,模型行为完全不同。所有时间编码、缺失填充、节假日处理逻辑必须在训练和推理之间用同一份代码(不是同一份逻辑——同一份代码)。

随机种子敏感

把同一份训练代码用 10 个不同的 random seed 跑一遍,验证集 IC 的标准差通常在 0.005 到 0.02 之间。Transformer 的种子敏感度比 TCN 大一倍,TCN 的种子敏感度比 GBDT 大三倍。

种子敏感的根源不是「模型本身有问题」,而是金融数据的真实信号弱、噪声大。一个 IC=0.05 的真信号埋在一片标准差为 1 的噪声里,loss landscape 上有很多近似平坦的极小值,不同种子收敛到不同的极小值。这是金融数据的客观性质,不是工程 bug。

集成与平均

应对种子敏感的标准做法是集成。三种粒度:

多种子集成:同一份代码、同一份数据,用 5 至 10 个不同 random seed 各训练一个模型,预测时取均值。最简单也最有效。在我做过的项目里,10 种子集成的 IC 比单种子稳定 30% 以上,且样本外退化幅度更小。

多窗口集成:训练数据起点不同,比如分别从 2018-01、2019-01、2020-01 起训练三个模型。这种集成捕捉「不同市场记忆长度」的信号。

多结构集成:不同模型架构(TCN + Transformer + PatchTST)的预测加权平均。权重可以等权或者用最近一段验证集的 IC 反向加权。多结构集成的工程复杂度高,通常只在策略容量足够大时才值得。

多检查点平均(Stochastic Weight Averaging):训练后期对最近 K 个 epoch 的模型权重做平均,相当于在 loss landscape 上找一个更平坦的极小值。SWA 的实现成本低(PyTorch 自带 torch.optim.swa_utils.AveragedModel),是单模型场景里性价比最高的稳定性提升手段。

把多种子集成集成到训练流程里:

def fit_ensemble(
    build_model_fn,
    train_loader,
    valid_loader,
    device,
    n_seeds: int = 10,
    base_seed: int = 0,
):
    models = []
    val_ics = []
    for k in range(n_seeds):
        torch.manual_seed(base_seed + k)
        torch.cuda.manual_seed_all(base_seed + k)
        np.random.seed(base_seed + k)
        model = build_model_fn().to(device)
        model, ic = fit(model, train_loader, valid_loader, device)
        models.append(model)
        val_ics.append(ic)
    return models, val_ics


@torch.no_grad()
def predict_ensemble(models, loader, device):
    preds = []
    for m in models:
        m.eval()
    for x, _ in loader:
        x = x.to(device)
        ys = [m(x)[:, -1].float().cpu() for m in models]
        preds.append(torch.stack(ys).mean(0))
    return torch.cat(preds)

10 个模型同时存进显存的成本是 10 倍参数量;如果显存吃紧,改成「训练完一个模型立刻把权重 dump 到磁盘,预测时按需加载」。

早停与正则

两点经验:

早停的 patience 不要太短。金融数据的验证集 IC 噪声大,单个 epoch 的下降可能只是噪声。建议 patience=5 到 8,且在 patience 区间里取 IC 最高的检查点而不是最后一个。

Dropout 比 weight_decay 更有效。在我做过的所有时序深度学习项目里,把 dropout 从 0.0 调到 0.1 的提升通常大于把 weight_decay 从 0 调到 1e-4。原因是 dropout 直接破坏了模型对个别神经元的过度依赖,而 weight_decay 只是把权重往零拉。建议起点:dropout=0.1,weight_decay=1e-5。

过拟合的诊断信号

模型是否过拟合了,光看「训练 loss 远低于验证 loss」是不够的。给出几个我在工程里用得最多的诊断信号:

信号一:验证 IC 的「单调性」。健康的训练里,验证 IC 应该单调(或近似单调)上升到峰值,再缓慢下降。如果验证 IC 在前几 epoch 抖动剧烈(比如 0.02 → 0.08 → 0.01 → 0.06),说明模型不稳定,要么是学习率太大,要么是 batch 太小。

信号二:训练-验证 IC 差距。当 train IC 是 val IC 的 3 倍以上时(比如 train=0.15、val=0.05),过拟合严重,需要加 dropout 或减层数。差距在 1.5 倍以内是健康的。

信号三:对训练数据 shuffle 后的退化。把训练数据完全 shuffle(破坏时序结构)后重新训练,如果验证 IC 没有显著下降,说明模型根本没学到时序结构,「时序模型」是个伪命题。这个测试很简单但很多人没做过。

信号四:留一日测试。把训练数据按日切成 K 份,留一日做验证、其它做训练,看 K 个验证日的 IC 方差。如果方差很大(变异系数 > 0.5),说明模型对训练样本的具体组合敏感,泛化能力弱。

这四个信号配合 SHAP(虽然在深度网络上稍麻烦)的特征重要性稳定性检查,能识别出绝大多数过拟合问题。


七、与传统因子的融合

把时序深度学习作为「替代品」直接顶掉传统因子模型,是工程上的常见错误。正确的做法是把它作为「补充品」,融合到现有因子体系里。三种主流的融合方式。

残差学习

第一种:把传统因子模型的预测当作 baseline,深度网络只学习 baseline 之外的残差。

形式上:传统因子模型 \(\hat{y}_{\text{linear}} = \mathbf{w}^\top \mathbf{f}\),深度网络 \(\hat{y}_{\text{nn}} = g_\theta(\mathbf{x})\),最终预测 \(\hat{y} = \hat{y}_{\text{linear}} + \hat{y}_{\text{nn}}\)。训练时让深度网络去拟合 \(y - \hat{y}_{\text{linear}}\) 而不是直接拟合 \(y\)

工程上的好处:

这是我最推荐的融合方式。

Stacking

第二种:把多个模型(线性、GBDT、TCN、Transformer)的预测作为新特征,再训练一个二阶模型做最终输出。

Stacking 的优点是理论上可以达到「不弱于任一基模型」的下界;缺点是二阶模型在金融数据上极易过拟合到一阶模型的噪声相关。具体地说:不同基模型在样本外的相关结构往往比训练集上更紧,stacking 模型学到的「权重」会对这种相关结构敏感。

工程建议:

模型蒸馏

第三种:训练一个大模型(教师),再用它的预测作为标签训练一个小模型(学生)。学生模型部署到生产,教师模型只在离线训练时用。

蒸馏在量化里的价值不仅是「模型压缩」,更重要的是「降噪」。教师模型的预测相对原始标签有更高的信噪比(因为模型平均掉了一部分噪声),小模型用这个「软标签」训练,泛化经常比直接用原始标签好。

工程实现:

def distill_loss(s_pred: torch.Tensor, t_pred: torch.Tensor, y: torch.Tensor,
                 lam: float = 0.7) -> torch.Tensor:
    """蒸馏损失:lam 倍的教师模仿 + (1-lam) 倍的真实标签拟合。"""
    mimic = nn.functional.mse_loss(s_pred, t_pred.detach())
    truth = nn.functional.mse_loss(s_pred, y)
    return lam * mimic + (1 - lam) * truth

lam 的取值在 0.5 到 0.8 之间,原始标签的权重不能完全归零,否则学生会复制教师的所有错误。

与因子库的对接

不管用哪种融合方式,深度网络的输出最终都要进入既有的因子库管道:归一化、行业市值中性、组合优化。两个工程要点:

第一,把深度网络的输出当成「一个新因子」,不是「一个新策略」。它要和其它几十个传统因子一起跑相关性检验、衰减检验、风格归因。如果它和现有动量因子相关 0.95,那它没有提供新信息,不要上线。

第二,深度因子的换手率要单独监控。深度模型的预测往往比线性模型更不稳定,会导致换手上升、交易成本侵蚀超额收益。如果深度因子的换手率是线性因子的 2 倍,那 IC 提升的 50% 以上会被交易成本吃掉。

风格归因

深度因子上线前必须做风格归因,回答「这个因子的超额收益主要来自哪些风格暴露」这个问题。具体做法是把因子收益对 Barra 风格因子(市值、估值、动量、波动、流动性、成长、杠杆、盈利等)做时序回归,看残差占比。

如果残差占比小于 30%,说明深度因子主要是在对风格做不同程度的暴露,而不是在提供独立 alpha。这种因子可以作为风格择时的辅助工具,但作为「发现新 alpha」的成果显然不达标。

如果残差占比大于 50%,说明深度因子捕获了风格之外的信号——这才是它真正的价值所在。

我见过的失败案例里,最常见的是「深度因子的超额收益 80% 来自小市值暴露」。这种因子不需要训练几百万参数的网络,直接做小市值 long-short 就行了。


八、上线工程

模型训完只是一半工作,剩下的另一半在上线工程里。这一节讲三件事:模型导出、推理延迟、模型监控。

ONNX / TorchScript 部署

PyTorch 的训练框架不适合直接做生产推理,原因是依赖太重(PyTorch 主包 + CUDA + cuDNN 几个 GB),且 Python GIL 限制了并发吞吐。生产部署的两条主流路径:

TorchScript。把 PyTorch 模型 trace 或 script 成静态图,运行时只依赖 LibTorch(C++ 运行时,约 200MB)。

import torch

model.eval()
example = torch.randn(1, 9, 256)  # (B, F, L)
scripted = torch.jit.trace(model, example)
scripted.save("tcn_v1.pt")

trace 的限制:模型里不能有数据相关的控制流(比如 if 分支依赖输入张量值)。如果有,要用 torch.jit.script 而不是 trace。我推荐 trace 优先,能用就用,不能再 script。

ONNX。把模型导出成 ONNX 格式,然后用 ONNX Runtime(C++/Python/Go/Rust 多语言绑定)推理。ONNX Runtime 的执行优化做得比 LibTorch 好一些,且支持把模型转换到 TensorRT 后端做进一步加速。

import torch.onnx

torch.onnx.export(
    model,
    example,
    "tcn_v1.onnx",
    input_names=["x"],
    output_names=["y"],
    dynamic_axes={"x": {0: "batch"}, "y": {0: "batch"}},
    opset_version=17,
)

dynamic_axes 要把 batch 维度声明为动态,否则导出的模型只能跑固定 batch size。序列长度 \(L\) 也建议声明为动态,方便推理时改窗口。

两者的取舍。TorchScript 的优点是和 PyTorch 行为完全一致,不需要单独验证;缺点是性能优化不如 ONNX Runtime + TensorRT。ONNX 的优点是性能好、跨语言;缺点是某些 PyTorch 算子没有对应的 ONNX 实现,导出可能失败。我的工程默认是先试 ONNX,失败了退到 TorchScript。

推理延迟

不同部署方式的延迟(RTX 4090,TCN 8 层 200K 参数,batch=1,L=256):

方案 P50 延迟 P99 延迟 备注
Python + PyTorch eager 4.2 ms 8.5 ms 基线,包含 Python 开销
Python + TorchScript 2.8 ms 5.3 ms trace 后调用,仍走 Python
C++ + LibTorch 1.6 ms 3.0 ms 纯 C++ 调用
C++ + ONNX Runtime CUDA 1.1 ms 2.4 ms ORT 的 graph optimization
C++ + ONNX Runtime + TensorRT 0.5 ms 1.4 ms TRT 的 fp16 + kernel fusion

注:上表是我自己在该硬件上测的近似数字,仅作为相对量级参考。具体的工程延迟取决于模型大小、batch 配置、是否启用 cuDNN benchmark、CPU-GPU 数据搬运策略。在低延迟场景里(比如逐 Tick 推理),数据搬运(cudaMemcpy)经常占总延迟的 30% 以上,建议用 pinned memory + cudaMemcpyAsync。

如果策略本身是分钟级或日频,1ms 和 5ms 没有区别,不需要在延迟上纠结。如果是 Tick 级或者订单簿级,建议直接走 C++ + ORT + TRT 路线,且把模型蒸馏到 50K 参数以下。

模型监控

部署后必须监控的指标,按重要性排:

1. 在线 IC。每天收盘后用当天的预测和实际收益算 IC,画到 dashboard 上。设阈值(比如近 20 日 IC 均值低于 0.02),触发告警。

2. 预测分布漂移。把当天的预测值分布(均值、标准差、分位数、偏度、峰度)和训练集做对比。如果 KS 检验 p 值 < 0.01,说明输入分布漂移,模型可能失效。

3. 特征覆盖率。每天检查每个特征的非空率、异常率、与历史均值方差的偏离。任何一个特征异常都要告警,因为深度模型对特征异常的容忍度比 GBDT 低。

4. 推理延迟。P50、P95、P99 延迟,按服务实例分层。延迟突增通常意味着 GPU 资源被抢占或者有内存泄漏。

5. 模型版本和热更新。每个版本要有唯一 ID(比如 git commit + 训练时间戳)。线上同时跑两个版本(current 和 candidate)的灰度,candidate 的 IC 在 10 个交易日内不显著低于 current 才能切流。

下面是一个最小的监控代码骨架:

import json
import time
from dataclasses import dataclass, asdict
from pathlib import Path

import numpy as np


@dataclass
class DailyMetrics:
    date: str
    model_version: str
    ic: float
    pred_mean: float
    pred_std: float
    pred_p1: float
    pred_p99: float
    n_samples: int
    feature_null_rate: dict
    inference_p50_ms: float
    inference_p99_ms: float


def compute_ic(pred: np.ndarray, y: np.ndarray) -> float:
    if len(pred) < 64:
        return float("nan")
    pc = pred - pred.mean()
    yc = y - y.mean()
    return float((pc * yc).sum() / np.sqrt((pc ** 2).sum() * (yc ** 2).sum() + 1e-12))


def write_metrics(out_dir: Path, m: DailyMetrics) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    f = out_dir / f"{m.date}_{m.model_version}.json"
    f.write_text(json.dumps(asdict(m), ensure_ascii=False, indent=2))

把这段代码塞到生产推理流程的尾部,每天自动写一份指标,配上简单的告警规则(IC < 阈值、延迟 > 阈值)就够用了。复杂的可观测性可以后续接到 Prometheus + Grafana 上。

用 vectorbt 评估

模型上线前的最后一步评估,是把它的预测灌进事件驱动回测。vectorbt 提供了一个轻量的向量化回测框架,适合快速验证。

import numpy as np
import pandas as pd
import vectorbt as vbt


def evaluate_with_vectorbt(
    pred_df: pd.DataFrame,
    price_df: pd.DataFrame,
    top_k: int = 50,
    fee: float = 0.001,
):
    """
    pred_df: index=date, columns=symbol, values=模型预测
    price_df: index=date, columns=symbol, values=收盘价
    每日选预测最高的 top_k 只股票,等权持有。
    """
    rank = pred_df.rank(axis=1, ascending=False)
    long_signal = rank <= top_k
    weights = long_signal.div(long_signal.sum(axis=1), axis=0).fillna(0.0)

    pf = vbt.Portfolio.from_orders(
        close=price_df,
        size=weights,
        size_type="targetpercent",
        group_by=True,
        cash_sharing=True,
        fees=fee,
        freq="D",
    )
    return pf.stats()

返回的 stats 包含 Sharpe、最大回撤、年化收益、换手率等关键指标。这只是「快速过一遍」的评估;正式的策略评估必须用第十一篇里的事件驱动回测引擎,把撮合、滑点、停牌、涨跌停等真实约束都模拟到位。深度模型的预测往往比线性模型更不稳定,换手率会显著高于线性因子;在 vectorbt 评估里看似 30% 的年化超额,套上真实交易成本和冲击模型后可能只剩 10%。

上线 checklist

最后给出一份我自己用的上线 checklist:

  1. 训练数据的最后一天是否在所有特征生成时刻之前?(防未来函数)
  2. 验证集和测试集是否完全独立(无样本重叠、有 embargo)?
  3. 多种子集成的 IC 标准差是否在可接受范围(< 单种子 IC 的 30%)?
  4. 模型在最近 60 个交易日的 IC 是否显著高于零(t 检验 p < 0.05)?
  5. 特征归一化、缺失填充、时间编码的代码是否训练和推理共用同一份?
  6. ONNX/TorchScript 导出后的推理结果与 PyTorch eager 一致(误差 < 1e-4)?
  7. 预测的分布是否合理(均值接近零、标准差稳定、无极端尾部)?
  8. 与现有因子的相关性是否低于 0.7(保证提供增量信息)?
  9. 灰度方案是否就绪(按比例分流、可快速回滚)?
  10. 监控告警是否配置(IC、延迟、特征覆盖率三类)?

这十条任意一条不过,都不应该上线。

推理时的窗口管理

生产环境里,模型每分钟(或每秒、每 Tick)都要出一次预测,朴素做法是把过去 \(L\) 步全部重新前向一遍。这种做法在分钟级以下完全可以接受,但在 Tick 级别会成为瓶颈。两个工程优化值得一提。

第一,环形缓冲区(ring buffer)。维护一个长度为 \(L\) 的 numpy 数组(或者 GPU 上的 tensor),每次新数据到来时把最旧一行覆盖掉,再把整个 buffer 喂给模型。这避免了每次都做大数组的拼接和切片。

第二,TCN 的增量推理。TCN 的因果性允许用「上一次的中间激活」加上「这一步的新输入」算出新的激活,复杂度从 \(O(L)\) 降到 \(O(1)\)(每一层)。具体实现需要每一层都缓存 \(k \cdot d\) 步的历史激活;NVIDIA 的 PyTorch 推理优化文档里有标准实现。我自己测过,对一个 8 层 TCN,这种增量推理把 P50 延迟从 1.6ms 降到 0.3ms。

Transformer 不能直接用同样的优化,因为注意力是全局的;但「滑动窗口注意力」(一种稀疏 mask)可以做局部的增量推理,复杂度同样是 \(O(1)\)

一个被忽视的工程问题:时区与时间戳

时序模型对时间戳的精确度极其敏感,但金融工程里时间戳的混乱程度远超想象。三个常见的坑:

第一,本地时间和 UTC 不一致。A 股交易时间是 UTC+8 的 9:30 到 15:00,如果原始数据是 UTC 的,开盘时间会是 1:30,模型的「日内时钟」编码完全错乱。每条时间戳都要明确标注时区,落库前统一到一个标准(推荐 UTC 存储 + 业务层转换)。

第二,跨市场数据的时间对齐。把美股的隔夜信息和 A 股开盘对齐时,要小心夏令时和冬令时的切换。每年 3 月和 11 月美国切换夏令时的那一周,数据对齐错一个小时是很常见的 bug。

第三,订单簿数据的微秒精度。如果存储用的是 pandas.Timestamp,默认是纳秒精度但有时会被截断到毫秒。模型训练时如果两条相邻订单的时间戳被截断成相同值,相对顺序会被破坏。建议直接用 int64 存储 epoch 微秒数,避免任何 datetime 库的隐式转换。

这些问题不会在论文里讨论,但任何一个搞错都会让模型失效。


九、TCN 因果卷积示意图

把第三节的 TCN 结构画成图,便于直观理解扩张卷积如何把感受野指数级扩大。

TCN 因果卷积与扩张卷积示意

图中蓝色为输入层 16 个时间步,绿色、橙色、紫色为隐藏层 1/2/3,红色为输出层。kernel=2,dilation 依次为 1/2/4/8,最终 \(y_t\) 的感受野覆盖 \([t-15, t]\) 共 16 个时间步。注意所有连边都从「过去」指向「现在或更晚」,没有任何一条边违反因果。


十、与下一篇的衔接

本文聚焦于「时序深度学习的工程实践」,目标场景以 A 股、港股、美股为主,所讨论的过拟合、数据泄漏、稳定性问题是几乎所有传统市场都共有的。但金融市场的另一片越来越重要的领域——加密货币——有它独特的工程问题:交易所连续 24×7 运行没有「日内时钟」、流动性集中在少数 BTC/ETH/SOL 上、做市商和散户的行为分布与传统市场截然不同、监管套利的可能性带来另类的策略空间、永续合约的资金费率本身就是一个高频时序信号。

下一篇会讨论加密货币上的策略工程:永续合约的资金费率套利、跨交易所价差、流动性挖矿的真实收益核算、链上数据(钱包、Gas、稳定币流向)的特征工程、做市与高频策略的工程取舍。本文里讲的 TCN 和 Transformer 在加密上同样适用,但训练协议要做几个适配:第一,加密市场无停牌和涨跌停,但闪崩和插针更频繁,标签的极端值处理更重要;第二,跨交易所的样本不能简单合并训练,每家交易所的微结构都不同;第三,永续合约的资金费率引入了「资金面」这个传统市场没有的因子,需要专门的特征工程。


十一、参考文献

  1. Bai, S., Kolter, J. Z., & Koltun, V. (2018). “An Empirical Evaluation of Generic Convolutional and Recurrent Networks for Sequence Modeling.” arXiv:1803.01271. TCN 的原始论文。
  2. van den Oord, A., et al. (2016). “WaveNet: A Generative Model for Raw Audio.” arXiv:1609.03499. 最早系统使用因果卷积 + 扩张卷积的工作。
  3. Vaswani, A., et al. (2017). “Attention Is All You Need.” NeurIPS 2017. Transformer 原始论文。
  4. Zhou, H., et al. (2021). “Informer: Beyond Efficient Transformer for Long Sequence Time-Series Forecasting.” AAAI 2021. ProbSparse 注意力。
  5. Wu, H., et al. (2021). “Autoformer: Decomposition Transformers with Auto-Correlation for Long-Term Series Forecasting.” NeurIPS 2021. 序列分解 + 自相关注意力。
  6. Zhou, T., et al. (2022). “FEDformer: Frequency Enhanced Decomposed Transformer for Long-term Series Forecasting.” ICML 2022. 频域注意力。
  7. Nie, Y., et al. (2023). “A Time Series is Worth 64 Words: Long-term Forecasting with Transformers.” ICLR 2023. PatchTST 论文。
  8. Oreshkin, B., et al. (2020). “N-BEATS: Neural basis expansion analysis for interpretable time series forecasting.” ICLR 2020. N-BEATS 论文。
  9. Wu, H., et al. (2023). “TimesNet: Temporal 2D-Variation Modeling for General Time Series Analysis.” ICLR 2023. TimesNet 论文。
  10. Lim, B., et al. (2021). “Temporal Fusion Transformers for Interpretable Multi-horizon Time Series Forecasting.” International Journal of Forecasting, 37(4). TFT 论文。
  11. Press, O., Smith, N. A., & Lewis, M. (2022). “Train Short, Test Long: Attention with Linear Biases Enables Input Length Extrapolation.” ICLR 2022. ALiBi 位置编码。
  12. Su, J., et al. (2021). “RoFormer: Enhanced Transformer with Rotary Position Embedding.” arXiv:2104.09864. RoPE 位置编码。
  13. Dao, T., et al. (2022). “FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness.” NeurIPS 2022. Flash Attention。
  14. Salimans, T., & Kingma, D. P. (2016). “Weight Normalization: A Simple Reparameterization to Accelerate Training of Deep Neural Networks.” NeurIPS 2016.
  15. Hendrycks, D., & Gimpel, K. (2016). “Gaussian Error Linear Units (GELUs).” arXiv:1606.08415.
  16. Loshchilov, I., & Hutter, F. (2019). “Decoupled Weight Decay Regularization.” ICLR 2019. AdamW。
  17. Izmailov, P., et al. (2018). “Averaging Weights Leads to Wider Optima and Better Generalization.” UAI 2018. SWA 论文。
  18. Zhang, H., et al. (2018). “mixup: Beyond Empirical Risk Minimization.” ICLR 2018. Mixup。
  19. Hinton, G., Vinyals, O., & Dean, J. (2015). “Distilling the Knowledge in a Neural Network.” NIPS 2014 Deep Learning Workshop. 知识蒸馏。
  20. López de Prado, M. (2018). Advances in Financial Machine Learning. Wiley. 第 4 章(样本权重)、第 7 章(Cross-Validation in Finance)。
  21. Gu, S., Kelly, B., & Xiu, D. (2020). “Empirical Asset Pricing via Machine Learning.” Review of Financial Studies, 33(5). 横评 OLS、ENet、GBDT、NN 在美股上的表现。
  22. Sezer, O. B., Gudelek, M. U., & Ozbayoglu, A. M. (2020). “Financial time series forecasting with deep learning: A systematic literature review: 2005-2019.” Applied Soft Computing, 90. 综述。
  23. PyTorch 官方文档:https://pytorch.org/docs/stable/index.html
  24. ONNX Runtime 官方文档:https://onnxruntime.ai/docs/
  25. vectorbt 官方文档:https://vectorbt.dev/

系列导航

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-05-01 · quant

【量化交易】量化交易全景:从信号到订单的工程链路

量化交易不是策略写得好就能赚钱,更难的是把数据、特征、因子、信号、组合、执行、风控、复盘这八段链路在工程上连成一条不漏数据、不串时间、不丢订单的流水线。本文是【量化交易】系列的总目录与读图,给出八段链路的输入输出、失败模式、不变量清单,并用研究流程图把从一个想法到一笔实盘订单之间所有该过的卡点串起来。

2026-05-01 · quant

【量化交易】市场结构:交易所、做市商、暗池、ECN

系统梳理全球市场结构(Market Structure)的工程图景:从证券交易所、衍生品交易所、加密交易所,到做市商、暗池、ECN/ATS,再到 Maker-Taker 收费、PFOF、Reg NMS 与 MiFID II 的监管影响;给出量化策略选择交易场所的判断框架与基于 ccxt 的多交易所行情聚合代码。

2026-05-01 · quant

【量化交易】市场微结构:订单簿、价差、流动性、冲击

系统讲解市场微结构的核心概念与可计算工具:限价订单簿的数据模型、报价/有效/已实现价差、Roll 模型、四维流动性度量、Kyle's lambda、订单流不平衡(OFI)、Almgren-Chriss 框架下的临时与永久冲击、PIN 与 VPIN、Hawkes 过程,并给出基于 polars 的 L2 增量处理与系数估计代码。

2026-05-01 · quant

【量化交易】订单类型与执行语义:限价、市价、IOC、FOK、冰山

把 Limit、Market、IOC、FOK、Iceberg、Stop、MOO/MOC 这些常被混为一谈的订单类型还原为价格、数量、时效、可见性、触发五个独立维度,并对照 A 股、港股、美股、CME、Binance 五个市场的实际语义差异,给出量化系统中的订单工厂、状态机与风控前置校验的工程实现。


By .