Neural ODEs: Does Continuous Time Beat a Delta-Time Feature?
神经常微分方程(Chen 等,NeurIPS 2018)用神经网络参数化隐藏状态的导数,再由 ODE 求解器计算输出。模型因此具有连续深度;对市场更有用的是,它具有连续时间:可以在任意 评估隐藏状态,而不只是在数据恰好落在的网格点上。
这项性质对以随机间隔到达的金融数据很有吸引力。但这个宣传隐藏的主张远弱于表面看起来的说法。给普通 GRU 增加一个输入特征——自上次观测以来的 ——它同样"知道"经过了多长时间。真正的问题不是连续时间模型能否接收不规则数据,而是观测之间学习到的连续动态,是否能提取出增量时间特征已经提供不了的额外信息,同时推理成本却高出十到一百倍。
本文搭建这一实验,给出运行它所需的完整工具链,并说明比较结果必须达到什么标准才足以证明这套机制值得使用。关于柱线构造的论点——交易时机本身携带信号——已经在超越时间柱线中用 17 种柱线类型的实测证据得到验证;这里唯一重要的建模后果是:求解器可以在任意 评估隐藏状态,因此不需要插值或填充。
待检验的主张

三个嵌套假设,每一个都严格强于前一个:
- H1 — 在原始不规则 BTC 成交流上拟合的 ODE-RNN,优于在同一数据流上完全不提供时间信息的 GRU。这几乎必然成立,也几乎没有价值:ODE-RNN 只是拥有基线缺少的信息。
- H2 — 当给 GRU 增加 输入特征后,ODE-RNN 仍然优于同一个 GRU。这才是诚实的检验。这是人们通常从 Neural ODE 文献中读出的主张,却几乎从未在金融数据上得到证明。
- H3 — H2 的优势能够经受延迟预算,也就是 ODE-RNN 使用足够快、可用于实时推理的固定步长求解器部署时,仍然成立,而不是只在自适应
dopri5下成立。
实验协议
两种方案使用相同的 tick 流、相同的目标和相同的调参预算。不重采样为 1m 柱线,因为重采样会破坏正在检验的精确结构。
| 设置 | |
|---|---|
| 数据 | BTC 成交记录,可变的成交间隔,不重采样 |
| 方案 | ODE-RNN;GRU + ;不含时间特征的 GRU |
| 目标 | 所有方案相同;随拟合过程指定 |
| 指标 | RMSE、留出集对数似然、每轮耗时、每步推理延迟 |
| 求解器扫描 | dopri5(rtol 1e-5)与固定步长 Euler,训练精度匹配 |
| 划分 | Walk-forward,仅使用样本外数据 |
| 方案 | RMSE | 对数似然 | 秒/轮 | 每步推理延迟 |
|---|---|---|---|---|
| GRU(无时间特征) | — | — | — | — |
| GRU + log(dt) | — | — | — | — |
| ODE-RNN(dopri5) | — | — | — | — |
| ODE-RNN(固定步长 Euler) | — | — | — | — |
H2 上的负面结果完全可以发表,而且可能是更有价值的结果——诚实的负面结果和多重检验下的折减夏普采用的正是这一标准。
次级实验:CNF 与 Student-t
如果 ODE-RNN 比较的成本过高,更便宜的实证锚点是分布建模:在真实 BTC 日收益上拟合连续标准化流,将拟合密度与 Student-t 拟合及经验尾部进行比较,并报告 1% 和 5% 水平的尾部分位数误差。这与GARCH 波动率预测和非对称 GARCH中已经测量的分布建模工作直接相连。
本文其余部分介绍运行上述实验所需的背景知识和工具链。
背景:从 ResNet 到连续动态

残差网络计算 。缩小步长并增加层数,就会逼近连续极限:
不再使用带有独立参数的 个离散层,而是由一个网络 指定瞬时变化率。时刻 的输出满足初值问题:
黑盒求解器(Euler、Runge-Kutta、Dormand-Prince)以数值方式计算积分,并根据局部误差估计自适应选择步长。
伴随法
穿过每个求解器步骤反向传播需要保存所有中间状态,内存开销与步数成正比。Chen 等人改为使用 内存求解反向 ODE。定义伴随状态 ,它满足
并在反向过程中累积参数梯度:
反向过程求解一个增广系统,同时计算 、 和 ,让求解器从 反向运行到 。
这种方法的代价是:反向重建 会累积数值误差,对于刚性或混沌动态尤其严重。检查点是折中方案——在少数中间时刻保存 ,在这些时刻之间重新计算。价格过程是连续半鞅且相当平滑,因此伴随法通常够用;微观结构事件附近的刚性可能迫使我们使用自适应求解器或混合方案。
ODE-RNN:观测之间的连续隐藏状态

ODE-RNN 是主要实验使用的架构。观测之间,隐藏状态按照 ODE 演化:
当观测 在 到达时,会触发一次离散更新:
上标表示观测之前和之后的状态。观测之间是学习到的连续动态;观测到来时则注入新信息。
H2 消融检验的机制是:长时间间隔意味着隐藏状态在 作用下演化了很长时间——可能衰减到基线,也可能发散——而这种演化形状是学习得到的,不是作为一个标量直接提供的。这种表达能力能否在真实成交数据上带来回报,正是尚未测量的部分。
tick 之外的自然应用包括:多资产投资组合中每种资产都有自己的观测时间表,相关资产的潜在状态在只有一个资产被观测时仍持续演化;以及不规则到达的事件驱动信号(新闻、财报、宏观数据发布)。
神经 SDE:加入随机成分

神经 ODE 是确定性的,无法表示价格路径中的噪声。经典处理方法——几何布朗运动、风险中性漂移,以及恒定波动率假设为何无法解释波动率微笑——见Black-Scholes 期权定价。神经 SDE 用学习到的扩散项替代参数化形式:
是漂移项, 是扩散项, 是 Wiener 过程, 和 都是神经网络。
架构:漂移网络与扩散网络
- 漂移网络 :预期轨迹,包括趋势、均值回归和动量。训练目标是最小化预测误差。
- 扩散网络 :作为状态函数学习的噪声幅度,在高波动状态下较大,在平静状态下较小。
这种拆分对应经典量化金融:漂移是风险中性(或 P 测度)动态,扩散是波动率曲面。
训练神经 SDE
随机积分 在经典意义下不可微。常见方法有三种:
- 路径梯度:重参数化技巧——采样布朗路径,将噪声视为固定输入并穿过求解器求导。
- 得分匹配:估计 ,通过去噪目标训练;这与加密货币预测的扩散模型中作为独立生成模型使用的机制相同,只是在这里降级为 SDE 的训练选项。
- 有限维分布匹配:匹配观测时刻的边缘分布,而不是完整的路径测度。
路径梯度是实践中的默认方案。torchsde 实现 Euler-Maruyama、Milstein 和随机 Runge-Kutta,并支持自动微分。
学习波动率曲面
经典模型(Heston、SABR)对扩散系数施加参数化形式。神经 SDE 将 学习为任意函数:
这是扩散过程的通用逼近器:只要容量足够,就能以任意精度逼近任意 Ito 过程。
用于缺失和异步数据的潜在 ODE

潜在 ODE(Rubanova、Chen、Duvenaud,2019)将 Neural ODE 与 VAE 结合:金融序列是平滑底层过程的噪声观测,该过程在低维潜在空间中学习。
- 识别网络:ODE-RNN 反向穿过观测值,生成 。
- 潜在动态:。
- 解码器:,可在任意请求的时刻求值。
损失是标准 ELBO:
投资组合状态估计:从跨资产的稀疏异步观测中推断捕捉联合动态的连续潜在状态,本质上是卡尔曼滤波器的非线性学习版本。
缺失数据插补:停牌和周末间隔得到平滑插值的潜在轨迹;解码器利用 VAE 后验给出的不确定性,生成穿过间隔的合理路径。
多频率融合:将日收盘价、日内 VWAP 和 tick 数据放入同一模型,无需共享时间网格。
用于收益分布的连续标准化流

CNF 使用 Neural ODE 将简单的基础分布变换为复杂目标。变换为 ,对数密度遵循变量的瞬时变化:
从 向前积分状态和对数密度,得到 和 。为实现可扩展性,使用 Hutchinson 随机估计器估计雅可比迹。
加密货币收益具有尖峰厚尾和负偏度——这已在真实数据的GARCH 波动率预测和非对称 GARCH 与杠杆效应中测量——CNF 学习这种形状,而不是预先假设它。以状态变量为条件可以让形状随市场状态变化。由于密度是精确的而非近似的,它可以提供给蒙特卡洛与 bootstrap 回测计算的尾部指标,以及HRP/CVaR 投资组合流程中的 CVaR 构造;联合尾部结构则由联合风险的 copula 模型单独处理。
条件密度估计
有用的表述方式,是直接对比针对同一问题的三种已发表方法。TFT通过分位数输出层给出固定的一组分位数。保序预测给出带覆盖率保证的校准区间。条件 CNF 给出精确且可微的密度:
可微性是它的区别所在:密度可以嵌入下游目标并通过反向传播,而固定分位数和保序区间都不支持这一点。在同一目标上,相比 TFT 分位数,精确密度是否值得付出其成本,本文尚未检验。
与离散替代方案的比较

| 属性 | LSTM/GRU | Transformer | Neural ODE | Neural SDE |
|---|---|---|---|---|
| 不规则时间处理 | 较差(需要填充) | 位置编码 | 原生支持 | 原生支持 |
| 内存(训练) | 伴随法 | 伴随法 | ||
| 不确定性量化 | 否(确定性) | 否(确定性) | 通过集成 | 原生支持 |
| 观测之间插值 | 否 | 否 | 是 | 是 |
| 连续时间密度 | 否 | 否 | 通过 CNF | 通过路径测度 |
| 计算成本 | 低 | 中等 | 可变(取决于求解器) | 高(SDE 求解器) |
表格中 LSTM 与注意力机制的比较,连同基准测试,已在TFT 文章中详细讨论;参见其中的"TFT vs LSTM vs Vanilla Transformer"和"When LSTM Still Wins"章节。这里新增的是右侧两列,而决定 H2/H3 问题的正是最后两行。
使用 torchdiffeq 的 Python 实现

torchdiffeq 提供带伴随反向传播的 ODE 求解器。
用于价格动态的基础 Neural ODE
import torch
import torch.nn as nn
from torchdiffeq import odeint_adjoint as odeint
class PriceDynamics(nn.Module):
"""Neural network defining dh/dt = f(h, t)."""
def __init__(self, hidden_dim: int = 64):
super().__init__()
self.net = nn.Sequential(
nn.Linear(hidden_dim, 128),
nn.Tanh(),
nn.Linear(128, 128),
nn.Tanh(),
nn.Linear(128, hidden_dim),
)
def forward(self, t, h):
return self.net(h)
class NeuralODEPredictor(nn.Module):
"""
Encode observed features -> latent state,
evolve via Neural ODE,
decode to price prediction.
"""
def __init__(self, input_dim: int, hidden_dim: int = 64):
super().__init__()
self.encoder = nn.Linear(input_dim, hidden_dim)
self.dynamics = PriceDynamics(hidden_dim)
self.decoder = nn.Linear(hidden_dim, 1)
def forward(self, x0, eval_times):
"""
x0: (batch, input_dim) features at t=0
eval_times: (T,) times at which to evaluate the ODE
Returns: (T, batch, 1) predictions
"""
h0 = self.encoder(x0) # (batch, hidden_dim)
h_traj = odeint(self.dynamics, h0, eval_times,
method='dopri5', rtol=1e-5, atol=1e-7)
return self.decoder(h_traj)
用于不规则 tick 数据的 ODE-RNN
这是实验方案。它必须击败的基线,是在同一数据流上使用 nn.GRUCell,并将 log(t_next - t_prev) 与 x 拼接。
class ODERNNCell(nn.Module):
"""Single step: ODE-evolve, then RNN-update."""
def __init__(self, input_dim: int, hidden_dim: int = 64):
super().__init__()
self.dynamics = PriceDynamics(hidden_dim)
self.gru_cell = nn.GRUCell(input_dim, hidden_dim)
def forward(self, h, x, t_prev, t_next):
times = torch.tensor([t_prev, t_next], dtype=torch.float32)
h_evolved = odeint(self.dynamics, h, times,
method='dopri5')[-1] # state at t_next
h_updated = self.gru_cell(x, h_evolved)
return h_updated
class ODERNN(nn.Module):
"""Process irregularly-sampled sequence."""
def __init__(self, input_dim: int, hidden_dim: int = 64):
super().__init__()
self.cell = ODERNNCell(input_dim, hidden_dim)
self.decoder = nn.Linear(hidden_dim, 1)
self.hidden_dim = hidden_dim
def forward(self, observations, times):
"""
observations: list of (batch, input_dim) tensors
times: list of floats, observation timestamps
"""
batch_size = observations[0].shape[0]
h = torch.zeros(batch_size, self.hidden_dim)
outputs = []
for i in range(len(observations)):
t_prev = 0.0 if i == 0 else times[i - 1]
h = self.cell(h, observations[i], t_prev, times[i])
outputs.append(self.decoder(h))
return torch.stack(outputs) # (seq_len, batch, 1)
训练循环
def train_neural_ode(model, train_loader, epochs=100, lr=1e-3):
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optimizer, T_max=epochs
)
for epoch in range(epochs):
epoch_loss = 0.0
for batch in train_loader:
features, times, targets = batch
optimizer.zero_grad()
predictions = model(features, times)
loss = torch.nn.functional.mse_loss(predictions, targets)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
epoch_loss += loss.item()
scheduler.step()
if (epoch + 1) % 10 == 0:
avg_loss = epoch_loss / len(train_loader)
print(f"Epoch {epoch+1}/{epochs}, Loss: {avg_loss:.6f}")
用于收益分布的连续标准化流
from torchdiffeq import odeint
class CNFDynamics(nn.Module):
"""Dynamics for continuous normalizing flow."""
def __init__(self, dim: int = 1, hidden_dim: int = 64):
super().__init__()
self.net = nn.Sequential(
nn.Linear(dim + 1, hidden_dim), # +1 for time
nn.Tanh(),
nn.Linear(hidden_dim, hidden_dim),
nn.Tanh(),
nn.Linear(hidden_dim, dim),
)
self.dim = dim
def forward(self, t, state):
z = state[..., :self.dim]
t_expand = t.expand(z.shape[0], 1)
zt = torch.cat([z, t_expand], dim=-1)
dz = self.net(zt)
e = torch.randn_like(z)
e_dz = torch.autograd.grad(
dz, z, e, create_graph=True
)[0]
trace_jac = (e_dz * e).sum(dim=-1, keepdim=True)
return torch.cat([dz, -trace_jac], dim=-1)
class ReturnDistributionCNF(nn.Module):
"""Model return distributions with continuous normalizing flows."""
def __init__(self, dim: int = 1):
super().__init__()
self.dynamics = CNFDynamics(dim)
self.dim = dim
def log_prob(self, x):
"""Compute log probability of observed returns."""
log_p0 = torch.zeros(x.shape[0], 1)
state0 = torch.cat([x, log_p0], dim=-1)
state0.requires_grad_(True)
times = torch.tensor([1.0, 0.0]) # backward
state_T = odeint(self.dynamics, state0, times,
method='dopri5')[-1]
z_T = state_T[..., :self.dim]
delta_log_p = state_T[..., self.dim:]
log_p_base = -0.5 * (z_T ** 2 + torch.log(
torch.tensor(2 * torch.pi)
)).sum(dim=-1, keepdim=True)
return log_p_base + delta_log_p
def sample(self, n_samples: int):
"""Generate samples from learned distribution."""
z0 = torch.randn(n_samples, self.dim)
log_p0 = torch.zeros(n_samples, 1)
state0 = torch.cat([z0, log_p0], dim=-1)
times = torch.tensor([0.0, 1.0]) # forward
state_T = odeint(self.dynamics, state0, times,
method='dopri5')[-1]
return state_T[..., :self.dim]
实践注意事项

以下事项会在运行上述实验时造成实际问题。
求解器选择与速度
dopri5 提供精度保证,但计算成本可变,因此 H3 与 H2 是两个不同的假设:只在自适应求解器下存在的优势,可能无法经受实时延迟预算。固定步长求解器(Euler、RK4)以精度换取可预测的延迟。实际的折中方案是使用 dopri5 训练,用经过校准的固定步长求解器部署,使其在代表性数据上的输出匹配自适应求解器——并测量差距,不要假设差距很小。
odeint(f, h0, t, method='dopri5', rtol=1e-6, atol=1e-8)
odeint(f, h0, t, method='euler', options={'step_size': 0.1})
对于不连续跳变附近的刚性问题,可使用 method='implicit_adams' 或 method='scipy_solver'。
数值稳定性
如果 输出很大的值,状态就会发散。缓解方法:
- 对 的层使用谱归一化,控制 Lipschitz 常数。
- 训练期间进行梯度裁剪(见上面的训练循环)。
- 时间归一化:将时间戳缩放到 。
- 对动态范数进行正则化:将 加入损失。
积分区间
对于跨越数月的数据,不要从 积分到 分钟:
t_normalized = (timestamps - timestamps[0]) / (timestamps[-1] - timestamps[0])
这样可以让求解器保持在数值上友好的范围,并使学习到的动态具有尺度不变性。这对比较也很重要:未归一化的 ODE-RNN 可能仅因数值原因输给 GRU 基线,那将是测量伪象而不是发现。
处理多个时间尺度
市场同时存在微秒、秒、分钟和日级动态,单个 Neural ODE 可能难以容纳全部动态。可选方案包括:在不同时间尺度堆叠多个 ODE 模块;增大隐藏维度以同时容纳快慢动态;或按频段运行独立的 Neural ODE,再融合输出。(这是模型容量问题,不同于自适应分辨率深挖中的回测保真度问题。)
开放研究方向

神经跳跃 SDE:为突然的市场错位(闪崩、财报意外)加入学习到的跳跃成分:
其中 是补偿泊松随机测度, 是学习到的跳跃核。
神经控制微分方程(Neural CDE):用一般驱动信号替代 Wiener 过程,让观测数据流驱动动态。这对订单流很自然,因为成交和报价流驱动着潜在的市场状态。
可微市场模拟:将 Neural SDE 用作可微模拟器中的生成模型,通过伴随法反向传播来端到端训练策略。策略与市场模型共同演化。
物理信息神经 ODE:PINN 将微分方程残差嵌入损失;该技术本身在Navier-Stokes 文章中介绍。金融应用是将无套利、看跌-看涨平价和鞅条件作为惩罚项或对学习到的动态施加硬约束。
结论

连续时间的建模视角在结构上是正确的:市场是连续过程,却在离散且不规则的时刻被观测,模型应当尊重这一点。工具链已经成熟——torchdiffeq、torchsde,以及其余部分使用的普通 PyTorch——已知成本是求解器速度和长积分区间上的数值稳定性。
尚未确定的是,这套方法是否有任何部分应当进入生产技术栈。结构正确并不等于具有预测优势;相对于 特征,连续动态所宣称的具体优势,本文还没有在真实成交数据上测量。在上面的 H2 表填完之前,折叠线以下的内容都应被视为带有可运行实现的、定义清楚的假设,而不是结果。
参考文献
- Chen, R.T.Q., Rubanova, Y., Bettencourt, J., Duvenaud, D. (2018). 神经常微分方程。NeurIPS 2018。arXiv:1806.07366
- Rubanova, Y., Chen, R.T.Q., Duvenaud, D. (2019). 不规则采样时间序列的潜在 ODE。NeurIPS 2019。arXiv:1907.03907
- Jia, J., Benson, A.R. (2019). 神经跳跃随机微分方程。NeurIPS 2019。
- Kidger, P., Morrill, J., Foster, J., Lyons, T. (2020). 不规则时间序列的神经控制微分方程。NeurIPS 2020。
- Hasan, A., Pereira, J.M., Farsiu, S., Carin, L. (2021). 神经网络随机微分方程模型及其在金融数据预测中的应用。arXiv:2111.13164
- torchdiffeq:github.com/rtqichen/torchdiffeq
- UvA 深度学习教程 — Neural ODE:uvadlc-notebooks.readthedocs.io
Authors
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.