神经网络变得简单(第 86 部分):U-形变换器(基础篇)
📘

神经网络变得简单(第 86 部分):U-形变换器(基础篇)

第 1/3 篇

「用 U 形变换器给行情做像素级重构」

把时间序列当成二维图像来处理的思路在 MT5 上已经能跑通。这一节里我们直接用 U-Net 结构的变换器(U-shaped Transformer)对价格序列做编码-解码,核心不是预测涨跌方向,而是重构输入窗口,用重构误差来标记异常波动区间。 在 MQL5 里,网络的前半段负责把固定长度的价格窗口压缩成隐状态,后半段再上采样还原。下面这段是编码器里一个下采样块的骨架,输入是归一化后的收盘价数组,输出是降维特征。 外汇与贵金属杠杆高、滑点随机,任何基于重构误差的预警都只是概率信号,实盘前请在策略测试器用至少 3 个月 tick 数据回测。打开 MT5 把下面代码贴进自定义指标,改一下窗口长度参数就能看到重构曲线和原曲线的偏离带。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| U-Transformer downsample block(simplified)                       |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void DownSample(const class="type">class="kw">double &class="kw">input[], class="type">int win, class="type">class="kw">double &output[])
  {
   class="type">int hid = win / class="num">2;                 class=class="str">"cmt">// 隐层长度折半
   ArrayResize(output, hid);
   for(class="type">int i=class="num">0; i<hid; i++)
     {
      output[i] = (class="kw">input[class="num">2*i] + class="kw">input[class="num">2*i+class="num">1]) * class="num">0.5; class=class="str">"cmt">// 相邻平均池化
     }
  }
class=class="str">"cmt">//+------------------------------------------------------------------+

◍ 为什么把变换器搬进行情序列

预测长期时间序列对交易决策很关键。2017 年提出的变换器架构靠自关注机制,能在较长间隔里抓依赖、从上下文抽关键信息,之后衍生出大量时间序列算法。 但近期研究指出,在多个时间序列数据集上,简单多层感知器(MLP)的准确率反而能超过变换器类模型。不过变换器已在不少领域落地,代表能力不算弱,值得在 MT5 里试它的机制。 改进原版的一个方向是论文《U-形变换器:在时间序列分析中保留高频上下文》里的 U-形变换器算法,它针对高频上下文保留做了调整。

两阶段训练化解时序数据稀缺

U-形变换器作者把训练拆成两段:先用较大的多源、多周期数据集做随机掩码恢复,让模型自己学时间序列的结构、依赖与上下文,同时顺带滤噪。论文里补充的训练数据不仅时间间隔不同,来源也不同,目的就是让同一个预训练权重能泛化到完全不一样的问题。 第二阶段冻结U-形变换器主体,只接一个决策“头”在小数据集上微调。相比从头训完整模型,微调更快、吃资源更少,这给用预训练模型解多个任务留了口子。外汇与贵金属行情序列结构差异极大,直接套NLP预训练思路行不通,这种两阶段法是绕开样本少的可验证路径。 修补操作用窗口和步幅都为2的卷积做池化,通道数翻倍且上一修补不被切碎;解码端用转置卷积做分离。嵌入则点卷积升维,再叠可训练相对位置编码。为稳训练,每个小批量归一化到标准正态分布;为抗数据不平衡,用加权随机采样平衡各集样本数。 在MT5接小布盯盘跑这套,可先只做第一阶段掩码恢复,看噪声过滤后重构误差是否明显下降,再决定冻权重接头。贵金属杠杆高、滑点突变多,任何重构稳健性都只是概率改善,不是免死金牌。

「用 MQL5 把可训练位置编码写进 U-形变换器」

把时间序列的位置信息喂给自注意力层,经典做法是叠正弦波,但周期固定、容易和行情自身的多频节律打架。这里换思路:让位置编码系数随输入数据动态生成,用一个简单的全连接层在训练里学出来,类 CNeuronLearnabledPE 从 CNeuronBaseOCL 继承,内部挂一个静态 cPositionEncoder 对象存可训练参数。 初始化时给嵌套对象设 tanh 激活,值域 -1 到 1,正好对齐正弦位置编码的区间;外层类本身不加激活。前馈里先生成位置编码张量,再直接加到输入张量上,全程靠方法返回值控错。 反向传播有个坑:位置编码模块不该把误差梯度传回输入或其嵌入,所以 calcInputGradients 里只对后续层梯度做激活校正、并重复一次前层激活校正,但绝不经由内部对象向后传播,updateInputWeights 只调嵌套对象同名方法更新自身权重。 U-形变换器主体 CNeuronUShapeAttention 用递归建块:Init 里若 inside_bloks > 0,就嵌套创建子模块,序列元素数翻 2 倍、嵌套数减 1,指针存进动态 cNeck。前馈按编码器→拆补丁→嵌套模块→解码器→合补丁走,最后和输入相加做跳接保高频。 编码器训练架构里 Dropout 掩码概率设 0.4,即随机遮盖 40% 原始输入而非其嵌入;用可学习位置编码接入 U-形编码器,后端接 3 个全连接层,强迫编码器同时重建输入并吐出预测值。训练 EA StudyEncoder.mq5 每轮从经验回放抽 1 个状态,前向算完再用未掩码真实值做反向调权,外汇/贵金属行情高波动,掩码恢复效果需用 MT5 跑附件代码自行验证。

◍ 训练收尾与可学习位置编码的实现细节

模型训练循环跑完所有迭代后,先清掉图表上的注释字段,再把训练结果写进 MT5 日志,随后直接终止 EA。这一套收尾动作在编码器训练 EA 里是固定流程,你打开附件里的 Study.mq5 就能对照看。 扮演者和评论者策略的 EA(路径 Experts\RevIN\Study.mq5)基本沿用前一篇逻辑,环境交互 EA 同理,本文不再拆算法。但要注意:除编码器训练 EA 外,其余 EA 里的编码器模型必须关掉训练模式,否则原始输入数据的掩码不会被禁用,推理会带噪。 下面这段 CNeuronLearnabledPE 类是可学习位置编码的核心。它继承 CNeuronBaseOCL,内部挂一个 cPositionEncoder 子层,前向时把输入和位置编码求和归一,激活函数对编码用 TANH、主层用 None。 class CNeuronLearnabledPE : public CNeuronBaseOCL { protected: CNeuronBaseOCL cPositionEncoder; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL); public: CNeuronLearnabledPE(void) {}; ~CNeuronLearnabledPE(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); //--- virtual int Type(void) const { return defNeuronLearnabledPE; } virtual void SetOpenCL(COpenCLMy *obj); }; bool CNeuronLearnabledPE::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, numNeurons, optimization_type, batch)) return false; if(!cPositionEncoder.Init(0, 1, OpenCL, numNeurons, optimization, iBatch)) return false; cPositionEncoder.SetActivationFunction(TANH); SetActivationFunction(None); //--- return true; } bool CNeuronLearnabledPE::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!cPositionEncoder.FeedForward(NeuronOCL)) return false; if(!SumAndNormilize(NeuronOCL.getOutput(), cPositionEncoder.getOutput(), Output, 1, false, 0, 0, 0, 1)) return false; //--- return true; } bool CNeuronLearnabledPE::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false; if(!DeActivation(cPositionEncoder.getOutput(), cPositionEncoder.getGradient(), Gradient, cPositionEncoder.Activation())) return false; if(!DeActivation(NeuronOCL.getOutput(), NeuronOCL.getGradient(), Gradient, NeuronOCL.Activation())) return false; //--- return true; } bool CNeuronLearnabledPE::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { 逐行看:类声明里 cPositionEncoder 是保护成员,三个虚函数覆盖基类的向前、权重更新和梯度计算。Init 中先调基类 Init,再初始化位置编码器,维度参数传 0 输出、1 索引,激活设 TANH,自身设 None。feedForward 先跑位置编码前向,再用 SumAndNormilize 把两者输出按权重 1 求和归一。calcInputGradients 对编码和输入分别做反激活累梯度。 外汇与贵金属市场高杠杆、高波动,这类自定义神经元若用于实盘信号,回测与 OOS 验证不充分时可能放大过拟合风险,建议先在 MT5 策略测试器用历史数据跑通梯度链路再谈部署。

MQL5 / C++
class CNeuronLearnabledPE : class="kw">public CNeuronBaseOCL
  {
class="kw">protected:
   CNeuronBaseOCL      cPositionEncoder;
   class=class="str">"cmt">//---
   class="kw">virtual class="type">bool        feedForward(CNeuronBaseOCL *NeuronOCL);
   class="kw">virtual class="type">bool        updateInputWeights(CNeuronBaseOCL *NeuronOCL);
   class="kw">virtual class="type">bool        calcInputGradients(CNeuronBaseOCL *NeuronOCL);
class="kw">public:
                     CNeuronLearnabledPE(class="type">void) {};
                    ~CNeuronLearnabledPE(class="type">void) {};
   class=class="str">"cmt">//---
   class="kw">virtual class="type">bool        Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl,
                            class="type">uint numNeurons, ENUM_OPTIMIZATION optimization_type, class="type">uint batch);
   class=class="str">"cmt">//---
   class="kw">virtual class="type">bool        Save(class="type">int const file_handle);
   class="kw">virtual class="type">bool        Load(class="type">int const file_handle);
   class=class="str">"cmt">//---
   class="kw">virtual class="type">int         Type(class="type">void) const            { class="kw">return defNeuronLearnabledPE;    }
   class="kw">virtual class="type">void        SetOpenCL(COpenCLMy *obj);
   };
class="type">bool CNeuronLearnabledPE::Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl,
                               class="type">uint numNeurons, ENUM_OPTIMIZATION optimization_type, class="type">uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, numNeurons, optimization_type, batch))
      class="kw">return false;
   if(!cPositionEncoder.Init(class="num">0, class="num">1, OpenCL, numNeurons, optimization, iBatch))
      class="kw">return false;
   cPositionEncoder.SetActivationFunction(TANH);
   SetActivationFunction(None);
class=class="str">"cmt">//---
   class="kw">return true;
  }
class="type">bool CNeuronLearnabledPE::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cPositionEncoder.FeedForward(NeuronOCL))
      class="kw">return false;
   if(!SumAndNormilize(NeuronOCL.getOutput(), cPositionEncoder.getOutput(), Output, class="num">1, false, class="num">0, class="num">0, class="num">0, class="num">1))
      class="kw">return false;
class=class="str">"cmt">//---
   class="kw">return true;
  }
class="type">bool CNeuronLearnabledPE::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      class="kw">return false;
   if(!DeActivation(cPositionEncoder.getOutput(), cPositionEncoder.getGradient(), Gradient,
   cPositionEncoder.Activation()))
      class="kw">return false;
   if(!DeActivation(NeuronOCL.getOutput(), NeuronOCL.getGradient(), Gradient, NeuronOCL.Activation()))
      class="kw">return false;
class=class="str">"cmt">//---
   class="kw">return true;
  }
class="type">bool CNeuronLearnabledPE::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {

常见问题

变换器靠自注意力直接抓长程依赖,不用像递归模型一步步传状态;用在行情上能同时看整段序列的局部和全局结构,适合做像素级重构。
先在无标签历史行情上做自监督重构预热编码器,再用少量标注数据微调解码头,显著降低过拟合概率。
小布内置了AIGC分析工具,可直接调用预置的U形变换器模板对品种做重构诊断,打开对应品种页即可看结果,不用本地搭环境。
行情节奏非线性,可训练编码能随数据调整时间感知权重,避免固定周期假设带来的偏移,训练后期损失通常更低。
常见坑是编码维度跟序列长度没对齐导致广播报错,以及初始化方差过大让注意力过早饱和,建议用小的随机初始化并显式reshape。