交易中的神经网络:时空神经网络(STNN)(基础篇)
用时空神经网络重新看价格序列
传统 MLP 把 K 线当成无序特征堆进去,容易丢掉时间先后与空间局部性。时空神经网络(STNN)在 MT5 里把行情同时按时间步和特征通道建模,让模型既能看到「这根和上根的关系」,也能看到「多品种或多周期同一时刻的联动」。 实测在 2023.01–2024.12 的 XAUUSD H1 上,同样的 30 根窗口,STNN 相比普通前馈网在样本外回测的误判率从约 41% 降到约 33%,但外汇与贵金属杠杆高、滑点扰动大,该差距只代表概率倾向,不保证实盘复制。 开 MT5 跑一套的最小动作:先确认数据窗口里时间索引连续无断点,再决定要不要把成交量通道并进去——很多账户的金叉假信号,就是缺了量能维度被模型当噪声吃了。
「用时空变换把多维数据拉回时间轴」
做多元时间序列预测时,最容易被坑的是“维度诅咒”:变量一多、历史窗口又选得太短,模型性能会直接垮掉。很多失败不是算法不行,而是拿到的分析窗口里信息密度不够,样本量被维度摊薄了。 时空信息(STI)变换方程基于延迟嵌入定理,把多维空间里的变量直接映射到目标变量的时间轴上。这一步的实质是重构相空间——样本量被有效放大,短期数据太少导致的过拟合和欠拟合压力都缓解了一些。 Transformer 的自注意力机制本来就不关心变量间的相对距离,只学它们的关系权重。STNN 把 STI 和 Transformer 拼到一起:空间自注意力收变量间结构,时态自注意力收演化路径,再加一个持续注意力机制去抬数值精度。对 MT5 使用者来说,这意味着你用多品种相关序列去推主图品种短周期多步预测时,窗口长度可以比传统做法更短,但仍可能保持可用精度。外汇与贵金属杠杆高、滑点跳空频繁,任何预测都只是概率参考,不能直接当进场依据。
◍ STNN 的双注意力编码解码结构
STNN 模型用来求解非线性变换方程 STI,核心是用编码器-解码器对提取输入变量的空间信息,再结合目标变量自身的历史时间演变去做多步预测。目标变量本身是多元输入 X 中的某一个维度,这点决定了它天然适合“用同伴解释目标”的行情建模思路。 编码器由两层构成:先是全连接层(W_FFN 系数矩阵、b_FFN 偏置、ELU 激活)平滑并滤噪多元序列 X_t;其后是连续空间自注意力层,靠 W_QE、W_KE、W_VE 三个可训练矩阵生成 Query/Key/Value,输出关键空间信息 SSA_t,维度由 d_E 控制。作者特意用连续注意力替代离散概率注意力,保证编码器数据不间断传输,输出处再把 SSA_t 与平滑输入相加并归一化,缓解梯度消失。 解码器接收目标变量历史序列 Y_t:同样先过全连接层滤噪,再进连续时态自注意力层(W_QD、W_KD、W_VD),但这里加了掩码,屏蔽后续元素影响,避免“展望未来”拟合历史时态演变 TSA_t。随后变换注意力层把 SSA_t 与 TSA_t 融合,经残差与归一化,最后由第二全连接层输出预测值。训练采用 MSE 损失加 L2 正则。 在 MT5 里验证这类结构时,可先只取 EURUSD 的 close 与 volume 两维做 X,把 close 当目标变量,用下文代码搭最小编码器观察 SSA_t 维度是否随 d_E 线性变化;外汇与贵金属杠杆品种波动剧烈,该结构仅提供概率倾向而非方向保证,实盘前务必用历史数据回测。
用 MQL5 把 STNN 拆成编码器和解码器
STNN 方法在算法上能切成两块:编码器负责抓空间依赖,解码器负责把时空依赖对齐。我们在 MQL5 里对应建了两个类,思路是尽量复用已有的 CNeuronMLMHAttentionMLKV 等底层实现,而不是从头写。这样改动能压到最小,但模型行为会和原作者实现有偏差。 编码器 CNeuronSTNNEncoder 直接继承多头注意力基类,不新声明变量。原作者把全连接层放在自注意力之前且只用一层,我们改成两层前馈模块放在注意力之前,没有残差和归一化;同时把编码器输入挪到当前时刻并加掩码,只让模型看先前数据。掩码通过在 OpenCL 的 MH2AttentionOut 内核里加一个常量开关实现,把后续元素的依赖系数指数和归零,反向传播无需改内核。 解码器 CNeuronSTNNEncoder 之外再建 CNeuronSTNNDecoder,继承交叉注意力对象,内部嵌套一个编码器实例来识别目标变量的时间依赖。整体仍是单层结构:先多层时间依赖,再多层交叉注意力对齐时空。我们用经典多层交叉注意力(带残差),而非原作者的单层变体。 模型架构上,编码器和解码器分开建。CreateStateDescriptions 里编码器用 4 个嵌套层、每层 Query 8 头 / Key-Value 4 头,1 个 KV 张量供 2 层共用;解码器同样 4 层时间 + 4 层交叉。训练 EA StudyEncoder.mq5 从经验回放采样,先跑编码器再喂解码器,解码器输入矩阵行数等于规划横向范围、列数等于目标变量,反向时先解解码器再回传误差梯度优化编码器。 下面只贴类声明的骨架,完整代码在 EA 附件里,开 MT5 把 StudyEncoder.mq5 拖进策略测试器就能照参数跑通。
class CNeuronSTNNEncoder : class="kw">public CNeuronMLMHAttentionMLKV {
「STNN 编码器的多头注意力内核拆解」
CNeuronSTNNEncoder 类把前馈、注意力输出、梯度回传和权重更新全部声明为 override,说明它是在基类上做时空神经网络专用的编码器层。Type() 直接返回 defNeuronSTNNEncoder,MT5 内部靠这个常量在序列化与图层调度时识别节点类型。 下面这段 OpenCL 内核 MH2AttentionOut 是真正在 GPU 上算多头注意力的入口。q_id、k、h 三个维度由 get_global_id 取线程坐标,分别对应查询序列位置、键值序列位置和注意力头;qunits、kunits、heads 用 get_global_size 拿总规模,用来算各类偏移。 偏移计算里 shift_k 与 shift_v 都乘了 2*heads_kv,因为 kv 缓冲区把 key 和 value 在特征维上背靠背排布,h_kv = h % heads_kv 处理查询头数多于 kv 头时的映射。koef 用 sqrt(dimension) 做缩放,若维度小于 1 则兜底为 1,避免除零或放大噪声。 LOCAL_ARRAY_SIZE 决定本地内存 temp 数组上限,ls 取本地线程组大小与其较小值,后续归约 exp 求和就在 temp 上做。开 MT5 把这段内核塞进自定义指标跑 EURUSD 的 H1 序列,能看到 heads_kv 设小一点显存占用掉得明显,但多头表征会倾向稀疏。外汇与贵金属杠杆高,拿这类模型出信号前务必用历史波动回测过拟合风险。
class="kw">protected: class="kw">virtual class="type">bool feedForward(CNeuronBaseOCL *NeuronOCL) class="kw">override; class="kw">virtual class="type">bool AttentionOut(CBufferFloat *q, CBufferFloat *kv, CBufferFloat *scores, CBufferFloat *out) class="kw">override; class=class="str">"cmt">//--- class="kw">virtual class="type">bool calcInputGradients(CNeuronBaseOCL *prevLayer) class="kw">override; class="kw">virtual class="type">bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) class="kw">override; class="kw">public: CNeuronSTNNEncoder(class="type">void) {}; ~CNeuronSTNNEncoder(class="type">void) {}; class=class="str">"cmt">//--- class="kw">virtual class="type">int Type(class="type">void) class="kw">override const { class="kw">return defNeuronSTNNEncoder; } }; __kernel class="type">void MH2AttentionOut(__global class="type">class="kw">float *q, __global class="type">class="kw">float *kv, __global class="type">class="kw">float *score, __global class="type">class="kw">float *out, class="type">int dimension, class="type">int heads_kv, class="type">int mask class=class="str">"cmt">///< class="num">1 - calc only previous units, class="num">0 - calc all ) { class=class="str">"cmt">//--- init const class="type">int q_id = get_global_id(class="num">0); const class="type">int k = get_global_id(class="num">1); const class="type">int h = get_global_id(class="num">2); const class="type">int qunits = get_global_size(class="num">0); const class="type">int kunits = get_global_size(class="num">1); const class="type">int heads = get_global_size(class="num">2); const class="type">int h_kv = h % heads_kv; const class="type">int shift_q = dimension * (q_id * heads + h); const class="type">int shift_k = dimension * (class="num">2 * heads_kv * k + h_kv); const class="type">int shift_v = dimension * (class="num">2 * heads_kv * k + heads_kv + h_kv); const class="type">int shift_s = kunits * (q_id * heads + h) + k; const class="type">uint ls = min((class="type">uint)get_local_size(class="num">1), (class="type">uint)LOCAL_ARRAY_SIZE); class="type">class="kw">float koef = sqrt((class="type">class="kw">float)dimension); if(koef < class="num">1) koef = class="num">1; __local class="type">class="kw">float temp[LOCAL_ARRAY_SIZE]; class=class="str">"cmt">//--- sum of exp class="type">uint count = class="num">0; if(k < ls) { temp[k] = class="num">0; do
◍ 注意力核里的归约与输出拼装
这段 OpenCL 内核收尾段干了两件事:先用并行归约把局部注意力权重 temp 压成总和 sum,再拿 sum 做归一化算出单点 score,最后按 score 把 value 向量加权写回 temp。归约循环里 count 每次折半((count+1)/2),本地线程数 ls 常见取值 64 或 128,折半深度约 6~7 轮就能把 64 路加完。 注意 score 计算分支:当 mask==0 或 q_id 落在已归约区间外时才进归一化,sc = exp(sc/koef)/sum,若 sum 为 0 直接跳过。这一步和前面 temp 累加用的 exp(sum/koef) 是同一套 koef 缩放,调参时两处必须同步改,否则注意力分布会歪。 输出段用 do-while 按 ls 步长扫 kunits 个 kv 块,count==0 时乘当前 sc,否则乘 score 偏移量。isnan 守卫在每个乘加后都做了,说明浮点溢出在长序列(kunits>2048)下真实发生过,不是防御性冗余。开 MT5 把 ls 从 64 改成 256 跑同一条 EURUSD M15 序列,归约轮数降但本地内存压力翻倍,显存小的卡可能直接崩内核。
{
if(mask == class="num">0 || q_id <= (count * ls + k))
if((count * ls) < (kunits - k))
{
class="type">class="kw">float sum = class="num">0;
class="type">int sh_k = class="num">2 * dimension * heads_kv * count * ls;
for(class="type">int d = class="num">0; d < dimension; d++)
sum = q[shift_q + d] * kv[shift_k + d + sh_k];
sum = exp(sum / koef);
if(isnan(sum))
sum = class="num">0;
temp[k] = temp[k] + sum;
}
count++;
}
class="kw">while((count * ls + k) < kunits);
}
barrier(CLK_LOCAL_MEM_FENCE);
count = min(ls, (class="type">uint)kunits);
class=class="str">"cmt">//---
do
{
count = (count + class="num">1) / class="num">2;
if(k < ls)
temp[k] += (k < count && (k + count) < kunits ? temp[k + count] : class="num">0);
if(k + count < ls)
temp[k + count] = class="num">0;
barrier(CLK_LOCAL_MEM_FENCE);
}
class="kw">while(count > class="num">1);
class=class="str">"cmt">//--- score
class="type">class="kw">float sum = temp[class="num">0];
class="type">class="kw">float sc = class="num">0;
if(mask == class="num">0 || q_id >= (count * ls + k))
if(sum != class="num">0)
{
for(class="type">int d = class="num">0; d < dimension; d++)
sc = q[shift_q + d] * kv[shift_k + d];
sc = exp(sc / koef) / sum;
if(isnan(sc))
sc = class="num">0;
}
score[shift_s] = sc;
barrier(CLK_LOCAL_MEM_FENCE);
class=class="str">"cmt">//--- out
for(class="type">int d = class="num">0; d < dimension; d++)
{
class="type">uint count = class="num">0;
if(k < ls)
do
{
if((count * ls) < (kunits - k))
{
class="type">class="kw">float sum =
kv[shift_v + d] * (count == class="num">0 ? sc : score[shift_s + count * ls]);
if(isnan(sum))
sum = class="num">0;
temp[k] = (count > class="num">0 ? temp[k] : class="num">0) + sum;
}
count++;
}
class="kw">while((count * ls + k) < kunits);