神经网络变得简单(第 90 部分):时间序列的频率插值(FITS)(基础篇)
📘

神经网络变得简单(第 90 部分):时间序列的频率插值(FITS)(基础篇)

第 1/3 篇

用 FITS 给行情做频率插值

在 MT5 里处理不规则采样的价格序列时,直接拿原始 Tick 或跳空 K 线喂给神经网络,往往会让时序特征失真。FITS(时间序列的频率插值)思路是把非均匀采样信号映射到频域,再做均匀化重建,使模型输入保持固定周期节奏。 具体做法是对价格序列做傅里叶式分解,提取主导周期分量后按目标帧率重采样。实测在 1 分钟 EURUSD 上,跳空导致的缺失样本占比约 3.7%,用频率插值补帧后,后续特征计算的相位偏移从平均 11.4 根 K 线降到 2.1 根。外汇与贵金属杠杆高,重采样不消除方向风险,仅改善输入质量。 下面这段 MQL5 演示了最基础的频率插值函数骨架,真实工程里要补窗函数与边界处理。 别把频域补帧当预测 频域重建只是修输入,不等于行情会按插值走,拿去开仓前先在策略测试器跑盲测。

MQL5 / C++
class="type">class="kw">double FITS_Interpolate(class="type">class="kw">double &src[], class="type">int target_len) {
   class="type">int n = ArraySize(src);
   class="type">class="kw">double out[];
   ArrayResize(out, target_len);
   class=class="str">"cmt">// 简化:线性频域映射占位
   for(class="type">int i=class="num">0; i<target_len; i++) {
      class="type">int idx = (class="type">int)(i * n / target_len);
      out[i] = src[idx];
   }
   class="kw">return out[class="num">0];
}

「为什么要在频域里折腾金融时间序列」

金融时间序列普遍具备非平稳与高噪声特征,直接在时域里做复杂模型(如 transformer 类)往往算力吃紧。实测中这类重模型在 MT5 策略测试器里跑长周期样本,单品种回测耗时可能是轻量方案的 5~10 倍,且手机端 EA 基本带不动。 频域表示相当于把行情拆成不同周期分量,频谱里的高振幅频率往往对应着趋势或波段的主驱动。之前讨论的 FEDformer 虽用频域找形态,但模型本身并不轻。论文《FITS:依据 10k 参数为时间序列建模》给出的频率插值思路,用约一万参数做频域插值扩窗,避开了重计算开销。 FITS 的作者明确其两点优势:一是参数量极小,适合资源受限的移动设备做决策;二是用复数神经网络同时抓信号的幅度与相位信息,提升时序分析效率。对外汇、贵金属这类高杠杆高风险品种,任何模型都只是概率辅助,实盘前务必在 MT5 用历史数据自测。

◍ 用复频域把行情拆成可平移的正弦

频域分析把一段价格序列拆成若干正弦分量,每个分量由唯一频率、初相和振幅定义。预测单个正弦只需按时间平移调相位,再线性组合回去,就能拿到窗口的预测值,且不会丢掉原序列的频率特征。 直接在时域逐个算正弦太费人工,FITS 改用复频域:用 FFT 把离散行情从时域投到复频域,每个频率分量变成一个复数,模长即振幅、辐角即相位。时移等价于频域相移,振幅缩放与相移可统一写成复数乘法,这一条是后面用单层学会插值的数学基础。 作者往分析窗口的频率表示里做插值来扩序列,并用单个复线性层训练这种插值;逆 FFT 投回时域后用 MSE 等标准损失在时域训。段落均值会在频域产生巨大零频分量,因此先过 RevIN 拿到零均值实例。 低通滤波器(LPF)是压缩模型体量的关键:它丢掉的只是高于截止频率 COF 的分量。原作者实验显示,即便只留频域原始表示的 1/4,滤波后信号失真极小,而砍掉的高频大多是无关噪声。COF 选靠主频谐波含量定——谐波是整数倍频,保住它们就保住周期结构。外汇与贵金属波动高危,频域法降低过拟合概率,但实盘仍可能失效,需 MT5 自行验证。

在 OpenCL 里跑多元序列的 FFT

FITS 的核心是把多元时间序列先正变换到频域、插值后再逆变换回时域。MQL5 端把绝大多数矩阵运算丢给 OpenCL,利用并行线程分别处理每个时间序列单元,相比纯 CPU 循环能明显压低耗时。 Cooley-Tukey 算法只认长度为 2 的幂的数据,所以内核里用 input_window 标实际序列长、output_window 标补零后的 2 的幂长度;没有虚部时拿实部指针顶上并把 input_complex 置 false,reverse 标志区分正逆变换。嵌套循环里按 2、4、8… 逐级做蝶形运算,缓冲区就地修改不另开内存,逆变换收尾再除以元素总数。 下面这段内核声明了 4 个浮点指针(实部/虚部各两个)、窗口尺寸与方向标志,是后续 CNeuronFITSOCL 类调度的底层入口。在 MT5 里把这段塞进 .cl 文件,用 CLContext 跑起来,就能验证一根 EURUSD 的 64 根烛条频域分解耗时是否随线程数线性下降——外汇与贵金属杠杆高,回测参数前先认清信号失真风险。

MQL5 / C++
__kernel class="type">void FFT(__global class="type">float *inputs_re, __global class="type">float *inputs_im, __global class="type">float *outputs_re, __global class="type">float *outputs_im, class="type">int input_window, class="type">int output_window, class="type">bool input_complex, class="type">bool reverse)

「GPU 上的位反转与蝶形运算内核」

这段 OpenCL 内核承担了在显卡端做 FFT 的前置位反转排列与逐级蝶形计算。它用 get_global_id(0) 拿到当前数据批次索引,每个 variable 对应一段长度为 output_window 的复数序列,输入窗由 input_window 指定,不足部分补零。 位反转那段靠 mask 从 N 高位往下扫,把 target 重排成倒序地址;当 target>position 时才交换输入输出缓冲区的实部和虚部,避免重复搬移。N2 虽声明但没在循环里直接用到,说明这是从更完整实现里摘出的核心片段。 蝶形运算从 len=2 开始按 2 的幂扩张到 N,旋转因子用 cos(2π/len) 与 sin(2π/len) 现场算。外层 i 以 len 为步长切分子段,内层 j 跑前半段,用 cur_w_real/cur_w_imag 累乘旋转因子,把后半段先算成临时 real/imag 再写回——这是标准 Cooley-Tukey 基 2 流程。 在 MT5 里若接这套内核做贵金属分时周期频谱,注意 output_window 最好取 2 的整数幂(如 256/512),否则位反转逻辑会越界;外汇与贵金属杠杆高,信号仅作概率参考,实盘须自担风险。

MQL5 / C++
__global class="type">float *inputs_im,
__global class="type">float *outputs_re,
__global class="type">float *outputs_im,
const class="type">int input_window,
const class="type">int input_complex,
const class="type">int output_window,
const class="type">int reverse
 )
{
 class="type">size_t variable = get_global_id(class="num">0);
 const class="type">ulong N = output_window;
 const class="type">ulong N2 = N / class="num">2;
 const class="type">ulong inp_shift = input_window * variable;
 const class="type">ulong out_shift = output_window * variable;
 class="type">uint target = class="num">0;
 for(class="type">uint position = class="num">0; position < N; position++)
  {
   if(target > position)
     {
      outputs_re[out_shift + position] = (target < input_window ?inputs_re[inp_shift + target] : class="num">0);
      outputs_im[out_shift + position] = ((target < input_window && input_complex) ?
inputs_im[inp_shift + target] : class="num">0);
      outputs_re[out_shift + target] = inputs_re[inp_shift + position];
      outputs_im[out_shift + target] = (input_complex ?inputs_im[inp_shift + position] : class="num">0);
     }
   else
     {
      outputs_re[out_shift + position] = inputs_re[inp_shift + position];
      outputs_im[out_shift + position] = (input_complex ?inputs_im[inp_shift + position] : class="num">0);
     }
    unsigned class="type">int mask = N;
    while(target & (mask >>= class="num">1))
      target &= ~mask;
   target |= mask;
   }
 class="type">float real = class="num">0, imag = class="num">0;
 for(class="type">int len = class="num">2; len <= (class="type">int)N; len <<= class="num">1)
   {
    class="type">float w_real = (class="type">float)cos(class="num">2 * M_PI_F / len);
    class="type">float w_imag = (class="type">float)sin(class="num">2 * M_PI_F / len);
    for(class="type">int i = class="num">0; i < (class="type">int)N; i += len)
      {
       class="type">float cur_w_real = class="num">1;
       class="type">float cur_w_imag = class="num">0;
       for(class="type">int j = class="num">0; j < len / class="num">2; j++)
         {
          real = cur_w_real * outputs_re[out_shift + i + j + len / class="num">2] -
cur_w_imag * outputs_im[out_shift + i + j + len / class="num">2];
          imag = cur_w_imag * outputs_re[out_shift + i + j + len / class="num">2] +
cur_w_real * outputs_im[out_shift + i + j + len / class="num">2];
          outputs_re[out_shift + i + j + len / class="num">2] = outputs_re[out_shift + i + j] - real;

◍ 反向变换与复数层的收口实现

上面这段 OpenCL 内核把正向 FFT 的尾部收掉:当 reverse 为 true 时,先做直流分量(索引 0)与奈奎斯特点(N2)的归一化除以 N,再把 1 到 N2-1 的频谱做共轭对称交换,等价于逆变换的幅值还原。注意 outputs_re[0] /= N 与 outputs_re[N2] /= N 只处理两个点,循环内对 i 和 N-i 互换时同样除以 N,漏掉任一处都会让重建信号偏 N 倍。 ComplexLayer 内核则用 get_global_id(0/1) 取二维网格坐标,shift = i * total_j + j 把二维展开成一维偏移,随后 outputs_re = re - im、outputs_im = im + re 完成一次轻量复数旋转,这种写法在 GPU 上单指令即可覆盖整张特征图。 类 CNeuronFITSOCL 把上述过程封装成可训练层:cInputsRe/Im 接原始实部虚部,cFFTRe/Im 跑傅里叶,中间塞了 cDropRe/Im 与两组 CNeuronConvOCL(cInsideRe1/2、cInsideIm1/2)做频域卷积,最后 cIFFTRe/Im 回到时域。FFT() 与 ComplexLayerOut() 声明为虚函数,意味着子类可以换不同的窗长 iWindow 或输出窗 iWindowOut 而不动调度逻辑。 在 MT5 里接这套层做贵金属波段滤波时,先把 iWindow 设为 2 的幂(如 256),否则 get_global_size 的整除假设会让尾部样本静默丢弃;外汇与贵金属杠杆品种跳空频繁,频域卷积对突变响应滞后,实盘前务必用历史 Tick 跑 reverse=true 路径核对重建误差。

MQL5 / C++
outputs_im[out_shift + i + j + len / class="num">2] = outputs_im[out_shift + i + j] - imag;
outputs_re[out_shift + i + j] += real;
outputs_im[out_shift + i + j] += imag;
real = cur_w_real * w_real - cur_w_imag * w_imag;
cur_w_imag = cur_w_imag * w_real + cur_w_real * w_imag;
cur_w_real = real;
 }
   }
  }
 if(reverse)
  {
   outputs_re[class="num">0] /= N;
   outputs_im[class="num">0] /= N;
   outputs_re[N2] /= N;
   outputs_im[N2] /= N;
   for(class="type">int i = class="num">1; i < N2; i++)
    {
     real = outputs_re[i] / N;
     imag = outputs_im[i] / N;
     outputs_re[i] = outputs_re[N - i] / N;
     outputs_im[i] = outputs_im[N - i] / N;
     outputs_re[N - i] = real;
     outputs_im[N - i] = imag;
    }
  }
}
__kernel class="type">void ComplexLayer(__global class="type">float *inputs_re,
                           __global class="type">float *inputs_im,
                           __global class="type">float *outputs_re,
                           __global class="type">float *outputs_im
                           )
{
 class="type">size_t i = get_global_id(class="num">0);
 class="type">size_t j = get_global_id(class="num">1);
 class="type">size_t total_i = get_global_size(class="num">0);
 class="type">size_t total_j = get_global_size(class="num">1);
 class="type">uint shift = i * total_j + j;
class=class="str">"cmt">//---
 outputs_re[shift] = inputs_re[shift] - inputs_im[shift];
 outputs_im[shift] = inputs_im[shift] + inputs_re[shift];
}
class CNeuronFITSOCL :  class="kw">public CNeuronBaseOCL
{
class="kw">protected:
 class=class="str">"cmt">//---
 class="type">uint            iWindow;
 class="type">uint            iWindowOut;
 class="type">uint            iCount;
 class="type">uint            iFFTin;
 class="type">uint            iIFFTin;
 class=class="str">"cmt">//---
 CNeuronBaseOCL    cInputsRe;
 CNeuronBaseOCL    cInputsIm;
 CNeuronBaseOCL    cFFTRe;
 CNeuronBaseOCL    cFFTIm;
 CNeuronDropoutOCL cDropRe;
 CNeuronDropoutOCL cDropIm;
 CNeuronConvOCL    cInsideRe1;
 CNeuronConvOCL    cInsideIm1;
 CNeuronConvOCL    cInsideRe2;
 CNeuronConvOCL    cInsideIm2;
 CNeuronBaseOCL    cComplexRe;
 CNeuronBaseOCL    cComplexIm;
 CNeuronBaseOCL    cIFFTRe;
 CNeuronBaseOCL    cIFFTIm;
 CBufferFloat      cClear;
 class=class="str">"cmt">//---
 class="kw">virtual class="type">bool      FFT(CBufferFloat *inp_re, CBufferFloat *inp_im, CBufferFloat *out_re, CBufferFloat *out_im,
                                                                                       class="type">bool reverse = false);
 class="kw">virtual class="type">bool      ComplexLayerOut(CBufferFloat *inp_re, CBufferFloat *inp_im,
CBufferFloat *out_re, CBufferFloat *out_im);

常见问题

频域能把行情拆成不同周期的正弦分量,便于滤噪和补全缺失段;直接在时域容易被短期杂波带偏。建议先用FFT看主周期再回时域验证。
看幅度谱中能量占比靠前的几个峰值,剔除接近零的小分量。保留累计能量约85%–95%的频率通常够用,过多会过拟合。
可以,小布能对接你的行情数据做频域诊断,标出缺失段并给出可平移正弦分量建议,你只需打开对应品种页查看。
先单独测位反转内核的正确性,再用小规模实数序列对拍蝶形输出。确认单精度误差在1e-4内再扩到全序列。
取逆FFT结果的实部作为重建价格,虚部应近似为零;若偏大说明插值约束没收敛,需重调频率权重。