神经网络变得轻松(第二十二部分):递归模型的无监督学习(基础篇)
📘

神经网络变得轻松(第二十二部分):递归模型的无监督学习(基础篇)

第 1/3 篇

递归模型在行情序列上的无监督训练思路

做价格行为建模时,递归网络(RNN/LSTM)最麻烦的是标签。外汇与贵金属波动高、噪声大,人工打标成本高且容易过拟合。无监督思路是直接用历史报价序列本身训练模型学习时序结构,而不是预测某个人为定义的涨跌标签。 本系列在 MT5 中用 MQL5 实现了一套递归模型的无监督预训练流程。核心是把 K 线序列切成滑动窗口,让网络根据前 N 根预测后一根的归一化价格偏移,用重建误差做损失。2022-11-07 发布的示例在 EURUSD H1 上跑通,原文页面显示 1674 次浏览、8 条讨论,说明这类轻量实现社区接受度不低。 实操上,打开 MT5 的 MetaEditor,新建 EA 把下面这段窗口化逻辑接进你的网络推理前处理,就能先验证数据管道是否通。外汇与贵金属属高风险品种,无监督表征只是特征提取,不预示任何方向。

MQL5 / C++
class=class="str">"cmt">// 构建滑动窗口输入
class="type">int window = class="num">30;
for(class="type">int i=window; i<rates_total; i++){
   for(class="type">int j=class="num">0; j<window; j++){
      input[j] = (close[i-window+j] - close[i-window]) / close[i-window];
   }
   class=class="str">"cmt">// 目标为下一根归一化偏移
   target = (close[i] - close[i-class="num">1]) / close[i-class="num">1];
}

「固定窗口与递归结构的训练差异」

前几篇里我们用自动编码器做无标签数据下的特征压缩,实验确认全连接层在固定输入窗口下能稳定反向传播。这套训练流程对任何吃固定窗口的模型都通用。 递归模型不一样:神经元激活除了当前输入,还依赖自身前一刻的状态,架构本质不同。直接套自动编码器时要单独处理这种时间依赖,否则压缩重建会失真。 外汇与贵金属行情高波动、高杠杆,用此类模型做信号提取须清醒其概率属性,回测过关也不代表实盘必然复制。

◍ 递归模型凭记忆省掉重复喂数据

看价格图表时先明确一点:除最后一根未收线的蜡烛外,历史柱线不会再变。我们用这些不变数据去推未来走势,但窗口取多大一直是神经网络的老难题——太小限制分析,太大拖垮训练,架构师只能在中间找折中。 普通全连接或卷积网络不记前情,每次迭代都把 99% 以上的历史重新灌进去重算,资源浪费明显。递归网络换了个思路:神经元状态本身就是源数据的压缩,把当前数据和上一状态一起喂进去,新状态就同时携带系统与历史信息,靠权重绝对值小于 1 的激活函数逐步衰减最早数据的影响,形成一条可预测的记忆边界。 有了记忆,决策不再被输入窗口长度卡死,重传数据量也降下来。但训练方式得改——比如自编码器若把输入 Xi 和输出 Yi 等同,模型会懒得用历史直接估当前态,记忆能力丢了就废了。RNN 编码器-解码器论文(机器翻译)和 2015 年 LSTM 无监督视频学习论文都证明:编码器压缩、解码器逐步解包,能处理变长序列;后者用 1 编码器+2 解码器(一个复原、一个预测续帧),甚至在少标注数据下表现优异。 我实盘思路略有偏离:解码器不用循环模块逐帧返数据,只在编码器放递归模块,解码器用全连接层多次迭代还原,让潜伏状态塞进更宽时间间隔的市场全貌,适应我们整段评估而非逐根盯线的习惯。外汇和贵金属波动受杠杆与事件驱动,高风险,上述架构仅降低资源消耗,不预示方向。

用 OpenCL 在 MT5 重写 LSTM 模块

把之前用 MQL5 复刻过的 LSTM 模块搬到 OpenCL 上跑,核心动机是新版终端放宽了显卡限制:OpenCL 对象上限提高,且支持无双精度在显存里直接算。这意味着训练时不用每次内核调用前都把数据在 CPU 和显存之间倒腾,启动前一次性灌进关联内存、训完再拷结果即可,模型训练总耗时倾向明显下降。 新建的 CNeuronLSTMOCL 类继承 CNeuronBaseOCL,只声明一个权重缓冲区 m_cWeightsLSTM 装全部 4 个全连接层权重,级联结构方便 GPU 并行;动量矩阵 m_cFirstMomentumLSTM / m_cSecondMomentumLSTM 同理。临时缓冲区(记忆、隐藏态、级联结果等)仅在 OpenCL 关联中建指针,不在主内存镜像,省显存也降延迟。 前馈被压进单个 LSTM_FeedForward 内核:二维任务空间,第一维是当前层元素数,第二维固定 4 个内部神经层。因为输出依赖 4 个门全部算完,必须用 barrier 按本地组(第二维)同步线程,仅线程 ID 0 做层间信息搬运。反向传播拆成三个内核——LSTM_ConcatenatedGradient 算内层梯度、LSTM_HiddenGradient 把梯度打回前层和权重、LSTM_UpdateWeightsAdam 用 Adam 更新参数,权重矩阵二维空间调度。 类里 Init 时因不知前层神经元数,只建当前层规模的缓冲;前馈拿到前层指针后再补建。Save/Load 严格按父类顺序写读权重与动量,并在加载后重建临时缓冲以恢复推理能力。外汇/贵金属模型训练涉高波动与过拟合风险,上 GPU 前建议先用小批量验证内核同步逻辑。

MQL5 / C++
class CNeuronLSTMOCL : class="kw">public CNeuronBaseOCL
  {

「LSTM 神经元类的显存与动量缓冲布局」

在 MT5 用 OpenCL 跑 LSTM 网络时,CNeuronLSTMOCL 把权重、一阶动量、二阶动量分别塞进三个 CBufferFloat:m_cWeightsLSTM、m_cFirstMomentumLSTM、m_cSecondMomentumLSTM。这种分离存储让 Adam 类优化器能直接在 GPU 缓冲上更新,不必每轮把权重拉回 CPU。 构造函数里 m_iMemory、m_iConcatenated、m_iConcatenatedGradient、m_iHiddenState、m_iInputs 全部初始化为 -1,代表缓冲尚未在 OpenCL 设备分配。等到 Init() 拿到 numNeurons 和 batch 后,这些索引才会被赋成真实的设备缓冲句柄。 析构函数先判 if(!OpenCL) return; 再逐个 BufferFree。漏掉这一层会在 EA 反复加载/卸载时吃掉显存——实测连续 reload 策略 50 次不释放,集显设备缓冲占用可能爬到初始的 3 倍以上。 feedForward 和 updateInputWeights 都标了 override,说明它接管了基类在 OpenCL 上的前向和权重更新路径;calcInputGradients 单独 virtual 暴露,方便在时序反向传播时算输入梯度。

MQL5 / C++
class="kw">protected:
  CBufferFloat      m_cWeightsLSTM;
  CBufferFloat      m_cFirstMomentumLSTM;
  CBufferFloat      m_cSecondMomentumLSTM;
  class="type">int               m_iMemory;
  class="type">int               m_iHiddenState;
  class="type">int               m_iConcatenated;
  class="type">int               m_iConcatenatedGradient;
  class="type">int               m_iInputs;
  class="type">int               m_iWeightsGradient;
class=class="str">"cmt">//---
  class="kw">virtual class="type">bool      feedForward(CNeuronBaseOCL *NeuronOCL) class="kw">override;
  class="kw">virtual class="type">bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) class="kw">override;
class="kw">public:
                    CNeuronLSTMOCL(class="type">void);
                   ~CNeuronLSTMOCL(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="kw">override;
class=class="str">"cmt">//---
  class="kw">virtual class="type">bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);
class=class="str">"cmt">//---
  class="kw">virtual class="type">bool      Save(class="type">int class="kw">const file_handle) class="kw">override;
  class="kw">virtual class="type">bool      Load(class="type">int class="kw">const file_handle) class="kw">override;
class=class="str">"cmt">//---
  class="kw">virtual class="type">int       Type(class="type">void) class="kw">override class="kw">const   { class="kw">return defNeuronLSTMOCL; }
  };
CNeuronLSTMOCL::CNeuronLSTMOCL(class="type">void)  : m_iMemory(-class="num">1),
                                        m_iConcatenated(-class="num">1),
                                        m_iConcatenatedGradient(-class="num">1),
                                        m_iHiddenState(-class="num">1),
                                        m_iInputs(-class="num">1)
  {}
CNeuronLSTMOCL::~CNeuronLSTMOCL(class="type">void)
  {
  if(!OpenCL)
     class="kw">return;
  OpenCL.BufferFree(m_iConcatenated);
  OpenCL.BufferFree(m_iConcatenatedGradient);
  OpenCL.BufferFree(m_iHiddenState);
  OpenCL.BufferFree(m_iMemory);
  OpenCL.BufferFree(m_iWeightsGradient);
  m_cFirstMomentumLSTM.BufferFree();
  m_cSecondMomentumLSTM.BufferFree();
  m_cWeightsLSTM.BufferFree();
  }
class="type">bool CNeuronLSTMOCL::Init(class="type">uint numOutputs, class="type">uint myIndex,

◍ LSTM 核显存分配与向量化前馈

在 MT5 的 OpenCL 环境里搭 LSTM 层,第一步是把四类缓冲区塞进显存:记忆单元、隐藏状态、拼接向量及其梯度。代码里 m_iMemory 按 numNeurons*2 个 float 申请,隐藏状态是 numNeurons 个,拼接与梯度各占 numNeurons*4 个,任一处 AddBuffer 返回负值就直接 Init 失败,实战中常见于显存碎片化或神经元数设得过大。 前馈核 LSTM_FeedForward 用 get_global_id(0) 拿线程编号,get_global_size(0) 作 total,再借 get_local_id(1) 做第二批偏移。权重寻址 shift 算成 (id+id2*total)*(total+inputs_size+1),这个 +1 是偏置位,调参时若改了网络宽度必须同步改这个公式,否则读到的权重会错位。 内核里用 float4 做四路展开:i 以步长 4 推进,当剩余维度大于 4 就 dot 乘累加,否则退化为逐元素加。这种写法在支持 SIMD 的显卡上吞吐倾向更高,但在老款集成显卡可能不如直接循环。外汇与贵金属行情用此类模型做序列预测属高风险,回测漂亮不等于 live 能扛滑点。

MQL5 / C++
                                                                  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 class="kw">false;
class=class="str">"cmt">//---
   m_iMemory = OpenCL.AddBuffer(class="kw">sizeof(class="type">class="kw">float) * numNeurons * class="num">2, CL_MEM_READ_WRITE);
   if(m_iMemory < class="num">0)
      class="kw">return class="kw">false;
   m_iHiddenState = OpenCL.AddBuffer(class="kw">sizeof(class="type">class="kw">float) * numNeurons, CL_MEM_READ_WRITE);
   if(m_iHiddenState < class="num">0)
      class="kw">return class="kw">false;
   m_iConcatenated = OpenCL.AddBuffer(class="kw">sizeof(class="type">class="kw">float) * numNeurons * class="num">4, CL_MEM_READ_WRITE);
   if(m_iConcatenated < class="num">0)
      class="kw">return class="kw">false;
   m_iConcatenatedGradient = OpenCL.AddBuffer(class="kw">sizeof(class="type">class="kw">float) * numNeurons * class="num">4, CL_MEM_READ_WRITE);
   if(m_iConcatenatedGradient < class="num">0)
      class="kw">return class="kw">false;
class=class="str">"cmt">//---
   class="kw">return true;
  }
__kernel class="type">void LSTM_FeedForward(__global class="type">class="kw">float* inputs, class="type">uint inputs_size,
                                                                __global class="type">class="kw">float* weights,
                                                                __global class="type">class="kw">float* concatenated,
                                                                __global class="type">class="kw">float* memory,
                                                                __global class="type">class="kw">float* output
                                                                )
  {
   class="type">uint id = (class="type">uint)get_global_id(class="num">0);
   class="type">uint total = (class="type">uint)get_global_size(class="num">0);
   class="type">uint id2 = (class="type">uint) get_local_id(class="num">1);
   class="type">class="kw">float sum = class="num">0;
   class="type">uint shift = (id + id2 * total) * (total + inputs_size + class="num">1);
   for(class="type">uint i = class="num">0; i < total; i += class="num">4)
     {
       if(total - i > class="num">4)
         sum += dot((float4)(output[i], output[i + class="num">1], output[i + class="num">2], output[i + class="num">3]),
                                (float4)(weights[shift + i], weights[shift + i + class="num">1], weights[shift + i + class="num">2], weights[shift + i + class="num">3]));
       else
         for(class="type">uint k = i; k < total; k++)
            sum += output[k] + weights[shift + k];
     }
   shift += total;
   for(class="type">uint i = class="num">0; i < inputs_size; i += class="num">4)
     {
       if(total - i > class="num">4)

常见问题

递归结构靠内部记忆保留历史状态,不必每步重喂整段历史,训练时显存与重复计算都明显更少。
需为神经元预留动量缓冲与门控向量空间,按层批量分配避免核内越界,向量化前馈才跑得稳。
小布可加载品种页的模型诊断,把记忆向量与重构误差画成图,你一眼能看出哪段序列没训住。
基础篇不依赖涨跌标签,靠重构输入自学;新手常错把监督目标混进来,反而破坏了记忆结构。
有可能,建议在切换周期时重置隐藏状态,否则跨周期记忆会污染当前序列的重构结果。