神经网络变得简单(第 88 部分):时间序列密集编码器(TiDE)(基础篇)
「用 TiDE 把行情序列压成稠密向量」
TiDE(Time-series Dense Encoder)是一种轻量时序编码结构,核心思路是把多变量历史窗口先映射为低维稠密向量,再解码回预测 horizon。相比 Transformer 类模型,它不依赖注意力矩阵,参数量通常能压到同精度 RNN 的 1/3 左右,在 MT5 这类终端上跑周级回测更不容易爆内存。 在 MQL5 里实现 TiDE 的编码段,可以用多层全连接把输入特征(如 close、rsi、vol)先降维。下面这段演示了编码层的前向逻辑,输入窗口长度 30、编码维度 16。 外汇与贵金属杠杆高、跳空频繁,用此类编码器做信号倾向时务必做样本外验证,实盘前先在策略测试器跑至少 3 个月 tick 数据。
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 和这类接好就能先跑通前馈。
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 时序网络在高波动段预测偏差可能放大,实盘前务必用历史数据回测验证。
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 的显存占用。
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; }