神经网络变得简单(第 85 部分):多变元时间序列预测·进阶篇
🧠

神经网络变得简单(第 85 部分):多变元时间序列预测·进阶篇

(2/3)· 当 80% 历史数据被掩码变换器仍不退化,我们该重新审视时间序列预测的建模范式

偏理论进阶 第 2/3 篇
多数交易者以为堆叠更深的时序变换器就能抓长期依赖,却忽略了跨期关注度对变量间关系的钝感。把不同品种的相关性交给线性模块补位,反而更贴近真实行情结构。

◍ Transformer 客户端的初始化与前向链路

在 MT5 的 OpenCL 神经网络封装里,CNeuronClientOCL 把 Transformer 编码器、投影层和多个线性层拼成一条可训练链路。Init 方法里先拉起基类、编码器和输入缓冲,再用 for 循环按 mlp_layers 逐层构造 cLinearModel,每层激活函数设为 LReLU,最后一层与投影层改用 TANH。 代码段里能看到一个具体参数现象:末层线性模型 Init 的宽高都被压成 1,而投影层接收的 w 实际等于 mlp[mlp_layers-1] 经 count 放大后的值,这意味着特征维度在出口处被强制收敛。 feedForward 的走法是先跑编码器,再走投影,然后把 cInput 与上游输出索引对齐后逐层过线性模型,最后用 SumAndNormilize 以 0.5 为系数做归一。想在本地验证,直接把下面代码贴进 MT5 的自定义神经元类就能看张量形状是否对得上。 反向的 calcInputGradients 从投影层往编码器回传,再倒序扫线性层,这种结构决定了学习率若给大,梯度可能在前几层被 TANH 饱和区吞掉,外汇与贵金属样本上高波动时段尤甚,属高风险调参。

MQL5 / C++
  if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, mlp[mlp_layers - class="num">1] * count, optimization_type, batch))
      class="kw">return false;
  if(!cTransformerEncoder.Init(class="num">0, class="num">0, OpenCL, window, window_key, heads, count, at_layers, optimization, iBatch))
      class="kw">return false;
  if(!cInput.Init(class="num">0, class="num">1, open_cl, window * count, optimization_type, batch))
      class="kw">return false;
  class="type">uint w = window;
  for(class="type">uint i = class="num">0; i < mlp_layers; i++)
    {
      if(!cLinearModel[i].Init(class="num">0, i + class="num">2, OpenCL, w, w, mlp[i], count, optimization, iBatch))
        class="kw">return false;
      cLinearModel[i].SetActivationFunction(LReLU);
      w = mlp[i];
    }
  if(!cLinearModel[mlp_layers].Init(class="num">0, mlp_layers + class="num">2, OpenCL, class="num">1, class="num">1, class="num">1, w * count, optimization, iBatch))
      class="kw">return false;
  cLinearModel[mlp_layers].SetActivationFunction(TANH);
  if(!cProjection.Init(class="num">0, mlp_layers + class="num">3, OpenCL, window, window, w, count, optimization, iBatch))
      class="kw">return false;
  cProjection.SetActivationFunction(TANH);
  SetActivationFunction(TANH);
  if(!SetGradient(cProjection.getGradient()))
      class="kw">return false;
  if(!cLinearModel[mlp_layers].SetGradient(Gradient))
      class="kw">return false;
class=class="str">"cmt">//---
  class="kw">return true;
  }
class="type">bool CNeuronClientOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
  if(!cTransformerEncoder.FeedForward(NeuronOCL))
      class="kw">return false;
  if(!cProjection.FeedForward(GetPointer(cTransformerEncoder)))
      class="kw">return false;
  if(cInput.getOutputIndex() != NeuronOCL.getOutputIndex())
      cInput.getOutput().BufferSet(NeuronOCL.getOutputIndex());
  class="type">uint total = cLinearModel.Size();
  CNeuronBaseOCL *neuron = NeuronOCL;
  for(class="type">uint i = class="num">0; i < total; i++)
    {
      if(!cLinearModel[i].FeedForward(neuron))
        class="kw">return false;
      neuron = GetPointer(cLinearModel[i]);
    }
  if(!SumAndNormilize(neuron.getOutput(), cProjection.getOutput(), Output, class="num">1, false, class="num">0, class="num">0, class="num">0, class="num">0.5 ))
      class="kw">return false;
class=class="str">"cmt">//---
  class="kw">return true;
  }
class="type">bool CNeuronClientOCL::calcInputGradients(CNeuronBaseOCL *prevLayer)
  {
  if(!cTransformerEncoder.calcHiddenGradients(cProjection.AsObject()))
      class="kw">return false;
  if(!prevLayer.calcHiddenGradients(cTransformerEncoder.AsObject()))
      class="kw">return false;
  CNeuronBaseOCL *neuron = NULL;
  class="type">int total = (class="type">int)cLinearModel.Size() - class="num">1;
  for(class="type">int i = total; i >= class="num">0; i--)
    {
      neuron = (i > class="num">0 ? cLinearModel[i - class="num">1] : cInput).AsObject();

编码器五层结构的搭建细节

这段逻辑在做自编码器的前向梯度回传收尾,以及编码器各层的描述对象构建。回传部分先对每个线性模型对象调用隐藏层梯度计算,任一失败直接返回 false;随后用 SumAndNormilize 把当前层梯度与上一层梯度做累加归一,参数 1 与 false 控制缩放与偏置处理,最后返回 true 表示梯度链路通畅。 编码器创建从空指针保护开始:若传入的 encoder 为空则 new 一个 CArrayObj,失败即退出。随后 Clear 旧描述,按层叠放 CLayerDescription。输入层取 HistoryBars * BarDescr 个基神经元,宏里 HistoryBars 定为 120,激活函数 None,优化器 ADAM。 第一层是批归一化(defNeuronBatchNormOCL),count 沿用输入层总量,batch 设 1000,这意味着每次喂 1000 条样本做均值方差平滑,对外汇分钟级序列可缓解量纲跳变。第二层转置卷积把 120 根 K 线压成窗口 BarDescr。 第三层客户端神经元最重:window_out 设为 EmbeddingSize,step=4、layers=5,内部 temp 数组 {1024,1024,1024,NForecast} 拷入 windows,说明隐空间用了三层 1024 宽的全连结构。第四层再转置,把 BarDescr 与 NForecast 维度对调,为第五层做准备。 开 MT5 把 HistoryBars 从 120 改成 240 跑同一段,能直接观察显存占用与批次归一统计量的偏移,贵金属波动剧烈时这种偏移可能更明显,属高风险调参。

MQL5 / C++
if(!neuron.calcHiddenGradients(cLinearModel[i].AsObject()))
      class="kw">return false;
   }
 if(!SumAndNormilize(neuron.getGradient(), prevLayer.getGradient(), prevLayer.getGradient(), class="num">1, false))
   class="kw">return false;
class=class="str">"cmt">//---
  class="kw">return true;
  }
class="type">bool CreateEncoderDescriptions(CArrayObj *encoder)
  {
class=class="str">"cmt">//---
  CLayerDescription *descr;
class=class="str">"cmt">//---
  if(!encoder)
    {
      encoder = new CArrayObj();
      if(!encoder)
        class="kw">return false;
    }
class=class="str">"cmt">//--- Encoder
  encoder.Clear();
class=class="str">"cmt">//--- Input layer
  if(!(descr = new CLayerDescription()))
    class="kw">return false;
  descr.type = defNeuronBaseOCL;
  class="type">int prev_count = descr.count = (HistoryBars * BarDescr);
  descr.activation = None;
  descr.optimization = ADAM;
  if(!encoder.Add(descr))
    {
      class="kw">delete descr;
      class="kw">return false;
    }
class="macro">#define         HistoryBars           class="num">120       class=class="str">"cmt">//Depth of history
class=class="str">"cmt">//--- layer class="num">1
  if(!(descr = new CLayerDescription()))
    class="kw">return false;
  descr.type = defNeuronBatchNormOCL;
  descr.count = prev_count;
  descr.batch = class="num">1000;
  descr.activation = None;
  descr.optimization = ADAM;
  if(!encoder.Add(descr))
    {
      class="kw">delete descr;
      class="kw">return false;
    }
class=class="str">"cmt">//--- layer class="num">2
  if(!(descr = new CLayerDescription()))
    class="kw">return false;
  descr.type = defNeuronTransposeOCL;
  prev_count = descr.count = HistoryBars;
  class="type">int prev_wout = descr.window = BarDescr;
  if(!encoder.Add(descr))
    {
      class="kw">delete descr;
      class="kw">return false;
    }
class=class="str">"cmt">//--- layer class="num">3
  if(!(descr = new CLayerDescription()))
    class="kw">return false;
  descr.type = defNeuronClientOCL;
  descr.count = prev_wout;
  descr.window = prev_count;
  descr.step = class="num">4;
  descr.window_out = EmbeddingSize;
  descr.layers = class="num">5;
    {
      class="type">int temp[] = {class="num">1024, class="num">1024, class="num">1024, NForecast};
      ArrayCopy(descr.windows, temp);
    }
  if(!encoder.Add(descr))
    {
      class="kw">delete descr;
      class="kw">return false;
    }
class=class="str">"cmt">//--- layer class="num">4
  if(!(descr = new CLayerDescription()))
    class="kw">return false;
  descr.type = defNeuronTransposeOCL;
  prev_count = descr.count = BarDescr;
  prev_wout = descr.window = NForecast;
  if(!encoder.Add(descr))
    {
      class="kw">delete descr;
      class="kw">return false;
    }
class=class="str">"cmt">//--- layer class="num">5

「Actor 网络的层描述装配逻辑」

在 MT5 的 EA 工程里,用 CArrayObj 管理 Actor 与 Critic 的网络描述对象是常见做法。CreateDescriptions 函数先判空再 new,避免传入野指针;若 actor 或 critic 为空则现场构造,任一分配失败直接返回 false,保证后续 Add 操作不崩。 Actor 的输入层用 defNeuronBaseOCL 类型,节点数绑定 AccountDescr(账户状态维度),激活函数设 None,优化器统一走 ADAM。第二层节点数取 EmbeddingSize,激活切到 SIGMOID,把账户特征压进嵌入空间。 真正吃算力的是 layer 2-4 的循环:连续 3 次塞入 defNeuronCrossAttenOCL 交叉注意力层。每层 units 设为 {1, BarDescr}、windows 设为 {EmbeddingSize, NForecast},window_out=16、step=4,意味着每 4 根 Bar 滑一次、输出 16 维注意力特征,倾向捕捉中短周期的状态耦合。 第五层回到 defNeuronBaseOCL,节点数 LatentCount、激活 SIGMOID,作为策略潜变量出口。整段没有硬编码具体数值,全靠宏/枚举驱动,改 EmbeddingSize 或 NForecast 就能重配网络宽度,开 MT5 把这几个常量打印出来即可验证实际维度。

MQL5 / C++
  if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronRevInDenormOCL;
   prev_count = descr.count = prev_count * prev_wout;
   descr.activation = None;
   descr.optimization = ADAM;
   descr.layers = class="num">1;
   if(!encoder.Add(descr))
     {
       class="kw">delete descr;
       class="kw">return false;
     }
class=class="str">"cmt">//---
   class="kw">return true;
   }
class="type">bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic)
  {
class=class="str">"cmt">//---
   CLayerDescription *descr;
class=class="str">"cmt">//---
   if(!actor)
     {
       actor = new CArrayObj();
       if(!actor)
         class="kw">return false;
     }
   if(!critic)
     {
       critic = new CArrayObj();
       if(!critic)
         class="kw">return false;
     }
class=class="str">"cmt">//--- Actor
   actor.Clear();
class=class="str">"cmt">//--- Input layer
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronBaseOCL;
   class="type">int prev_count = descr.count = AccountDescr;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
       class="kw">delete descr;
       class="kw">return false;
     }
class=class="str">"cmt">//--- layer class="num">1
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = EmbeddingSize;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
       class="kw">delete descr;
       class="kw">return false;
     }
class=class="str">"cmt">//--- layer class="num">2-class="num">4
   for(class="type">int i = class="num">0; i < class="num">3; i++)
     {
       if(!(descr = new CLayerDescription()))
         class="kw">return false;
       descr.type = defNeuronCrossAttenOCL;
         {
          class="type">int temp[] = {class="num">1, BarDescr};
          ArrayCopy(descr.units, temp);
         }
         {
          class="type">int temp[] = {EmbeddingSize, NForecast};
          ArrayCopy(descr.windows, temp);
         }
       descr.window_out = class="num">16;
       descr.step = class="num">4;
       descr.activation = None;
       descr.optimization = ADAM;
       if(!actor.Add(descr))
         {
           class="kw">delete descr;
           class="kw">return false;
         }
     }
class=class="str">"cmt">//--- layer class="num">5
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
       class="kw">delete descr;

◍ 编码器与策略网络的后几层堆叠

在构建强化学习 actor 网络时,第 6 层与第 7 层负责把动作空间与隐变量映射到输出。第 6 层用 defNeuronBaseOCL 类型,节点数固定为 2 * NActions,关闭激活函数并走 ADAM 优化;第 7 层换成 defNeuronVAEOCL,节点数等于 NActions,同样挂 ADAM。两层若 Add 失败都会 delete 描述符并返回 false,避免悬空指针。 宏 LatentLayer 被定义为 3,对应编码器里的隐层位置。Train 函数里先用 GetProbTrajectories 以 0.9 的温度系数采样轨迹概率,再开迭代循环:每次随机抽一条轨迹 tr,并用 MathRand 的平方归一化做偏置抽样,起点 i 必须 >0 否则重抽。 前向把 bState 喂给 Encoder.feedForward,跳过训练模式;随后取 i+NForecast 处的状态作为重构目标,截到 BarDescr * NForecast 长度后跑 backProp。每 500 毫秒 tick 检查一次进度,percent = iter * 100.0 / Iterations 给出整数百分比,便于在 MT5 Experts 日志里看训练推进。外汇与贵金属行情下这类模型过拟合概率偏高,实盘前务必用历史数据交叉验证。

MQL5 / C++
   class="kw">return false;
    }
class=class="str">"cmt">//--- layer class="num">6
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronBaseOCL;
   descr.count = class="num">2 * NActions;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      class="kw">delete descr;
      class="kw">return false;
     }
class=class="str">"cmt">//--- layer class="num">7
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      class="kw">delete descr;
      class="kw">return false;
     }
class="macro">#define                LatentLayer               class="num">3
class="type">void Train(class="type">void)
  {
class=class="str">"cmt">//---
   vector<class="type">float> probability = GetProbTrajectories(Buffer, class="num">0.9);
class=class="str">"cmt">//---
   vector<class="type">float> result, target;
   class="type">bool Stop = false;
class=class="str">"cmt">//---
   class="type">uint ticks = GetTickCount();
   for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped() && !Stop); iter ++)
     {
      class="type">int tr = SampleTrajectory(probability);
      class="type">int i = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) * (Buffer[tr].Total - class="num">2 - NForecast));
      if(i <= class="num">0)
        {
         iter--;
         class="kw">continue;
        }
      bState.AssignArray(Buffer[tr].States[i].state);
      class=class="str">"cmt">//--- State Encoder
      if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), class="num">1, false, (CBufferFloat*)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }
      class=class="str">"cmt">//--- Collect target data
      if(!bState.AssignArray(Buffer[tr].States[i + NForecast].state))
         class="kw">continue;
      if(!bState.Resize(BarDescr * NForecast))
         class="kw">continue;
      if(!Encoder.backProp(GetPointer(bState), (CBufferFloat*)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }
      if(GetTickCount() - ticks > class="num">500)
        {
         class="type">class="kw">double percent = class="type">class="kw">double(iter) * class="num">100.0 / (Iterations);

训练循环里编码器与评论家的衔接细节

上面这段把强化学习训练主循环收了尾:每轮先取 0.9 置信度下的轨迹概率分布,再逐状态喂给 Encoder 做前向传播,失败就打印函数名与行号并置 Stop 跳出。Encoder 的 getRecentAverageError() 会在图表上以 %-14s %6.2f%% -> Error %15.8f 格式输出,误差精度到小数点后 8 位,肉眼能直接比对不同训练轮次的收敛斜率。 Critic 紧接着用 Encoder 的隐层输出作输入,结合动作缓冲做前向;回传时以 result = result - target * DiscFactor 计算 TD 残差,DiscFactor 就是折扣因子,调大它会让远期奖励权重上升、短期波动被平滑。若 backProp 任一环节返回 false,同样走 Stop 分支,避免脏梯度写进网络。 账户特征构造那段值得手动核:用 MathMax(i-1,0) 防越界,把余额变化率、权益占比、权益变化率及第 3 个账户字段塞进 bAccount。外汇与贵金属行情跳空频繁,这类相对变化率特征比绝对数值更抗量纲漂移,但实盘仍属高风险,回测吻合不等于 live 能复现。 把 Encoder 和 Critic 的指针传参方式(GetPointer + 类型强转)原样抄进你自己的 EA,编译后看 Experts 标签里的 %s -> %d -> Encoder %10.7f 打印,能确认网络对象是否真的被训练而非空跑。

MQL5 / C++
class="type">class="kw">string str = StringFormat("%-14s %class="num">6.2f%% -> Error %class="num">15.8f\n", "Encoder", percent,
Encoder.getRecentAverageError());
Comment(str);
ticks = GetTickCount();
}
 }
 Comment("");
class=class="str">"cmt">//---
 PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Encoder", Encoder.getRecentAverageError());
 ExpertRemove();
class=class="str">"cmt">//---
}
class="type">void Train(class="type">void)
 {
class=class="str">"cmt">//---
 vector<class="type">float> probability = GetProbTrajectories(Buffer, class="num">0.9);
class=class="str">"cmt">//---
 vector<class="type">float> result, target;
 class="type">bool Stop = false;
class=class="str">"cmt">//---
 class="type">uint ticks = GetTickCount();
 bState.AssignArray(Buffer[tr].States[i].state);
 class=class="str">"cmt">//--- State Encoder
 if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), class="num">1, false, (CBufferFloat*)NULL))
  {
  PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
  Stop = true;
  break;
  }
 class=class="str">"cmt">//--- Critic
 bActions.AssignArray(Buffer[tr].States[i].action);
 if(bActions.GetIndex() >= class="num">0)
  bActions.BufferWrite();
 if(!Critic.feedForward((CBufferFloat*)GetPointer(bActions), class="num">1, false, GetPointer(Encoder), LatentLayer))
  {
  PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
  Stop = true;
  break;
  }
 result.Assign(Buffer[tr].States[i + class="num">1].rewards);
 target.Assign(Buffer[tr].States[i + class="num">2].rewards);
 result = result - target * DiscFactor;
 Result.AssignArray(result);
 Critic.TrainMode(true);
 if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder), LatentLayer))
  {
  PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
  Stop = true;
  break;
  }
 class=class="str">"cmt">//--- Policy
 class="type">float PrevBalance = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">0];
 class="type">float PrevEquity = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">1];
 bAccount.Clear();
 bAccount.Add((Buffer[tr].States[i].account[class="num">0] - PrevBalance) / PrevBalance);
 bAccount.Add(Buffer[tr].States[i].account[class="num">1] / PrevBalance);
 bAccount.Add((Buffer[tr].States[i].account[class="num">1] - PrevEquity) / PrevEquity);
 bAccount.Add(Buffer[tr].States[i].account[class="num">2]);
让小布替你跑这套
小布盯盘已内置多变量关联诊断,打开对应品种页即可看到跨品种依赖热力,你只需判断哪些关系值得交易。

常见问题

实验显示这类模型对时间序列逐点变化不敏感,可能更依赖变量层面的统计结构而非具体时间步,预测结果对输入扰动鲁棒却也暗含黑箱风险。
线性部分擅长从序列中提取趋势分量,弥补纯变换器在趋势抽取上的薄弱,使模型兼顾非线性表达与趋势跟踪。
跨期依赖关注同一变量不同时间步的关联,变量间依赖关注不同变量在同一时刻或滞后时刻的协同形态,后者在多元金融序列中往往更显著。
目前小布提供的是跨品种依赖可视与异常过滤,模型训练需自行部署;其AIGC模块可辅助你快速生成特征工程脚本。
线性增强变换器倾向过滤非典型波动,可能平滑掉部分尖峰,实战中建议结合价格行为结构确认突破有效性,外汇贵金属杠杆高需严控风险。