神经网络变得简单(第 88 部分):时间序列密集编码器(TiDE)·进阶篇
(2/3)·当线性模型暴露短板、变换器算力吃紧,TiDE如何用纯MLP实现5倍提速与更优预测
不少交易者把变换器当成时间序列预测的唯一答案,却忽略了它在长周期推演里算力陡增、收益却趋平的现实。另一些人迷信线性模型,直到协变量出现非线性关系时账户回测直接失真。这篇我们拆开TiDE,看它怎么用最朴素的MLP绕开这两类坑。
◍ TiDE 前向与前向梯度的 OpenCL 实现骨架
在 MT5 里用 OpenCL 跑 TiDE 类时序模型,前向传播的逻辑是先对齐辅助输入缓冲,再逐层 FeedForward。代码里先用 getOutputIndex 判断第二个输入是否落在预期索引,不一致就通过 BufferSet 重绑,否则直接返回 false 中断计算。 前向主链路把全局残差、两组特征投影、历史拼接缓冲依次喂入,再用 acEncoderDecoder 数组做循环前向:prev 指针从编码器输入起,每轮更新为当前层对象,total 即数组 Size(),任一层失败立即退出。最后用 Concat 把解码器输入拼好,经 TemporalDecoder 输出后与残差做 SumAndNormilize,Output 长度由 iForecast 控制。 反向的 calcInputGradients 则是对称拆解:先从 TemporalDecoder 反传梯度,DeConcat 把末层编码器-解码器梯度按 iForecast 与 iVariables 切回特征投影与时间解码输入。循环从 total-2 倒序到 0,逐层 calcHiddenGradients,再 DeConcat 回历史输入与编码器输入。 注意 cGlobalResidual 和 NeuronOCL 的激活态:若 Activation 不是 None,必须调 DeActivation 对输出与梯度做对应反激活。外汇与贵金属行情下用这类 GPU 前向网络预测,误差可能放大,实盘前务必在策略测试器用历史数据校验缓冲维度。
if(cFeatureInput.getOutputIndex() != SecondInput.GetIndex()) { CBufferFloat *temp = cFeatureInput.getOutput(); if(!temp.BufferSet(SecondInput.GetIndex())) class="kw">return false; } if(!cGlobalResidual.FeedForward(NeuronOCL)) class="kw">return false; if(!acFeatureProjection[class="num">0].FeedForward(NeuronOCL)) class="kw">return false; if(!acFeatureProjection[class="num">1].FeedForward(cFeatureInput.AsObject())) class="kw">return false; if(!Concat(NeuronOCL.getOutput(), acFeatureProjection[class="num">0].getOutput(), cEncoderInput.getOutput(), iHistory, iHistory, iVariables)) class="kw">return false; class="type">uint total = acEncoderDecoder.Size(); CNeuronBaseOCL *prev = cEncoderInput.AsObject(); for(class="type">uint i = class="num">0; i < total; i++) { if(!acEncoderDecoder[i].FeedForward(prev)) class="kw">return false; prev = acEncoderDecoder[i].AsObject(); } if(!Concat(prev.getOutput(), acFeatureProjection[class="num">1].getOutput(), cTemporalDecoderInput.getOutput(), iForecast, iForecast, iVariables)) class="kw">return false; if(!cTemporalDecoder.FeedForward(cTemporalDecoderInput.AsObject())) class="kw">return false; if(!SumAndNormilize(cGlobalResidual.getOutput(), cTemporalDecoder.getOutput(), Output, iForecast, true)) class="kw">return false; class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CNeuronTiDEOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = -class="num">1) { if(!cTemporalDecoderInput.calcHiddenGradients(cTemporalDecoder.AsObject())) class="kw">return false; class="type">int total = (class="type">int)acEncoderDecoder.Size(); if(!DeConcat(acEncoderDecoder[total - class="num">1].getGradient(), acFeatureProjection[class="num">1].getGradient(), cTemporalDecoderInput.getGradient(), iForecast, iForecast, iVariables)) class="kw">return false; for(class="type">int i = total - class="num">2; i >= class="num">0; i--) if(!acEncoderDecoder[i].calcHiddenGradients(acEncoderDecoder[i + class="num">1].AsObject())) class="kw">return false; if(!cEncoderInput.calcHiddenGradients(acEncoderDecoder[class="num">0].AsObject())) class="kw">return false; if(!DeConcat(cHistoryInput.getGradient(), acFeatureProjection[class="num">0].getGradient(), cEncoderInput.getGradient(), iHistory, iHistory, iVariables)) class="kw">return false; if(cGlobalResidual.Activation() != None) { if(!DeActivation(cGlobalResidual.getOutput(), cGlobalResidual.getGradient(), cGlobalResidual.getGradient(), cGlobalResidual.Activation())) class="kw">return false; } if(!NeuronOCL.calcHiddenGradients(cGlobalResidual.AsObject())) class="kw">return false; if(NeuronOCL.Activation()!=None) if(!DeActivation(cHistoryInput.getOutput(),cHistoryInput.getGradient(), cHistoryInput.getGradient(),SecondActivation)) class="kw">return false;
「TiDE 反向传播与权重落盘的实现细节」
这段 CNeuronTiDEOCL 的成员函数展示了 TiDE 模型在 OpenCL 后端做梯度回传与权重更新的关键链路。反向阶段先对历史输入分支和特征投影分支分别做 SumAndNormilize 与 calcHiddenGradients,其中第二次归一化使用的缩放系数是 0.5f,而历史梯度归一化系数为 1,这种不对称缩放会影响残差与特征通道的梯度幅值平衡。 更新权重时,代码遍历 acEncoderDecoder 数组,用 prev 指针串联各层 UpdateInputWeights,循环次数由 total = acEncoderDecoder.Size() 决定;若任何一层返回 false 则整个函数提前退出,说明单层更新失败会阻断后续时序解码器的权重同步。 Save 函数把 iHistory、iForecast、iVariables、iFeatures 四个 uint 维度以 INT_VALUE 长度写入文件句柄,任一 FileWriteInteger 返回值小于 INT_VALUE 即判失败。在 MT5 里跑这套网络时,可故意让某层 UpdateInputWeights 返回 false 观察训练中断点,验证梯度链路是否如预期般脆弱。
if(!SumAndNormilize(NeuronOCL.getGradient(), cHistoryInput.getGradient(), NeuronOCL.getGradient(), iHistory, false, class="num">0, class="num">0, class="num">0, class="num">1)) class="kw">return false; if(!cFeatureInput.calcHiddenGradients(acFeatureProjection[class="num">0].AsObject())) class="kw">return false; if(!SumAndNormilize(cFeatureInput.getGradient(), cFeatureInput.getGradient(), SecondGradient, iFeatures, false, class="num">0, class="num">0, class="num">0, class="num">0.5f)) class="kw">return false; if(!cFeatureInput.calcHiddenGradients(acFeatureProjection[class="num">1].AsObject())) class="kw">return false; if(!SumAndNormilize(SecondGradient, cFeatureInput.getGradient(), SecondGradient, iFeatures, false, class="num">0, class="num">0, class="num">0, class="num">1.0f)) class="kw">return false; if(SecondActivation!=None) if(!DeActivation(SecondInput,SecondGradient,SecondGradient,SecondActivation)) class="kw">return false; class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CNeuronTiDEOCL::updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) { class=class="str">"cmt">//--- if(!cGlobalResidual.UpdateInputWeights(cHistoryInput.AsObject())) class="kw">return false; if(!acFeatureProjection[class="num">0].UpdateInputWeights(cHistoryInput.AsObject())) class="kw">return false; if(!acFeatureProjection[class="num">1].UpdateInputWeights(cFeatureInput.AsObject())) class="kw">return false; class=class="str">"cmt">//--- class="type">uint total = acEncoderDecoder.Size(); CNeuronBaseOCL *prev = cEncoderInput.AsObject(); for(class="type">uint i = class="num">0; i < total; i++) { if(!acEncoderDecoder[i].UpdateInputWeights(prev)) class="kw">return false; prev = acEncoderDecoder[i].AsObject(); } class=class="str">"cmt">//--- if(!cTemporalDecoder.UpdateInputWeights(cTemporalDecoderInput.AsObject())) class="kw">return false; class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CNeuronTiDEOCL::Save(const class="type">int file_handle) { if(!CNeuronBaseOCL::Save(file_handle)) class="kw">return false; class=class="str">"cmt">//--- if(FileWriteInteger(file_handle, (class="type">int)iHistory, INT_VALUE) < INT_VALUE) class="kw">return false; if(FileWriteInteger(file_handle, (class="type">int)iForecast, INT_VALUE) < INT_VALUE) class="kw">return false; if(FileWriteInteger(file_handle, (class="type">int)iVariables, INT_VALUE) < INT_VALUE) class="kw">return false; if(FileWriteInteger(file_handle, (class="type">int)iFeatures, INT_VALUE) < INT_VALUE) class="kw">return false; class=class="str">"cmt">//---
TiDE 模型的存档与重载路径
把训练好的 TiDE 网络落盘,靠的是 Save 里先写编码器层数、再逐层序列化。代码先取 acEncoderDecoder.Size() 得到 total,用 FileWriteInteger 以 INT_VALUE 长度写入文件头,任何一次写不足 INT_VALUE 就直接 return false,保证文件结构不被截断。 随后对 acEncoderDecoder 每个元素调 Save,再依次保存 cGlobalResidual、两个 acFeatureProjection 与 cTemporalDecoder,循环里固定 i<2 说明特征投影层写死为两层,改结构得动这块硬编码。 Load 方向更苛刻:每读一个整型参数(iHistory、iForecast、iVariables、iFeatures、total)都用 FileIsEnding 判尾,读到文件尾立刻返回 false,避免旧模型文件缺字段导致数组越界。 若 prev_size 与读到的 total 不等,用 ArrayResize 扩容;新扩出的层走 Init(0, i+2, OpenCL, 1,1,1, ADAM, 1) 初始化,老层只调 LoadInsideLayer 灌权重。开 MT5 把这段接进自己的 CNeuronTiDEOCL,能直接验证模型热重载是否丢参。
class="type">uint total = acEncoderDecoder.Size(); if(FileWriteInteger(file_handle, (class="type">int)total, INT_VALUE) < INT_VALUE) class="kw">return false; for(class="type">uint i = class="num">0; i < total; i++) if(!acEncoderDecoder[i].Save(file_handle)) class="kw">return false; if(!cGlobalResidual.Save(file_handle)) class="kw">return false; for(class="type">int i = class="num">0; i < class="num">2; i++) if(!acFeatureProjection[i].Save(file_handle)) class="kw">return false; if(!cTemporalDecoder.Save(file_handle)) class="kw">return false; class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CNeuronTiDEOCL::Load(const class="type">int file_handle) { if(!CNeuronBaseOCL::Load(file_handle)) class="kw">return false; if(FileIsEnding(file_handle)) class="kw">return false; iHistory = (class="type">uint)FileReadInteger(file_handle); if(FileIsEnding(file_handle)) class="kw">return false; iForecast = (class="type">uint)FileReadInteger(file_handle); if(FileIsEnding(file_handle)) class="kw">return false; iVariables = (class="type">uint)FileReadInteger(file_handle); if(FileIsEnding(file_handle)) class="kw">return false; iFeatures = (class="type">uint)FileReadInteger(file_handle); if(FileIsEnding(file_handle)) class="kw">return false; class="type">int total = FileReadInteger(file_handle); class="type">int prev_size = (class="type">int)acEncoderDecoder.Size(); if(prev_size != total) if(ArrayResize(acEncoderDecoder, total) < total) class="kw">return false; for(class="type">int i = class="num">0; i < total; i++) { if(i >= prev_size) if(!acEncoderDecoder[i].Init(class="num">0, i + class="num">2, OpenCL, class="num">1, class="num">1, class="num">1, ADAM, class="num">1)) class="kw">return false; if(!LoadInsideLayer(file_handle, acEncoderDecoder[i].AsObject())) class="kw">return false; } if(!LoadInsideLayer(file_handle, cGlobalResidual.AsObject())) class="kw">return false; for(class="type">int i = class="num">0; i < class="num">2; i++) if(!LoadInsideLayer(file_handle, acFeatureProjection[i].AsObject())) class="kw">return false; if(!LoadInsideLayer(file_handle, cTemporalDecoder.AsObject())) class="kw">return false;
◍ 编码器各层的初始化与张量排布
在 MT5 里搭 TiDE 类时序模型时,编码器的描述对象必须先做空指针兜底:若传入的 CArrayObj *encoder 为空,就 new 一个再继续,否则后面 Add _layer 会直接崩在终端日志里。 输入层用 defNeuronBaseOCL,节点数等于 HistoryBars * BarDescr,激活函数设 None、优化器走 ADAM;prev_count 被存下来,供后续 BatchNorm 层复用同样宽度。 第一层接 defNeuronBatchNormOCL,batch 参数写死 1000,这意味着训练时按每批 1000 条样本做归一化——如果你的样本数远小于此,显存占用会虚高,可在回测前调小。 第二层 defNeuronTransposeOCL 把 (HistoryBars, BarDescr) 转置,count=HistoryBars、window=BarDescr;第三层 defNeuronTiDEOCL 才是核心,window_out=NForecast、step=4,windows 数组按 [HistoryBars, 2*EmbeddingSize, EmbeddingSize, 2*EmbeddingSize, NForecast] 排好,任何一层 Add 失败都 delete descr 并返 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 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=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; descr.count = HistoryBars; 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 = defNeuronTiDEOCL; descr.count = BarDescr; descr.window = HistoryBars; descr.window_out = NForecast; descr.step = class="num">4; { class="type">int windows[]={HistoryBars,class="num">2*EmbeddingSize,EmbeddingSize,class="num">2*EmbeddingSize,NForecast}; if(ArrayCopy(descr.windows,windows)<=class="num">0) class="kw">return false; } descr.activation = None; if(!encoder.Add(descr))
「编码器尾层与 Actor/Critic 交叉注意力堆叠」
编码器后半段从第四层开始收口:第四层用 defNeuronConvOCL,窗口与步长都设为 NForecast,输出维度与 BarDescr 对齐;第五层做转置卷积 defNeuronTransposeOCL 把时序特征还原;第六层接 defNeuronRevInDenormOCL 做反归一化,优化器指定 ADAM,layers=1。任何一层 Add 失败都 delete descr 并返回 false,保证对象不泄漏。 LatentLayer 宏写死为 4,意味着潜变量取编码器第四层输出,后续 Actor/Critic 都以此为条件输入。 Actor 与 Critic 各自用 for 循环连续压 10 层 defNeuronCrossAttenOCL(layer 2-12)。每层 units 拷 {1, BarDescr}、windows 拷 {EmbeddingSize, NForecast},window_out=32、step=4、激活 None、优化 ADAM。10 层堆叠后模型可能对长跨度的外汇/贵金属 tick 序列学到交叉依赖,但过拟合概率随层数上升,MT5 上跑前先把 NForecast 调小做 sanity check 更稳。 外汇与贵金属杠杆高、滑点跳空频繁,这类深度结构仅作概率性辅助,实盘前务必用历史数据回测验证。
{
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 = defNeuronConvOCL;
descr.count = BarDescr;
descr.window = NForecast;
descr.step = NForecast;
descr.window_out = NForecast;
descr.activation=None;
if(!encoder.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 = defNeuronTransposeOCL;
descr.count = BarDescr;
descr.window = NForecast;
if(!encoder.Add(descr))
{
class="kw">delete descr;
class="kw">return false;
}
class=class="str">"cmt">//--- layer class="num">6
if(!(descr = new CLayerDescription()))
class="kw">return false;
descr.type = defNeuronRevInDenormOCL;
descr.count = BarDescr*NForecast;
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="macro">#define LatentLayer class="num">4
class="type">bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic)
{
class=class="str">"cmt">//---
........
........
class=class="str">"cmt">//--- Actor
........
........
class=class="str">"cmt">//--- layer class="num">2-class="num">12
for(class="type">int i = class="num">0; i < class="num">10; 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">32;
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">//--- Critic
........
........
class=class="str">"cmt">//--- layer class="num">2-class="num">12
for(class="type">int i = class="num">0; i < class="num">10; i++)
{
if(!(descr = new CLayerDescription()))
class="kw">return false;
descr.type = defNeuronCrossAttenOCL;
{
class="type">int temp[] = {class="num">1, BarDescr};
ArrayCopy(descr.units, temp);
}
{训练循环里的时间特征注入
Critic 网络描述符在 Add 之前,先把窗口尺寸写进 windows 数组:EmbeddingSize 与 NForecast 两个值,随后 window_out 固定为 32、step 为 4,激活函数留 None,优化器走 ADAM。若 Add 返回失败,直接 delete 描述符并 return false,避免悬空对象。 Train 函数从 GetProbTrajectories(Buffer, 0.9) 拿到概率向量后进入迭代。每次先用 SampleTrajectory 抽一条轨迹,再用 MathRand 平方分布把起点 i 压在 Buffer[tr].Total - 2 - NForecast 范围内;i<=0 时 iter 自减并 continue,防止越界。 时间特征用四个周期分量拼出来:年周期用 2024.01.01 减 2023.01.01 作分母取 sin,月线 PERIOD_MN1 取 cos,周线 PERIOD_W1 与日线 PERIOD_D1 取 sin,全部以 2πx 为相位。bTime 写满后 BufferWrite,再喂给 Encoder.feedForward 做状态编码。外汇与贵金属行情受周期扰动明显,这类特征可能提升模型对季节波动的捕捉概率,但回测不代表实盘,杠杆品种高风险。
class="type">int temp[] = {EmbeddingSize, NForecast}; ArrayCopy(descr.windows, temp); } descr.window_out = class="num">32; descr.step = class="num">4; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { class="kw">delete descr; class="kw">return false; } } ........ ........ class=class="str">"cmt">//--- class="kw">return true; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Train function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void Train(class="type">void) { class=class="str">"cmt">//--- vector<class="type">class="kw">float> probability = GetProbTrajectories(Buffer, class="num">0.9); class=class="str">"cmt">//--- vector<class="type">class="kw">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); bTime.Clear(); class="type">class="kw">double time = (class="type">class="kw">double)Buffer[tr].States[i].account[class="num">7]; class="type">class="kw">double x = time / (class="type">class="kw">double)(D&class="macro">#x27;class="num">2024.01.class="num">01&class="macro">#x27; - D&class="macro">#x27;class="num">2023.01.class="num">01&class="macro">#x27;); bTime.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); x = time / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1); bTime.Add((class="type">class="kw">float)MathCos(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); x = time / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1); bTime.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); x = time / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1); bTime.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); if(bTime.GetIndex() >= class="num">0) bTime.BufferWrite(); class=class="str">"cmt">//--- State Encoder if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), class="num">1, false,