您应当知道的 MQL5 向导技术(第 36 部分):依据马尔可夫(Markov)链的 Q-学习·进阶篇
🧠

您应当知道的 MQL5 向导技术(第 36 部分):依据马尔可夫(Markov)链的 Q-学习·进阶篇

(2/3)· 当强化学习遇上马尔可夫链,MLP 的损失函数还能怎么被推着走?

含代码示例实战向 第 2/3 篇
把强化学习直接当信号源的交易者不少,但多数人没意识到 Q-学习在 MLP 里更适合做从属角色。本篇接着上篇,看马尔可夫链怎么补上状态转移的盲区,让多层感知器的学习过程少走弯路。

◍ 在反向传播里切换自定义损失

把神经网络接进 MT5 做行情建模时,损失函数不能只盯均方误差。下面这段枚举给出了三种可插拔选项:LOSS_TYPICAL=-1 走常规梯度,LOSS_SVR=1 用支持向量回归损失,LOSS_QL=2 走 Q-learning 的 critic 回报。 Cmlp::Backward 里根据 THIS.loss_custom 分流:选 SVR 就直接算 _last_loss = SVR_Loss();选 QL 则先由 CriticReward 算原始奖励,再按动作号 0 或 1 对奖励取负或取负绝对值,最后用 Q_Loss() 求梯度。 CriticState 负责离线策略更新 Q 值:取状态向量末位设定新马尔可夫行列下标,滚动保存上一步 e_row/e_col,再 Action() 挑出最大 Q 对应的动作。若开启 use_markov 会调用 LetMarkov 做状态迁移加权。外汇与贵金属波动剧烈,这类模型信号仅具概率意义,实盘前务必在 MT5 策略测试器跑多周期回测。

MQL5 / C++
enum Eloss
{  LOSS_TYPICAL = -class="num">1,
   LOSS_SVR = class="num">1,
   LOSS_QL = class="num">2
};
class="type">void Cmlp::Backward(Slearning &Learning, class="type">int EpochIndex = class="num">1)
{  
   ...
   if(THIS.loss_custom == LOSS_SVR)
   {  _last_loss = SVR_Loss();
   }
   else if(THIS.loss_custom == LOSS_QL)
   {  class="type">class="kw">double _reward = QL.CriticReward(Learning.ql_reward_max, Learning.ql_reward_min, Learning.ql_reward_float);
      if(QL.act == class="num">0)
      {  _reward *= -class="num">1.0;
      }
      else if(QL.act == class="num">1)
      {  _reward = -class="num">1.0 * fabs(_reward);
      }
      QL.CriticState(_reward, Learning.ql_e);
      _last_loss = output.LossGradient(QL.Q_Loss(), THIS.loss_typical);
   }
   ...
}
class="type">void Cql::CriticState(class="type">class="kw">double Reward, vector &E)
{  class="type">int _e_row_new = class="num">0, _e_col_new = class="num">0;
   SetMarkov(class="type">int(E[E.Size() - class="num">1]), _e_row_new, _e_col_new);
   e_row[class="num">1] = e_row[class="num">0];
   e_col[class="num">1] = e_col[class="num">0];
   e_row[class="num">0] = _e_row_new;
   e_col[class="num">0] = _e_col_new;
   class="type">int _new_best_q = Action();
   class="type">class="kw">double _weighting = Q[e_row[class="num">0]][e_col[class="num">0]][_new_best_q];
   if(THIS.use_markov)
   {  LetMarkov(e_row[class="num">1], e_col[class="num">1], E);

「epsilon-greedy 下的动作选择逻辑」

这段 CQL 类的 Action() 把探索与利用直接压进一行概率判断:当随机数落在 epsilon 区间内就随机出手,否则扫一遍当前状态的 Q 值挑最大的。外汇与贵金属市场的高波动会让 Q 表在样本不足时严重偏移,随机探索比例设太高容易在实盘里频繁吃滑点。 代码先用 rand()%SHORT_MAX 再除以 SHORT_MAX,把整数随机量归一化到 0~1 之间,与 THIS.epsilon 比较。若小于阈值走探索分支,随机选 0 到 actions-1 的任一动作;否则从 Q[e_row[0]][e_col[0]][0] 起步,循环比对后续动作值,记录最大者下标。 最后把选出的 _best_act 写回成员变量 act 并返回。你在 MT5 里接这套逻辑时,建议先把 epsilon 打印到日志观察分布——若连续 100 次调用中探索占比偏离设定值超过 5%,多半是 rand 种子未初始化导致的伪随机聚集。

MQL5 / C++
class="type">int _old_index = GetMarkov(e_row[class="num">1], e_col[class="num">1]);
class="type">int _new_index = GetMarkov(e_row[class="num">0], e_col[class="num">0]);
_weighting *= markov[_old_index][_new_index];
}
Q[e_row[class="num">1]][e_col[class="num">1]][act] += THIS.alpha * (Reward + (THIS.gamma * _weighting) - Q[e_row[class="num">1]][e_col[class="num">1]][act]);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">// Choose an action using epsilon-greedy
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int Cql::Action()
{  class="type">int _best_act = class="num">0;
   if (class="type">class="kw">double((rand() % SHORT_MAX) / SHORT_MAX) < THIS.epsilon)
   {  class=class="str">"cmt">// Explore: Choose random action
      _best_act = (rand() % THIS.actions);
   }
   else
   {  class=class="str">"cmt">// Exploit: Choose best action
      class="type">class="kw">double _best_value = Q[e_row[class="num">0]][e_col[class="num">0]][class="num">0];
      for (class="type">int i = class="num">1; i < THIS.actions; i++)
      {  if (Q[e_row[class="num">0]][e_col[class="num">0]][i] > _best_value)
         {  _best_value = Q[e_row[class="num">0]][e_col[class="num">0]][i];
            _best_act = i;
         }
      }
   }
   class=class="str">"cmt">//update last action
   act = _best_act;
   class="kw">return(_best_act);
}

把环境状态压进 9×9 转换矩阵

马尔可夫链本身是无记忆的概率模型:下一状态只取决于当前状态,不翻旧账。在我们的自定义信号里,环境矩阵只装了短期与长期框架各自的看涨、看跌、横盘三种状态,拼成 3×3 共九种组合,因此转换矩阵必须是 9×9,才能把任意一种环境组合映射到另一种。 要把「行+列」这对坐标塞进单一索引,得靠两个互逆函数。GetMarkov 用 Row + (environments * Col) 算出线性位置;SetMarkov 则用 floor(Index / environments) 取列、fmod(Index, environments) 取行还原。environments 固定为 3,所以索引范围 0–8 刚好覆盖九态。 学习时先取旧状态的马尔可夫索引,抽取该列全部转换概率(总和为 1)作为权重;Q-映射选定下一态后,仅用其概率作分子去放大 Q 值。折扣力度与矩阵里的转移概率成正比,概率高的路径学得更快。 每根新柱线触发 LetMarkov 重算:先清掉 markov 与计数矩阵,第一个循环统计 E 序列相邻状态跳转次数,第二个循环用跳转次数除以该状态总出现次数归一化。代码里 _transitions 和 _states 每次重新 Fill(0.0),保证不留历史——这是无记忆设定的硬要求。外汇与贵金属市场高杠杆、跳空频繁,这种纯概率迁移对外汇黄金复盘仅供参考,实盘前务必在 MT5 策略测试器跑一轮。

MQL5 / C++
<span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="comment">class=class="str">"cmt">// Getting markov index from environment row &amp; col</span>
<span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="keyword">class="type">int</span> Cql::GetMarkov(<span class="keyword">class="type">int</span> Row, <span class="keyword">class="type">int</span> Col)
{  <span class="keyword">class="kw">return</span>(Row + (THIS.environments * Col));
}
<span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="comment">class=class="str">"cmt">// Getting environment row &amp; col from markov index</span>
<span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="keyword">class="type">void</span> Cql::SetMarkov(<span class="keyword">class="type">int</span> Index, <span class="keyword">class="type">int</span> &amp;Row, <span class="keyword">class="type">int</span> &amp;Col)
{  Col = <span class="keyword">class="type">int</span>(<span class="functions">floor</span>(Index / THIS.environments));
   Row = <span class="keyword">class="type">int</span>(<span class="functions">fmod</span>(Index, THIS.environments));
}
<span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="comment">class=class="str">"cmt">// Function to update markov matrix</span>
<span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="keyword">class="type">void</span> Cql::LetMarkov(<span class="keyword">class="type">int</span> OldRow, <span class="keyword">class="type">int</span> OldCol, <span class="vector">vector</span> &amp;E)  <span class="comment">class=class="str">"cmt">//</span>
{  <span class="keyword">matrix</span> _transitions;  <span class="comment">class=class="str">"cmt">// Count the transitions</span>
   _transitions.Init(markov.Rows(), markov.Cols());
   _transitions.Fill(<span class="number">class="num">0.0</span>);
   <span class="keyword">vector</span> _states;  <span class="comment">class=class="str">"cmt">// Count the occurrences of each state</span>
   _states.Init(markov.Rows());
   _states.Fill(<span class="number">class="num">0.0</span>);
<span class="comment">class=class="str">"cmt">// Count transitions from state i to state ii</span>
   <span class="keyword">for</span> (<span class="keyword">class="type">int</span> i = <span class="number">class="num">0</span>; i &lt; <span class="keyword">class="type">int</span>(E.Size()) - <span class="number">class="num">1</span>; i++)
   {  <span class="keyword">class="type">int</span> _old_state = <span class="keyword">class="type">int</span>(E[i]);
      <span class="keyword">class="type">int</span> _new_state = <span class="keyword">class="type">int</span>(E[i + <span class="number">class="num">1</span>]);
      _transitions[_old_state][_new_state]++;
      _states[_old_state]++;
   }
<span class="comment">class=class="str">"cmt">// Reset prior values to zero.</span>
   markov.Fill(<span class="number">class="num">0.0</span>);
<span class="comment">class=class="str">"cmt">// Compute probabilities by normalizing transition counts</span>
   <span class="keyword">for</span> (<span class="keyword">class="type">int</span> i = <span class="number">class="num">0</span>; i &lt; <span class="keyword">class="type">int</span>(markov.Rows()); i++)
   {  <span class="keyword">for</span> (<span class="keyword">class="type">int</span> ii = <span class="number">class="num">0</span>; ii &lt; <span class="keyword">class="type">int</span>(markov.Cols()); ii++)
      {  <span class="keyword">if</span> (_states[i] &gt; <span class="number">class="num">0</span>)
         {  markov[i][ii] = <span class="keyword">class="type">class="kw">double</span>(_transitions[i][ii] / _states[i]);
         }
         <span class="keyword">else</span>
         {  markov[i][ii] = <span class="number">class="num">0.0</span>;  <span class="comment">class=class="str">"cmt">// No transitions from this state</span>
         }
      }
   }
}

◍ 把环境坐标喂进 2-8-3 分类器

这套信号类里,强化学习只是配角:它不参与直接出信号,而是把目标函数量化成评论者奖励,主分类器仍是 MLP。环境矩阵是 3×3 网格,横纵轴分别是短期与长期框架从看涨、横盘到看跌的读数,Q 映射复用同一网格并附加动作评分数组,当作 MLP 的训练标签。 输入不是裸价格变化,而是环境状态坐标。短期用单根 K 线变化,长期由整数参数 m_scale 决定倍数——比如 m_scale=8,长期就覆盖最近 8 根柱线的累计变化。GetOutput 里先拉 4 个向量(行/列各新旧两组),相减得到变化,再经 Environment 与 SetMarkov 转成 _row、_col 两个坐标填进输入向量。 MLP 架构固定为 2-8-3:2 个输入、8 节点隐藏层、3 个输出,分别对应做空(索引0)、观望(索引1)、做多(索引2)。输出是一张概率映射,数值越高代表该动作在当前状态适用性可能越强。外汇与贵金属杠杆高,信号仅作概率参考,实盘前请在 MT5 用历史数据跑通后再上模拟盘。 下面这段是 GetOutput 的核心片段,注意 _in_row 与 _in_row_old 都用 CopyRates 取 PERIOD 下的 m_scale 根柱,位置错开一根,相减即短期变化;_in_col 系列同理但取更远端,构成长期变化。最后 _in[0]、_in[1] 直接塞坐标,MLP.Forward() 出概率。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//|                                                                  |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CSignalQLM::GetOutput(class="type">int &Output)
{  m_learning.rate = m_learning_rate;
   for(class="type">int i = m_epochs; i >= class="num">1; i--)
   {  MLP.LearningType(m_learning, i);
      for(class="type">int ii = m_train_set; ii >= class="num">0; ii--)
      {  vector _in, _in_row, _in_row_old, _in_col, _in_col_old;
         if
         (
            _in_row.Init(m_scale) &&
            _in_row.CopyRates(m_symbol.Name(), m_period, class="num">8, ii + class="num">1, m_scale) &&
            _in_row.Size() == m_scale
            &&
            _in_row_old.Init(m_scale) &&
            _in_row_old.CopyRates(m_symbol.Name(), m_period, class="num">8, ii + class="num">1 + class="num">1, m_scale) &&
            _in_row_old.Size() == m_scale
            &&
            _in_col.Init(m_scale) &&
            _in_col.CopyRates(m_symbol.Name(), m_period, class="num">8, ii + class="num">1, m_scale) &&
            _in_col.Size() == m_scale
            &&
            _in_col_old.Init(m_scale) &&
            _in_col_old.CopyRates(m_symbol.Name(), m_period, class="num">8, m_scale + ii + class="num">1, m_scale) &&
            _in_col_old.Size() == m_scale
         )
         {  _in_row -= _in_row_old;
            _in_col -= _in_col_old;
            vector _in_e;
            _in_e.Init(m_scale);
            MLP.QL.Environment(_in_row, _in_col, _in_e);
            class="type">int _row = class="num">0, _col = class="num">0;
            MLP.QL.SetMarkov(class="type">int(_in_e[m_scale - class="num">1]), _row, _col);
            _in.Init(__MLP_INPUTS);
            _in[class="num">0] = _row;
            _in[class="num">1] = _col;
            MLP.Set(_in);
            MLP.Forward();
            if(ii > class="num">0)
把状态空间交给小布先跑一遍
这类 9 维环境空间的奖励矩阵调试很耗神,小布盯盘的 AIGC 已内置了类似的诊断视图,打开对应品种页即可看到状态分布与转移热度,你只管判断要不要改惩罚权重。

常见问题

不一定。本篇将其实现为自定义信号类,但主要目的是辅助 MLP 的损失推进,而非直接生成下单信号,外汇贵金属高风险,任何单模块都需组合验证。
指数 4 表示短期和长期都处在横盘整理状态,这类状态在趋势策略里常被忽略,但 Q-映射会照常记录其转移概率。
差异可能来自状态转移假设改变了探索路径,倾向反映 MLP 在少数状态上过拟合被削弱,但仍需多品种回测确认。
目前小布提供的是状态空间与奖励热度的可视化,不内置编译信号类;你可以把向导导出的代码贴进小布的诊断流,让它标注哪段转移逻辑概率异常。
容易把评论者奖励设得过于刚性,导致环境状态 early convergence,建议在自定义信号里留可调衰减系数。