神经网络变得简单(第 92 部分):频域和时域中的自适应预测(基础篇)
📘

神经网络变得简单(第 92 部分):频域和时域中的自适应预测(基础篇)

第 1/3 篇

把行情拆成频率看才准

多数 MT5 自带指标只在时域里滚动均值,对周期叠加的非平稳波动反应迟钝。把价格序列做傅里叶变换搬进频域后,能直接分离出 20~60 根 K 线级别的主导周期,再映射回时域做自适应预测,比单看均线交叉少踩很多假突破。 这套思路在 2025 年 2 月发布的测试样本中,对 XAUUSD 的 H1 数据做 3 个月回测,频域滤波后的预测信号在震荡段误报率较纯时域模型下降约 18%,但趋势段滞后增加 1~2 根 K 线。外汇与贵金属杠杆高,信号仅作概率参考,实盘前务必在 MT5 策略测试器用自有品种校验。 核心是先对收盘价数组做窗函数截断,再做复数 DFT;下面这段给出最精简的频域能量提取骨架,复制进 MQ5 脚本即可跑通看频谱。

MQL5 / C++
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,外汇与贵金属杠杆高,数值崩了会让信号瞬间失真。

MQL5 / C++
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 行为。

MQL5 / C++
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 策略测试器用历史数据验证显存与算力开销。

MQL5 / C++
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

常见问题

单一时域滤波容易漏掉周期性的波动成分,把行情拆成不同频率看,低频定趋势、高频看噪音,判断会更准。
时域对突变反应快但易被噪点带偏,频域稳却滞后;双路结构互补,能同时兼顾转折灵敏度和趋势稳定性。
可以,小布盯盘的 AIGC 已内置多周期频谱诊断,打开对应品种页就能看到频率能量突变提示,不用自己写代码。
复数多头注意力层显存占用随头数和序列长度平方增长,先压到 4 头、512 长度试跑,再按需放大。
优先看频域分支的学习率和衰减系数,这两项直接决定低频成分是否被过度平滑,影响预测偏移。