生长型神经气:MQL5 中的实施·进阶篇
🧠

生长型神经气:MQL5 中的实施·进阶篇

(2/3)·从伪代码到类的封装,把自适应聚类真正写进 EA 前先搞懂这几步

含代码示例实战向 第 2/3 篇
不少交易者把 GNG 当成黑箱直接套用,却连神经元年龄和局部误差的更新顺序都没理清,回测里聚类数乱跳还以为是行情问题。算法在 MQL5 里跑不起来,多半是初始化和近邻平移那几步写岔了。

「GNG 神经元的权重更新与距离度量」

在 GNG(Growing Neural Gas)网络里,单个神经元继承自通用神经元类 CCustomNeuron,其核心方法 AdaptWeights 负责按传入的 delta 数组调整突触权重。代码先以 ArraySize(delta)!=m_synapses 做防线,维度不对直接 return,避免错位写入;随后用 for 循环把 delta[i] 叠加进 m_weights[i],并把 NET 清零,意味着本次前向计算缓存作废。 CGNGNeuron 在基类上扩展了 uid、E、U、error 四个字段,构造函数统一置 0。其中 error 存的是神经元与输入向量的「距离」,E 保留为局部误差容器,两者分离方便后续竞争与老化逻辑调用。 ProcessVector 是距离计算主体:当输入向量维度与突触数不符时退出,否则遍历每个维度累加 (in[i]-m_weights[i]) 的平方。这本质是欧氏距离的平方,未开根号以省算力,返回的 error 数值越大代表该神经元对当前样本越不敏感。 连接类 CGNGConnection 记录两个神经元 uid1、uid2 及 age,age 用于边缘老化淘汰。外汇与贵金属行情高波动、高杠杆,用此类拓扑做形态聚类时务必以小样本离线验证,实盘误判概率可能偏高。

MQL5 / C++
class="type">void CCustomNeuron::AdaptWeights(class="type">class="kw">double &delta[])
  {
  if(ArraySize(delta)!=m_synapses) class="kw">return;
  for(class="type">int i=class="num">0;i<m_synapses;i++) m_weights[i]+=delta[i];
  NET=class="num">0;
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 一个 GNG 网络中的独立神经元                                          |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CGNGNeuron:class="kw">public CCustomNeuron
  {
class="kw">public:
   class="type">int                uid;
   class="type">class="kw">double             E;
   class="type">class="kw">double             U;
   class="type">class="kw">double             error;
                     CGNGNeuron();
   class="kw">virtual class="type">void       ProcessVector(class="type">class="kw">double &in[]);
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 构造函数                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
CGNGNeuron::CGNGNeuron()
  {
  E=class="num">0;
  U=class="num">0;
  error=class="num">0;
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 计算神经元到输入向量的 "距离"                                     |
class=class="str">"cmt">//| 输入: in - 数据向量                                                |
class=class="str">"cmt">//| 输出: 无                                                           |
class=class="str">"cmt">//| 注意: 当前 "距离" 保存在 error 变量中,                            |
class=class="str">"cmt">//|        "局部误差" 包含于另外的变量中,                              |
class=class="str">"cmt">//|        该变量叫做 E                                                |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CGNGNeuron::ProcessVector(class="type">class="kw">double &in[])
  {
  if(ArraySize(in)!=m_synapses) class="kw">return;
  error=class="num">0;
  NET=class="num">0;
  for(class="type">int i=class="num">0;i<m_synapses;i++)
    {
    error+=(in[i]-m_weights[i])*(in[i]-m_weights[i]);
    }
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 定义两个神经元的连接 (边缘) 的类                                   |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CGNGConnection:class="kw">public CObject
  {
class="kw">public:
   class="type">int                uid1;
   class="type">int                uid2;
   class="type">int                age;
                     CGNGConnection();

◍ GNG 神经元链表的初始化与追加逻辑

在增长型神经气体(GNG)算法里,神经元不是预先固定数量的,而是靠链表动态生长。下面这段 CList 派生类把随机种子绑在本地时间上,保证每次 MT5 启动跑出来的初始神经元 uid 分布不重样。 Append 方法负责在链表尾塞一个“空白”神经元:首次调用时直接 new 出首节点,后续则接在 last_node 后面并维护双向指针。值得注意,uid 用 MathRand() 生成后还要过一遍 Find(rnd) 查重,碰撞就重摇,直到不重复才 break——这意味着当链表里神经元数量逼近 MathRand 上限(32767)时,while 循环可能明显拖慢。 Init 接口留给两个权重向量 v1、v2,用来在列表建好之后填进前两个神经元的参考向量。做价格聚类的朋友可以直接把 EURUSD 的 M15 收盘价差分序列灌进 v1/v2 试跑,观察神经元拓扑何时收敛。外汇与贵金属杠杆高,这类实验仅在策略研发环境进行,实盘接入前须充分回测。

MQL5 / C++
class="kw">virtual class="type">int      Type() const               { class="kw">return(TYPE_GNG_CONNECTION);}
};
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 构造函数                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
CGNGConnection::CGNGConnection()
  {
   age=class="num">0;
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 神经元链表                                                          |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CGNGNeuronList:class="kw">public CList
  {
class="kw">public:
   class=class="str">"cmt">//--- 构造函数
                    CGNGNeuronList() {MathSrand(TimeLocal());}
   CGNGNeuron       *Append();
   class="type">void             Init(class="type">class="kw">double &v1[],class="type">class="kw">double &v2[]);
   CGNGNeuron       *Find(class="type">int uid);
   class="type">void             FindWinners(CGNGNeuron *&Winner,CGNGNeuron *&SecondWinner);
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 在列表末尾增加一个 "空白" 神经元                                       |
class=class="str">"cmt">//| 输入: 无                                                            |
class=class="str">"cmt">//| 输出: 新神经元指针                                                  |
class=class="str">"cmt">//+------------------------------------------------------------------+
CGNGNeuron *CGNGNeuronList::Append()
  {
   if(m_first_node==NULL)
     {
      m_first_node= new CGNGNeuron;
      m_last_node = m_first_node;
     }
   else
     {
      GetLastNode();
      m_last_node=new CGNGNeuron;
      m_curr_node.Next(m_last_node);
      m_last_node.Prev(m_curr_node);
     }
   m_curr_node=m_last_node;
   m_curr_idx=m_data_total++;
   class="kw">while(true)
     {
      class="type">int rnd=MathRand();
      if(!CheckPointer(Find(rnd)))
        {
         ((CGNGNeuron *)m_curr_node).uid=rnd;
         class="kw">break;
        }
     }
class=class="str">"cmt">//---
   class="kw">return(m_curr_node);
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 根据向量的权重创建两个神经元                                         |
class=class="str">"cmt">//| 再用于初始化列表                                                    |
class=class="str">"cmt">//| 输入: v1,v2 - 权重向量                                             |

GNG 神经元链表的初始化与胜者检索

在 MT5 里跑 Growing Neural Gas 这类自组织网络,第一步是把两个初始向量塞进链表并清掉旧状态。Init 方法先 Clear 再连续 Append 两次,分别用 v1、v2 初始化头两个神经元,相当于给网络种下两个种子点。 Find 按 uid 线性遍历,从 GetFirstNode 起步,do-while 配合 CheckPointer(GetNextNode()) 一路比对,命中即返回指针,没找到回 NULL。这种 O(n) 查找在神经元数量较少时开销可忽略,节点上了千就要留意每次调用的累计成本。 FindWinners 干的事是扫两遍:第一遍找 error 最小的当 Winner,第二遍排除 Winner 后找 error 最小的当 SecondWinner。注意 err_min 初始为 0,首节点因 !CheckPointer(Winner) 必然入选,随后只有更小误差才能替换——这意味着误差非负时逻辑自洽,若你的误差计算可能输出负值得先校准。 下面这段是上述三个方法的原始实现,逐行照着抄进 EA 的 include 就能用: void CGNGNeuronList::Init(double &v1[],double &v2[]) { Clear(); Append(); ((CGNGNeuron *)m_curr_node).Init(v1); Append(); ((CGNGNeuron *)m_curr_node).Init(v2); } CGNGNeuron *CGNGNeuronList::Find(int uid) { if(!GetFirstNode()) return(NULL); do { if(((CGNGNeuron *)m_curr_node).uid==uid) return(m_curr_node); } while(CheckPointer(GetNextNode())); return(NULL); } void CGNGNeuronList::FindWinners(CGNGNeuron *&Winner,CGNGNeuron *&SecondWinner) { double err_min=0; Winner=NULL; if(!CheckPointer(GetFirstNode())) return; do {

if(!CheckPointer(Winner)((CGNGNeuron *)m_curr_node).error<err_min)

{ err_min= ((CGNGNeuron *)m_curr_node).error; Winner = m_curr_node; } } while(CheckPointer(GetNextNode())); err_min=0; SecondWinner=NULL; GetFirstNode(); do { if(m_curr_node!=Winner)

if(!CheckPointer(SecondWinner)((CGNGNeuron *)m_curr_node).error<err_min)

{ err_min=((CGNGNeuron *)m_curr_node).error; SecondWinner=m_curr_node; } } while(CheckPointer(GetNextNode())); m_curr_node=Winner; } 连接链表 CGNGConnectionList 直接继承 CList,只暴露 Append 和 Init(uid1,uid2),说明边结构在 GNG 里就是一对神经元 ID 的绑定,后续生长时靠它记录拓扑。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 输出: 无                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CGNGNeuronList::Init(class="type">class="kw">double &v1[],class="type">class="kw">double &v2[])
  {
  Clear();
  Append();
  ((CGNGNeuron *)m_curr_node).Init(v1);
  Append();
  ((CGNGNeuron *)m_curr_node).Init(v2);
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 根据 uid 搜索神经元                                                |
class=class="str">"cmt">//| 输入: uid - 神经元的唯一ID                                         |
class=class="str">"cmt">//| 输出: 如果成功返回神经元指针, 否则返回 NULL                         |
class=class="str">"cmt">//+------------------------------------------------------------------+
CGNGNeuron *CGNGNeuronList::Find(class="type">int uid)
  {
  if(!GetFirstNode()) class="kw">return(NULL);
  do
    {
    if(((CGNGNeuron *)m_curr_node).uid==uid)
      class="kw">return(m_curr_node);
    }
  class="kw">while(CheckPointer(GetNextNode()));
  class="kw">return(NULL);
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 根据最小当前误差搜索两个 "最好的" 神经元                           |
class=class="str">"cmt">//| 输入: 无                                                           |
class=class="str">"cmt">//| 输出: Winner - 最 "接近" 输入向量的神经元                          |
class=class="str">"cmt">//|        SecondWinner - 第二 "接近" 的神经元                         |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CGNGNeuronList::FindWinners(CGNGNeuron *&Winner,CGNGNeuron *&SecondWinner)
  {
  class="type">class="kw">double err_min=class="num">0;
  Winner=NULL;
  if(!CheckPointer(GetFirstNode())) class="kw">return;
  do
    {
    if(!CheckPointer(Winner) || ((CGNGNeuron *)m_curr_node).error<err_min)
      {
      err_min= ((CGNGNeuron *)m_curr_node).error;
      Winner = m_curr_node;
      }
    }
  class="kw">while(CheckPointer(GetNextNode()));
  err_min=class="num">0;
  SecondWinner=NULL;
  GetFirstNode();
  do
    {
    if(m_curr_node!=Winner)
      if(!CheckPointer(SecondWinner) || ((CGNGNeuron *)m_curr_node).error<err_min)
        {
        err_min=((CGNGNeuron *)m_curr_node).error;
        SecondWinner=m_curr_node;
        }
    }
  class="kw">while(CheckPointer(GetNextNode()));
  m_curr_node=Winner;
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 神经元之间连接的链表                                               |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CGNGConnectionList:class="kw">public CList
  {
class="kw">public:
  CGNGConnection   *Append();
  class="type">void             Init(class="type">int uid1,class="type">int uid2);

「GNG 连接链表的增查与初始化实现」

在 Growing Neural Gas 的 C++ 风格封装里,连接关系由 CGNGConnectionList 以双向链表承载。Append() 负责在尾部挂一个空白连接:首节点为空时直接 new 出头节点,否则先 GetLastNode() 定位尾端,再串好 Next/Prev 指针,最后把 m_data_total 自增 1 作为索引计数。 Init(int uid1,int uid2) 用 Append() 产出的首节点写入两端神经元 ID,并把当前/尾指针都锚定到首节点、索引归零。这意味着列表初始化时至少占用 1 个连接槽,m_data_total 从 0 变为 1。 Find(int uid1,int uid2) 不区分方向:用 do-while 从首节点扫到空指针,只要 (uid1,uid2) 或 (uid2,uid1) 命中就返回该连接指针,全表无果才返 NULL。注意这里强依赖 CheckPointer 判空,漏调 GetFirstNode() 会直接从野指针起步。 FindFirstConnection(int uid) 则是按单神经元 ID 拉拓扑邻居的入口,搜索域限定在从第一个神经元展开的列表。实盘跑 MT5 时,若你自建 GNG 聚类做 EURUSD 波动状态划分,可直接抄这套链表逻辑验证邻居检索耗时——样本量上千连接时,线性扫描延迟通常落在毫秒级,但高频调参仍建议缓存邻接表。外汇与贵金属杠杆高,任何信号模型都只是概率参考,别拿回测连通性当方向依据。

MQL5 / C++
CGNGConnection *CGNGConnectionList::Append()
  {
   if(m_first_node==NULL)
     {
      m_first_node= new CGNGConnection;
      m_last_node = m_first_node;
     }
   else
     {
      GetLastNode();
      m_last_node=new CGNGConnection;
      m_curr_node.Next(m_last_node);
      m_last_node.Prev(m_curr_node);
     }
   m_curr_node=m_last_node;
   m_curr_idx=m_data_total++;
   class="kw">return(m_curr_node);
  }
class="type">void CGNGConnectionList::Init(class="type">int uid1,class="type">int uid2)
  {
   Append();
   ((CGNGConnection *)m_first_node).uid1 = uid1;
   ((CGNGConnection *)m_first_node).uid2 = uid2;
   m_last_node = m_first_node;
   m_curr_node = m_first_node;
   m_curr_idx=class="num">0;
  }
CGNGConnection *CGNGConnectionList::Find(class="type">int uid1,class="type">int uid2)
  {
   if(!CheckPointer(GetFirstNode())) class="kw">return(NULL);
   do
     {
      if((((CGNGConnection *)m_curr_node).uid1==uid1 && ((CGNGConnection *)m_curr_node).uid2==uid2)
         ||(((CGNGConnection *)m_curr_node).uid1==uid2 && ((CGNGConnection *)m_curr_node).uid2==uid1))
         class="kw">return(m_curr_node);
     }
   class="kw">while(CheckPointer(GetNextNode()));
   class="kw">return(NULL);
  }

◍ 在连接链表里捞指定神经元的边

GNG 拓扑里每个神经元都靠连接对象维系邻居关系,要快速定位某条 uid 参与的边,就得在连接链表上做线性扫描。上面两段分别实现了从头找和从当前节点往后找两种入口,逻辑对称但起点不同。 FindConnection 先判断首节点指针是否有效,无效直接返 NULL;随后死循环里比对当前连接对象的 uid1 或 uid2 是否等于目标 uid,命中就 break 返回该节点指针。若中途下一个节点指针校验失败,说明链表到头也没找到,同样返 NULL。 FindNextConnection 的区别仅在于起点换成 GetCurrentNode(),它跳过首节点、从当前游标继续向后搜,适合已知大致位置时的增量遍历。两类函数在 MT5 策略里若被高频调用,链表长度超过 200 条连接时单次查找延迟可能明显抬升,建议在实盘前用 Comment 打印耗时验证。 别把线性扫描当免费 连接数膨胀到千级后,这种 O(n) 查找会让每 tick 的推理开销陡增,必要时要自己加哈希索引而非硬套原链表的遍历。

MQL5 / C++
  if(!CheckPointer(GetFirstNode())) class="kw">return(NULL);
  class="kw">while(true)
    {
      if(((CGNGConnection *)m_curr_node).uid1==uid || ((CGNGConnection *)m_curr_node).uid2==uid) class="kw">break;
      if(!CheckPointer(GetNextNode())) class="kw">return(NULL);
    }
  class="kw">return(m_curr_node);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 搜索指定的神经元的拓扑结构邻居       |
class=class="str">"cmt">//| 搜索范围是从当前神经元开始的神经元列表            |
class=class="str">"cmt">//| 输入: uid - 神经元ID                            |
class=class="str">"cmt">//| 输出: 如果有连接返回连接的指针, 否则返回 NULL       |
class=class="str">"cmt">//+------------------------------------------------------------------+
CGNGConnection   *CGNGConnectionList::FindNextConnection(class="type">int uid)
  {
  if(!CheckPointer(GetCurrentNode())) class="kw">return(NULL);
  class="kw">while(true)
    {
      if(!CheckPointer(GetNextNode())) class="kw">return(NULL);
      if(((CGNGConnection *)m_curr_node).uid1==uid || ((CGNGConnection *)m_curr_node).uid2==uid) class="kw">break;
    }
  class="kw">return(m_curr_node);
  }

把 GNG 算法装进一个类里

新建头文件 GNG.mqh 丢进 Include\GNG 目录,核心就是 CGNGAlgorithm 这个类。它只握有两个真正干活的物理介质:Neurons 指针(神经元链表)和 Connections 指针(连接链表),其余字段全是外部灌进来的算法参数,比如 lambda、age_max、alpha、beta 这些。 Init(...) 负责把外部参数传进实例并初始化数据结构;而之前约定的停止条件 StoppingCriterion() 目前是空壳,始终返回 false,意味着训练不会自己停。ProcessVector(…) 作为主函数只管按既定步骤组织数据并调用方法,细节都分散在带注释的代码位置,本身不堆逻辑。 想验证就直接把下面代码贴进 MT5 的 Include\GNG\GNG.mqh,编译看神经元链表和连接链表能否随 Init 参数正常起手。外汇与贵金属行情序列喂进去前,注意这类无停止条件的自组织网络容易过拟合,高风险。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//|                                                                 GNG.mqh |
class=class="str">"cmt">//|                                 Copyright class="num">2010, alsu              |
class=class="str">"cmt">//|                                        alsufx@gmail.com          |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#class="kw">property copyright "Copyright class="num">2010, alsu"
class="macro">#class="kw">property link      "alsufx@gmail.com"
class="macro">#include "Neurons.mqh"
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 体现 GNG 算法的主类                                              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CGNGAlgorithm
  {
class="kw">public:
  class=class="str">"cmt">//--- 神经元对象以及它们之间连接的链表
  CGNGNeuronList   *Neurons;
  CGNGConnectionList *Connections;
  class=class="str">"cmt">//--- 算法的参数
  class="type">int               input_dimension;
  class="type">int               iteration_number;
  class="type">int               lambda;
  class="type">int               age_max;
  class="type">class="kw">double            alpha;
  class="type">class="kw">double            beta;
  class="type">class="kw">double            eps_w;
  class="type">class="kw">double            eps_n;
  class="type">int               max_nodes;
                     CGNGAlgorithm();
                    ~CGNGAlgorithm();
  class="kw">virtual class="type">void      Init(class="type">int __input_dimension,
                         class="type">class="kw">double &v1[],
                         class="type">class="kw">double &v2[],
                         class="type">int __lambda,
                         class="type">int __age_max,
                         class="type">class="kw">double __alpha,
                         class="type">class="kw">double __beta,
                         class="type">class="kw">double __eps_w,
                         class="type">class="kw">double __eps_n,
                         class="type">int __max_nodes);
把重复劳动交给小布
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到聚类边界与神经元增长态势,你专注决策就好。

常见问题

通常赢家用较大的 ε_b、近邻用较小的 ε_n,具体值要按输入向量量纲调,外汇贵金属波动尺度差异大,建议先标准化再跑。
可以,小布盯盘的品种页支持外接指标信号,把 MQL5 算出的神经元中心输出成缓冲区就能叠加查看,省去自己画连接线的功夫。
S-GNG 引入监督信号修正拓扑,在难以线性切分的输入空间里拓扑更紧凑,分类效率倾向优于径向基函数网络,但计算开销也更高。
删边能保留已学到的局部结构,避免误删有效聚类中心;节点删除通常只在误差长期不更新时才触发,符合 GNG 生长优先的逻辑。
概率上会,所以实战中要设最大节点数或定期剪枝,MT5 的 OnTimer 里做轻量维护比每 tick 全量更新更稳。