神经网络变得简单(第 89 部分):频率增强分解变换器(FEDformer)(基础篇)
◍ 用 FEDformer 把行情频率拆开看
频率增强分解变换器(FEDformer)的思路,是把价格序列先做季节-趋势分解,再在频域里做稀疏注意力,而不是像标准 Transformer 那样在时域全连接。对 MT5 上的外汇与贵金属来说,这种结构可能更扛得住非平稳行情里的突变噪声。 原作者在 2025 年 1 月 31 日发布的这一节,帖文浏览量为 1154,说明这类把深度学习结构塞进 MQL5 生态的尝试,在实战交易者里并非冷门。开 MT5 用自定义指标加载这类模型前,先确认你的历史数据长度够做多尺度分解,否则频域稀疏会退化成普通均线。 外汇与贵金属杠杆高、跳空频繁,任何基于频域分解的信号都只是概率倾向,实盘前务必在策略测试器里跑过至少一年 Tick 级回测。
「变换器为何在长序列预测上翻车」
长序列时间序列预测里,原生变换器最大的坑是高计算复杂度和内存占用,导致很难直接给长序列建模。更隐蔽的问题是:它在捕捉序列分布的共性特征时会失效。 论文《FEDformer:频率增强分解变压器进行长期序列预测》里放了实况与预测的对比截屏——预测序列的分布和真实数据差得很远。根因在逐点注意力:每个时间步的预测是独立且无依赖的,模型没法把整条序列当整体,全局统计属性就丢了。 作者给了两条路。一是用季节性趋势分解,把序列拆开再逼近真实分布;二是把傅里叶分析塞进变换器,用频率特征替代时间维度的直接建模,让全局属性更好捕获。两者合起来就是 FEDformer。 傅里叶分析里挑哪些频率分量是个关键。常规做法留低频、扔高频,但时间序列里某些突变恰恰绑着重要事件,全砍高频会丢信息。作者基于‘序列在傅里叶基下通常是未知稀疏表示’这个事实,理论上证明随机选子集(高低频都含)表现更好,实证也站得住。 顺带一个硬指标:变换器加频率分析后,计算成本从二次方降到线性复杂度。六个基线数据集上,多变量和单变量预测相对 SOTA 分别提了 14.8% 和 22.6%——外汇或贵金属用这类模型做长周期预判时,仍属高风险,线性加速不代表预测必然可靠。
FEDformer 的频率分解与混合专家架构
FEDformer 给出两个变体:傅里叶基版走纯频域路线,小波浪版把时域和频域揉在一起看。长期序列预测被当成序列到序列问题——输入长度 I、输出长度 O、状态向量维度 D,编码器吃下 I*D 的张量,解码器拿到 (I/2+O)*D 的矩阵,这是跑通模型前必须先对齐的维度约定。 原版 Transformer 被塞进了深度分解架构,核心三件套是频率响应分析单元(FEB)、频率增强关注度(FEA)和混合专家分解(MOEDecomp)。编码器多级堆叠,单级先经 MOEDecomp 抽季节性分量 S_en,再进 FEB;解码器更宽,吐出 S_de 和趋势 T_de,用 W_l 投影提趋势,FEA 替掉交叉关注度。 FEB-f 与 FEA-f 靠离散傅里叶变换(DFT)落地,工程上用快速傅里叶变换(FFT)提速。实现里先线性投影,时域转频域,随机采 M 个谐波,乘可训参数核,补零到全频响维度后做逆变换回时域。FEA-f 的 Query 来自解码器、Key/Value 来自编码器,但三者在频域里算关注度,结果补零逆变换——复杂度靠事先选定模式索引控住。 小波浪版(FEB-w / FEA-w)用 Legendre 小波浪基的固定矩阵做递归三分:高频、低频、残差各过一组 FEB-f,按 1/2 间隔自顶向下拆,重造阶段递归拼回输出张量。FEA-w 仅把分解阶段的 FEB-f 换成 FEA-f,多挂一个模块处理最琐碎残差。 MOEDecomp 专治复杂周期里趋势难剥的问题:一组不同窗口大小的均值滤波器抽多重趋势,再按数据相关权重合并。外汇与贵金属序列常带这种混周期,直接套固定窗口均值容易把真趋势滤掉,用混合专家权重更稳,但模型过拟合风险也更高,建议在 MT5 用真实 tick 数据先小样本验证分解层数。
◍ 在 MT5 里手搓 Legendre 小波分解层
原文作者没有严格照搬 FEDformer 论文,而是挑了 DWT(离散小波变换)路线。他对比论文里的 Exchange 列测试结果,发现 DWT 模型在多项数据上明显优于 DFT——推测原因是 DFT 丢掉了输入的时间分量,而 DWT 同时分析频率和时间两个维度,对没有明确周期的外汇、贵金属 tick 序列更友好。这类品种波动受新闻与流动性扰动,周期模糊,实盘里用 DWT 倾向更稳。 落地时他新建了 CNeuronLegendreWavelets 类,直接继承卷积层 CNeuronConvOCL。核心思路很暴力:小波基是固定矩阵,信号向量乘这个矩阵就完成分解。因为基固定,类里没有可训练参数,updateInputWeights 被重写成了空存根。下面这段就是类里唯一被覆盖的三个方法之一的骨架。 [CODE] class CNeuronLegendreWavelets : public CNeuronConvOCL { protected: virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) { return true; } 作者选了 9 个 Legendre 多项式当基,多项式定义域锁在 [0,1],所以初始化时把窗口长度归一为 1,再按序列元素个数切步长——这意味着不管你挂的是 M1 还是 H1,喂进去的只是窗口内的相对频率特征。基矩阵不在编译期写死,而是用宏替换公式在模型 Init 阶段按实际窗口大小现场填,方便换不同小波做对照实验。 对交易者来说,这套实现的副作用很实在:剔除了 DFT/FFT 后,训练与推理的算力开销大幅下降,但预测精度可能略恶化。外汇与贵金属属高风险品种,任何简化模型都要先在 MT5 策略测试器里跑回测再上实盘。
class CNeuronLegendreWavelets : class="kw">public CNeuronConvOCL { class="kw">protected: class="kw">virtual class="type">bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) { class="kw">return true; }
「勒让德小波神经元的权重初始化套路」
在 MT5 的 OpenCL 卷积神经元体系里,CNeuronLegendreWavelets 用勒让德多项式把卷积核预置成 wavelet 形态,而不是随机初始化。类里先留了空构造 / 析构,Type() 直接返回 defNeuronLegendreWavelets 常量,方便上层网络做类型分发。 宏里写死了三条多项式:Legendre4 最高次系数是 70,Legendre6 是 924,Legendre8 是 12870,对应四次、六次、八次展开。注意 Init 实际还调了 Legendre10,但宏定义被截断了,复制代码时要自己补完十次项,否则编译会报未定义符号。 Init 里先以窗口大小 window、固定卷积核宽度 9 调父类 CNeuronConvOCL::Init;随后把 WeightsConv 全填 0,再按 iWindow 循环:每个位置塞入 k=i/iWindow 下的 Legendre4、6、8、10 四个值,shift 每次跳 iWindow+1。这意味着一层卷积核实际由 4 组不同阶小波拼接,阶数越高对局部拐点的响应越尖。 外汇与贵金属行情高频噪声大,直接拿这套预置核跑 EURUSD 5 分钟可能过拟合到无意义波动;建议先在小样本回测里把 window 从 9 调到 20 观察置信度变化,再决定是否接实盘信号。
class="kw">public: CNeuronLegendreWavelets(class="type">void) {}; ~CNeuronLegendreWavelets(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 step, class="type">uint units_count, 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 defNeuronLegendreWavelets; } }; class="macro">#define Legendre4(x) (class="num">70*pow(x,class="num">4) - class="num">140*pow(x,class="num">3) + class="num">90*pow(x,class="num">2) - class="num">20*x + class="num">1) class="macro">#define Legendre6(x) (class="num">924*pow(x,class="num">6) - class="num">2772*pow(x,class="num">5) + class="num">3150*pow(x,class="num">4) - class="num">1680*pow(x,class="num">3) + \ class="num">420*pow(x,class="num">2) - class="num">42*x + class="num">1) class="macro">#define Legendre8(x) (class="num">12870*pow(x,class="num">8) - class="num">51480*pow(x,class="num">7) + class="num">84084*pow(x,class="num">6) - class="num">72072*pow(x,class="num">5) + \ class="num">34650*pow(x,class="num">4) - class="num">9240*pow(x,class="num">3) + class="num">1260*pow(x,class="num">2) - class="num">72*x + class="num">1) class="type">bool CNeuronLegendreWavelets::Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint window, class="type">uint step, class="type">uint units_count, ENUM_OPTIMIZATION optimization_type, class="type">uint batch) { if(!CNeuronConvOCL::Init(numOutputs, myIndex, open_cl, window, step, class="num">9, units_count, optimization_type, batch)) class="kw">return false; WeightsConv.BufferInit(WeightsConv.Total(), class="num">0); for(class="type">uint i = class="num">0; i < iWindow; i++) { class="type">uint shift = i; class="type">float k = class="type">float(i) / iWindow; if(!WeightsConv.Update(shift, Legendre4(k))) class="kw">return false; shift += iWindow + class="num">1; if(!WeightsConv.Update(shift, Legendre6(k))) class="kw">return false; shift += iWindow + class="num">1; if(!WeightsConv.Update(shift, Legendre8(k))) class="kw">return false; shift += iWindow + class="num">1; if(!WeightsConv.Update(shift, Legendre10(k))) class="kw">return false; shift += iWindow + class="num">1;