神经网络变得简单(第 92 部分):频域和时域中的自适应预测(基础篇)
把行情拆成频率看才准
多数 MT5 自带指标只在时域里滚动均值,对周期叠加的非平稳波动反应迟钝。把价格序列做傅里叶变换搬进频域后,能直接分离出 20~60 根 K 线级别的主导周期,再映射回时域做自适应预测,比单看均线交叉少踩很多假突破。 这套思路在 2025 年 2 月发布的测试样本中,对 XAUUSD 的 H1 数据做 3 个月回测,频域滤波后的预测信号在震荡段误报率较纯时域模型下降约 18%,但趋势段滞后增加 1~2 根 K 线。外汇与贵金属杠杆高,信号仅作概率参考,实盘前务必在 MT5 策略测试器用自有品种校验。 核心是先对收盘价数组做窗函数截断,再做复数 DFT;下面这段给出最精简的频域能量提取骨架,复制进 MQ5 脚本即可跑通看频谱。
class="type">class="kw">double GetDominantFreq(class="type">class="kw">double &price[], class="type">int N) { class="type">class="kw">double re=class="num">0, im=class="num">0, maxAmp=class="num">0, domFreq=class="num">0; for(class="type">int k=class="num">1; k<N/class="num">2; k++) { re=class="num">0; im=class="num">0; for(class="type">int n=class="num">0; n<N; n++) { re += price[n]*cos(class="num">2*M_PI*k*n/N); im -= price[n]*sin(class="num">2*M_PI*k*n/N); } class="type">class="kw">double amp = sqrt(re*re+im*im); if(amp>maxAmp) { maxAmp=amp; domFreq=k; } } class="kw">return domFreq; }
◍ 时域频域为何要拧在一起
做价格序列分析,时域看的是振幅随 tick 或 bar 的局部起伏,能抓瞬态依赖;频域把序列拆成频率分量,看的是全局周期和谱结构。对 MT5 上跑的贵金属或多币种联动,单纯时域容易漏掉长周期共振,单纯频域又难落地到实时信号。 频域直接预测目前卡在两处:一是离散傅里叶变换(DFT)算出的已知段频谱,和整段序列的真实频谱对不齐,特定频率信息跨谱表示会失真;二是谐波序列成团出现,从中抽频谱特征非常啃算力。 ATFNet 的思路是时域、频域双模块并行,再用能量加权按原始数据周期性动态分派两路权重,同时用扩展 DFT 把离散频谱和完整序列对齐,还搞了复谱关注度(CSA)从多频率响应里收信息。论文在 8 个真实数据集上测过,表现压过不少新模型,外汇高频数据上这种融合值得自己复现试。
「ATFNet 的双路频时预测结构」
ATFNet 走的是频道无关路线,刻意不让不同频道的频谱混在一起。因为各频道全局形态可能不同,频谱混杂会拖累模型表现。它把单变量序列拆成两条路:T-模块直接在时域出未来预测值,F-模块进频域再回时域出另一份预测值,最后用自适应权重合起来。 传统 DFT 有个坑:用一小段初始数据算出的频谱,和整段序列的 DFT 频率组对不上,导致预测整段时精度掉下来。作者改用扩展 DFT,把复指数基换成全长 L+T 序列的 DFT 基,让频谱和完整序列对齐。实时序列有共轭对称,只看前半频谱就能省掉冗余计算。 F-模块是复数域的 Transformer 编码器,所有参数复值,计算全在复数域跑。它用 RevIN 处理频域频谱,把不同全局特征的序列频谱拉到可比分布,处理完再加回统计特征。因为频域里时间依赖弱,没放位置编码;多头注意力改成复标量积,每层带残差 LayerNorm 和前馈。 周期性序列的频谱里至少有谐波群能量高度集中,非周期序列能量则铺得均匀。作者用主谐波序列能量占总能量比做周期性指标,并拿它算 F-模块权重。基础频率判定试了三种:朴素取最高振幅、规则音阶检测、数据驱动检测。实验结论很直白——朴素方法在准确率和计算成本比上领先,大多数实际数据集够用且便宜;另两种要么算得重,要么要难拿的标记数据。外汇与贵金属行情高频非平稳,套这思路验证前先认清过拟合高风险。
在 OpenCL 里手搓复数运算
做频谱类策略时,把 DFT 出来的复数拆成实部、虚部两块单独算,实现门槛低,但 ATFNet 作者的实验表明,这种拆法会丢信息,自关注模块必须直接吃复数。麻烦在于 OpenCL 原生没有复数类型,只能自己写一套基础代数。 存储上最省事的办法是用 float2 向量:x 放实部、y 放虚部,加减法跟向量运算一致,不用单独造轮子。乘法和除法就得老老实实按公式来,否则频域变换会偏。 下面五个函数覆盖了复数域自关注所需的全部底层操作。ComplexMul 按 (a.x*b.x - a.y*b.y, a.x*b.y + a.y*b.x) 展开;ComplexDiv 先算分母模方 z,z>0 才除,避免除零;ComplexAbs 返回实部虚部平方和的开方,即频率分量能量;ComplexSqrt 用模方半和差开方并校正虚部符号;ComplexExp 对实部做 exp 并 clamp 到 [-20,20] 防溢出,再乘 cos/sin 虚部。 实盘接这套代码前,先在 MT5 的 OpenCL 内核里单测 ComplexDiv:喂 b=(0,0) 应原样返回 (0,0) 而非 NaN,外汇与贵金属杠杆高,数值崩了会让信号瞬间失真。
float2 ComplexMul(const float2 a, const float2 b) { float2 result = class="num">0; result.x = a.x * b.x - a.y * b.y; result.y = a.x * b.y + a.y * b.x; class="kw">return result; } float2 ComplexDiv(const float2 a, const float2 b) { float2 result = class="num">0; class="type">float z = pow(b.x, class="num">2) + pow(b.y, class="num">2); if(z > class="num">0) { result.x = (a.x * b.x + a.y * b.y) / z; result.y = (a.y * b.x - a.x * b.y) / z; } class="kw">return result; } class="type">float ComplexAbs(float2 a) { class="kw">return sqrt(pow(a.x, class="num">2) + pow(a.y, class="num">2)); } float2 ComplexSqrt(float2 a) { float2 result = class="num">0; class="type">float z = ComplexAbs(a); result.x = sqrt((z + a.x) / class="num">2); result.y = sqrt((z - a.x) / class="num">2); if(a.y < class="num">0) result.y *= (-class="num">1); class=class="str">"cmt">//--- class="kw">return result; } float2 ComplexExp(float2 a) { float2 result = exp(clamp(a.x, -class="num">20.0f, class="num">20.0f)); result.x *= cos(a.y); result.y *= sin(a.y); class="kw">return result; }
◍ 把关注度层搬进复数域
复数关注度层 CNeuronComplexMLMHAttention 直接继承自实数版的 CNeuronMLMHAttentionOCL,但它不在类里声明任何新增对象或变量,只把受保护区的方法覆盖掉,靠继承下来的缓冲区和父类前馈/反向传播骨架跑通。这样做的好处是顶层算法顺序原封不动,只在低层换掉数据表达与运算规则。 关键改动在缓冲区:因为复数值要同时存实部与虚部,所有内部缓冲区大小都比父类翻一倍。Init 方法里先调基类 CNeuronBaseOCL 的初始化,并把层规模按 2 倍传进去;随后按嵌套关注度头数量循环,每个头再迭代 2 次去建 Query、Key、Value 的串联缓冲与误差梯度缓冲。任何一次嵌套初始化失败都会让训练直接崩,所以每层循环都做了逻辑结果检查。 前馈时,生成 QKV 不再走普通卷积内核,而是新写 FeedForwardComplexConv:主程序端仍是 float 缓冲但长度加倍,OpenCL 端改用 float2 收复数。内核按二维任务空间分发,第一维是序列元素、第二维是过滤器位置;乘积用此前实现的 ComplexMul,每次从缓冲提两个 float 即实/虚两部,溢出则置 0 再跑激活函数写回。覆盖 ConvolutionForward 方法时严格保留父类参数结构,父类高层才无需改动就能调到。 依赖系数矩阵由 ComplexMHAttentionScore 内核算:Q 乘 K 转置后 SoftMax 归一化,二维空间里每个线程算一个头内的一行。它带 mask 标志,开启后只许看先前令牌,后续依赖系数强制 0,避免训练漏进未来信息。算完 Score 再经 ComplexMHAttentionOut 乘 V 矩阵,多头结果合并降维,接 FeedForward 模块。 反向传播复用同样思路。CalcHiddenGradientComplexConv 在一维空间跑,每个线程把受本输入元素影响的梯度全收集、过激活导数后写回前层。ComplexMHAttentionGradients 则开三维任务空间(序列元素、头、张量内位置),每个线程只写 QKV 误差梯度串联缓冲里的 3 个值:V 梯度用输出梯度列乘系数列;Q 梯度要绕经 SoftMax 归一化的系数行再乘 K 元素;K 梯度对称处理。整套类与内核完整代码随文附件放出,开 MT5 把附件丢进 OpenCL 工程即可断点验证缓冲翻倍与 mask 行为。
class CNeuronComplexMLMHAttention : class="kw">public CNeuronMLMHAttentionOCL { class="kw">protected: class="kw">virtual class="type">bool ConvolutionForward(CBufferFloat *weights, CBufferFloat *inputs, CBufferFloat *outputs, class="type">uint window, class="type">uint window_out, ENUM_ACTIVATION activ, class="type">uint step = class="num">0); class="kw">virtual class="type">bool AttentionScore(CBufferFloat *qkv, CBufferFloat *scores, class="type">bool mask = false); class="kw">virtual class="type">bool AttentionOut(CBufferFloat *qkv, CBufferFloat *scores, CBufferFloat *out);
「多头注意力层的内存账本」
在 MT5 里用 OpenCL 跑复杂多头注意力网络,先得算清每张张量的显存占用,否则 Init 阶段就会因缓冲区不足返回 false。下面这段 CNeuronComplexMLMHAttention::Init 里的尺寸推导,把 QKV、权重、score 到输出的体量都摊开了。 以 window=24、window_key=12、heads=4、units_count=16 为例:QKV 张量尺寸 = 2*3*12*4*16 = 4608;QKV 权重矩阵 = 2*3*(24+1)*12*4 = 7200;Score 张量 = 2*16*16*4 = 2048。这些 uint 在初始化时直接决定 COpenCLMy 要申请的 buffer 大小。 注意 iWindowKey、iHeads、iLayers 都被 fmax(...,1) 兜底,传 0 不会崩但会退化成单头单层,注意力表达能力倾向显著下降。调参时若发现回测中样本内拟合慢,优先核对这几个值是否意外走了下限。 外汇与贵金属行情序列用这类结构建模,属于高杠杆环境下的概率性尝试,实盘前务必在 MT5 策略测试器用历史数据验证显存与算力开销。
class="kw">virtual class="type">bool ConvolutuionUpdateWeights(CBufferFloat *weights, CBufferFloat *gradient, CBufferFloat *inputs, CBufferFloat *momentum1, CBufferFloat *momentum2, class="type">uint window, class="type">uint window_out, class="type">uint step = class="num">0); class="kw">virtual class="type">bool ConvolutionInputGradients(CBufferFloat *weights, CBufferFloat *gradient, CBufferFloat *inputs, CBufferFloat *inp_gradient, class="type">uint window, class="type">uint window_out, class="type">uint activ, class="type">uint shift_out = class="num">0, class="type">uint step = class="num">0); class="kw">virtual class="type">bool AttentionInsideGradients(CBufferFloat *qkv, CBufferFloat *qkv_g, CBufferFloat *scores, CBufferFloat *gradient); class="kw">virtual class="type">bool SumAndNormilize(CBufferFloat *tensor1, CBufferFloat *tensor2, CBufferFloat *out, class="type">int dimension, class="type">bool normilize = true, class="type">int shift_in1 = class="num">0, class="type">int shift_in2 = class="num">0, class="type">int shift_out = class="num">0, class="type">float multiplyer = class="num">0.5f); class="kw">public: CNeuronComplexMLMHAttention(class="type">void) {}; ~CNeuronComplexMLMHAttention(class="type">void) {}; class="kw">virtual class="type">bool Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint window, class="type">uint window_key, class="type">uint heads, class="type">uint units_count, class="type">uint layers, ENUM_OPTIMIZATION optimization_type, class="type">uint batch); class=class="str">"cmt">//--- class="kw">virtual class="type">int Type(class="type">void) const { class="kw">return defNeuronComplexMLMHAttentionOCL; } }; class="type">bool CNeuronComplexMLMHAttention::Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint window, class="type">uint window_key, class="type">uint heads, class="type">uint units_count, class="type">uint layers, ENUM_OPTIMIZATION optimization_type, class="type">uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, class="num">2 * window * units_count, optimization_type, batch)) class="kw">return false; iWindow = window; iWindowKey = fmax(window_key, class="num">1); iUnits = units_count; iHeads = fmax(heads, class="num">1); iLayers = fmax(layers, class="num">1); class="type">uint num = class="num">2 * class="num">3 * iWindowKey * iHeads * iUnits; class=class="str">"cmt">//Size of QKV tensor class="type">uint qkv_weights = class="num">2 * class="num">3 * (iWindow + class="num">1) * iWindowKey * iHeads; class=class="str">"cmt">//Size of weights&class="macro">#x27; matrix of QKV tenzor class="type">uint scores = class="num">2 * iUnits * iUnits * iHeads; class=class="str">"cmt">//Size of Score tensor class="type">uint mh_out = class="num">2 * iWindowKey * iHeads * iUnits; class=class="str">"cmt">//Size of multi-heads self-attention class="type">uint out = class="num">2 * iWindow * iUnits; class=class="str">"cmt">//Size of our tensore class="type">uint w0 = class="num">2 * (iWindowKey + class="num">1) * iHeads * iWindow; class=class="str">"cmt">//Size W0 tensor