神经网络变得简单(第 88 部分):时间序列密集编码器(TiDE)(基础篇)
📘

神经网络变得简单(第 88 部分):时间序列密集编码器(TiDE)(基础篇)

第 1/3 篇

「用 TiDE 把行情序列压成稠密向量」

TiDE(Time-series Dense Encoder)是一种轻量时序编码结构,核心思路是把多变量历史窗口先映射为低维稠密向量,再解码回预测 horizon。相比 Transformer 类模型,它不依赖注意力矩阵,参数量通常能压到同精度 RNN 的 1/3 左右,在 MT5 这类终端上跑周级回测更不容易爆内存。 在 MQL5 里实现 TiDE 的编码段,可以用多层全连接把输入特征(如 close、rsi、vol)先降维。下面这段演示了编码层的前向逻辑,输入窗口长度 30、编码维度 16。 外汇与贵金属杠杆高、跳空频繁,用此类编码器做信号倾向时务必做样本外验证,实盘前先在策略测试器跑至少 3 个月 tick 数据。

MQL5 / C++
class="type">class="kw">double EncodeTiDE(class="type">class="kw">double &class="kw">input[], class="type">int win, class="type">int dim, class="type">class="kw">double &weights[])
{
   class="type">class="kw">double encoded[class="num">16];
   ArrayInitialize(encoded, class="num">0.0);
   for(class="type">int i=class="num">0; i<win; i++)
      for(class="type">int j=class="num">0; j<dim; j++)
         encoded[j] += class="kw">input[i] * weights[i*dim + j];
   class="kw">return encoded[class="num">0];
}

◍ TiDE 为什么比变换器更适合长周期预测

变换器架构在时间序列预测里一度被寄予厚望,但近年的基准测试暴露了短板:在部分长周期预测任务上,朴素线性模型就能打出持平甚至更好的成绩。问题在于线性模型自身——它假设协变量与时间无关,一旦遇到非线性依赖就直接失效。 TiDE(时间序列密集编码器)走的是另一条路:用纯 MLP 替代自关注、递归和卷积层,只对历史序列加协变量做编码,再联合未来协变量解码出预测。因为没有注意力机制,它的计算复杂度随上下文长度和预测跨度呈线性增长,而不是变换器那种平方级膨胀。 作者在线性动态系统(LDS)里做了推导:当 LDS 设计矩阵的最大奇异值不等于 1 时,简化版线性 TiDE 能逼近最优误差。模拟数据上,它的表现压过了 LSTM 和变换器。 放到真实世界基准看,TiDE 相较此前的神经网络基线持平或占优,而推理速度比最强的变换器模型快 5 倍,训练快 10 倍以上。外汇与贵金属行情属高噪声非线性序列,直接套用前务必用 MT5 接历史 tick 做回测,这类深度学习模型在高杠杆品种上仍属高风险。

TiDE 怎么把历史价和协变量压成预测

TiDE(Time-series Dense Encoder)是一种纯 MLP 架构的长期序列预测模型,核心是用闭环模块(带一个隐藏层 + ReLU + 线性跳接 + Dropout + 输出归一化)逐通道处理单条序列的过去值与协变量,权重在全数据集上全局共享,不区分品种通道。 编码侧先拿闭环模块把历史与未来协变量各自投影到低维,再汇集平滑,拼上静态属性和过去序列,送进密集编码器得到嵌入;解码侧用密集解码器堆若干闭环模块把嵌入映成预测状态向量,再按时态解码器逐个 t 步映射出预测,同时把未来协变量接进来——比如某日历日新闻面突袭,这种直接传导在贵金属跳空时可能更管用。 时态解码器输出会加上全局残差连接(过去序列线性直映到预测区间),保证纯线性模型始终是 TiDE 子类。训练用小批量梯度下降 + MSE 损失,每局含历史对与预测横向范围,两小批量时间点在训练区间可能重叠,外汇与贵金属杠杆高,回测过拟合风险大,开 MT5 用自有品种验证前别直接信论文结论。

「用 MQL5 把 TiDE 拼成可训练模块」

TiDE 的核心是一个闭环模块,原作者用全连接层处理每个独立通道,且模块参数跨所有通道全局共享。我们在 MT5 里想跑并行,就直接复用之前 CCMR 方法写过的 CResidualConv 卷积模块——窗口大小等于步幅、对应单通道数据量,也就是历史深度。差别只是 CCMR 里带归一化层,这里先忽略。 新类 CNeuronTiDEOCL 继承自 CNeuronBaseOCL,关键参数只有 4 个:iHistory(历史深度)、iForecast(预测步数)、iVariables(通道数)、iFeatures(协变量数)。编码器和解码器合并进 acEncoderDecoder[] 数组,数组长度即闭环模块总数;协变量投影拆成 acFeatureProjection[2],分别喂给编码和解码;另配 cTemporalDecoder 时态解码器和 cGlobalResidual 卷积层做全局残差。所有对象声明为静态,构造/析构留空。 前馈时,历史数据先投影到预测维度(自回归做法),时间戳用年/月/周/日 4 个谐量生成协变量投影,与历史级联后送进编码-解码循环,解码输出再与时态解码器输入级联,最后双流相加并跨通道归一化。反向传播分两步:先按逆向顺序在嵌套模块间分派误差梯度,再用 updateInputWeights 逐对象调参。 一个容易踩的坑:Train 方法里时间戳谐量缓冲区和它的梯度指针都传给反向传播。哪怕你后续根本不用谐量梯度,也不能拿谐量本身覆盖梯度缓冲——各层调权时会读输入端的误差梯度,覆盖掉会导致投影参数更新失真,训练走向不可预测。 加载模型时只存可训练参数和关键常量,所以 Load 方法要先读模块堆叠大小,必要时 resize 数组再初始化元素后读数据,最后补辅助对象并交换缓冲区。附件里给出了完整类代码,开 MT5 把 CResidualConv 和这类接好就能先跑通前馈。

MQL5 / C++
class CNeuronTiDEOCL :  class="kw">public CNeuronBaseOCL
  {
class="kw">protected:
   class="type">uint                iHistory;

◍ TiDE 神经元的类骨架与初始化落点

在 MT5 用 OpenCL 跑时序模型时,CNeuronTiDEOCL 这个类把历史窗口、预测步长和外生变量数全收进成员变量:iForecast 管预测长度,iVariables 管变量维度,iFeatures 管特征投影宽度。注意 iFeatures 和 iHistory 都用了 MathMax(x,1) 兜底,说明框架不允许零长度输入,否则 Init 直接返 false。 下面这段声明值得直接抄进你的头文件改造。acFeatureProjection 数组长度写死为 2,分别处理历史侧和预测侧的特征投影;cGlobalResidual 与 cTemporalDecoder 则承担残差与时序解码。虚函数 feedForward / updateInputWeights / calcInputGradients 都带 SecondInput 指针,意味着这套结构原生支持双输入(比如价格序列 + 成交量)。 Init 里有个容易踩的坑:基类 CNeuronBaseOCL::Init 的第三个尺寸参数是 forecast * variables,而不是 history。也就是说输出缓冲按「预测步长×变量数」铺开,历史长度只在后面 acFeatureProjection[0] 的 Init 中才用到(iHistory * iVariables)。如果你改了变量数没同步 forecast,模型可能静默初始化失败。 外汇与贵金属行情跳空频繁,这类 GPU 时序网络在高波动段预测偏差可能放大,实盘前务必用历史数据回测验证。

MQL5 / C++
class="type">uint iForecast;
class="type">uint iVariables;
class="type">uint iFeatures;
class=class="str">"cmt">//---
CResidualConv acEncoderDecoder[];
CNeuronConvOCL cGlobalResidual;
CResidualConv acFeatureProjection[class="num">2];
CResidualConv cTemporalDecoder;
class=class="str">"cmt">//---
CNeuronBaseOCL cHistoryInput;
CNeuronBaseOCL cFeatureInput;
CNeuronBaseOCL cEncoderInput;
CNeuronBaseOCL cTemporalDecoderInput;
class=class="str">"cmt">//---
class="kw">virtual class="type">bool feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput);
class="kw">virtual class="type">bool updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput);
class="kw">virtual class="type">bool calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput,
CBufferFloat *SecondGradient,
ENUM_ACTIVATION SecondActivation = None);
class="kw">public:
 CNeuronTiDEOCL(class="type">void) {};
 ~CNeuronTiDEOCL(class="type">void) {};
 class=class="str">"cmt">//---
 class="kw">virtual class="type">bool Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl,
 class="type">uint history, class="type">uint forecast, class="type">uint variables, class="type">uint features,
 class="type">uint &encoder_decoder[], ENUM_OPTIMIZATION optimization_type,
 class="type">uint batch);
 class=class="str">"cmt">//---
 class="kw">virtual class="type">bool Save(class="type">int const file_handle);
 class="kw">virtual class="type">bool Load(class="type">int const file_handle);
 class=class="str">"cmt">//---
 class="kw">virtual class="type">int Type(class="type">void) const { class="kw">return defNeuronTiDEOCL; }
 class="kw">virtual class="type">void SetOpenCL(COpenCLMy *obj);
 class=class="str">"cmt">//---
 class="kw">virtual class="type">bool WeightsUpdate(CNeuronBaseOCL *source, class="type">class="kw">float tau);
};
class="type">bool CNeuronTiDEOCL::Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint history,
 class="type">uint forecast, class="type">uint variables, class="type">uint features, class="type">uint &encoder_decoder[],
 ENUM_OPTIMIZATION optimization_type, class="type">uint batch)
 {
 if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, forecast * variables,
 optimization_type, batch))
 class="kw">return false;
 iHistory = MathMax(history, class="num">1);
 iForecast = forecast;
 iVariables = variables;
 iFeatures = MathMax(features, class="num">1);
 if(!acFeatureProjection[class="num">0].Init(class="num">0, class="num">0, OpenCL, iFeatures, iHistory * iVariables, class="num">1,
 optimization, iBatch))
 class="kw">return false;
 if(!acFeatureProjection[class="num">1].Init(class="num">0, class="num">1, OpenCL, iFeatures, iForecast * iVariables, class="num">1,
 optimization, iBatch))
 class="kw">return false;

残差与时序解码器的初始化衔接

这段逻辑出现在 TiDE 类神经网络结构的构建尾部,负责把编码器栈、全局残差连接和时序解码器一次性挂到 OpenCL 上下文上。先取 encoder_decoder 数组长度 total,若扩容 acEncoderDecoder 失败直接返回 false,保证内存边界不出错。 当 total 为 0 时只初始化单层编码器,历史窗口长度用 2 * iHistory,预测步长走 iForecast;若已有堆叠层,则循环把第 i 层输入指向上一层输出,层号从 i+2 递增,最后一层接 iForecast 做出口。 残差块 cGlobalResidual 被赋予层号 total+3 并强制 TANH 激活,时序解码器 cTemporalDecoder 用 2*iForecast 作输入维、iForecast 作输出维。若梯度类型不匹配就调用 SetGradient 修正,任何一步失败都回退。 feedForward 里先校验指针,再比对 cHistoryInput 的输出索引与传入 NeuronOCL 是否一致,不一致就把临时缓冲重定向到对应索引。外汇与贵金属行情下用这类结构做预测属高概率尝试,实盘前务必在 MT5 策略测试器用历史数据验证层数与 iBatch 的显存占用。

MQL5 / C++
class="type">int total = ArraySize(encoder_decoder);
if(ArrayResize(acEncoderDecoder, total + class="num">1) < total + class="num">1)
   class="kw">return false;
if(total == class="num">0)
   {
    if(!acEncoderDecoder[class="num">0].Init(class="num">0, class="num">2, OpenCL, class="num">2 * iHistory, iForecast, iVariables,
optimization, iBatch))
       class="kw">return false;
   }
else
   {
    if(!acEncoderDecoder[class="num">0].Init(class="num">0, class="num">2, OpenCL, class="num">2 * iHistory, encoder_decoder[class="num">0], iVariables,
optimization, iBatch))
       class="kw">return false;
    for(class="type">int i = class="num">1; i < total; i++)
       if(!acEncoderDecoder[i].Init(class="num">0, i + class="num">2, OpenCL, encoder_decoder[i - class="num">1],
encoder_decoder[i], iVariables, optimization, iBatch))
          class="kw">return false;
    if(!acEncoderDecoder[total].Init(class="num">0, total + class="num">2, OpenCL, encoder_decoder[total - class="num">1],
iForecast, iVariables, optimization, iBatch))
       class="kw">return false;
   }
if(!cGlobalResidual.Init(class="num">0, total + class="num">3, OpenCL, iHistory, iHistory, iForecast, iVariables,
optimization, iBatch))
   class="kw">return false;
cGlobalResidual.SetActivationFunction(TANH);
if(!cTemporalDecoder.Init(class="num">0, total + class="num">4, OpenCL, class="num">2 * iForecast, iForecast, iVariables,
optimization, iBatch))
   class="kw">return false;
if(!cHistoryInput.Init(class="num">0, total + class="num">5, OpenCL, iHistory * iVariables, optimization, iBatch))
   class="kw">return false;
if(!cFeatureInput.Init(class="num">0, total + class="num">6, OpenCL, iFeatures, optimization, iBatch))
   class="kw">return false;
if(!cEncoderInput.Init(class="num">0, total + class="num">7, OpenCL, class="num">2 * iHistory * iVariables, optimization,iBatch))
   class="kw">return false;
if(!cTemporalDecoderInput.Init(class="num">0, total + class="num">8, OpenCL, class="num">2 * iForecast * iVariables,
optimization, iBatch))
   class="kw">return false;
if(cGlobalResidual.getGradient() != Gradient)
   if(!cGlobalResidual.SetGradient(Gradient))
      class="kw">return false;
if(cTemporalDecoder.getGradient() != getGradient())
   if(!cTemporalDecoder.SetGradient(Gradient))
      class="kw">return false;
class=class="str">"cmt">//---
   class="kw">return true;
}
class="type">bool CNeuronTiDEOCL::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput)
  {
  if(!NeuronOCL || !SecondInput)
     class="kw">return false;
  if(cHistoryInput.getOutputIndex() != NeuronOCL.getOutputIndex())
    {
     CBufferFloat *temp = cHistoryInput.getOutput();
     if(!temp.BufferSet(NeuronOCL.getOutputIndex()))
        class="kw">return false;
    }

常见问题

TiDE 用多层稠密编码替代注意力,计算随序列长度近似线性增长,长周期下显存和训练时间明显低于变换器,适合日线级以上预测。
把历史价作为主序列、把成交量或利率等作为协变量并行接入编码器,经稠密层压缩成向量后再送时序解码器输出未来步长。
可以,小布内置了序列压缩与长周期预测诊断,打开对应品种页即可加载 TiDE 模块并给出概率性方向参考,你只需看结论调参数。
残差分支初始权重宜接近零、主路正常初始化,让解码器起点接近恒等映射,训练前期损失更稳,后续再放开学习。
多因编码层过窄或初始化落点集中,可加宽首层并采用差异化随机初始化,同时检查协变量是否含常量列导致信息退化。