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