神经网络变得简单(第 85 部分):多变元时间序列预测·进阶篇
(2/3)· 当 80% 历史数据被掩码变换器仍不退化,我们该重新审视时间序列预测的建模范式
◍ 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 饱和区吞掉,外汇与贵金属样本上高波动时段尤甚,属高风险调参。
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 跑同一段,能直接观察显存占用与批次归一统计量的偏移,贵金属波动剧烈时这种偏移可能更明显,属高风险调参。
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 把这几个常量打印出来即可验证实际维度。
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 日志里看训练推进。外汇与贵金属行情下这类模型过拟合概率偏高,实盘前务必用历史数据交叉验证。
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 打印,能确认网络对象是否真的被训练而非空跑。
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]);