分布式 Q-学习基础:用奖励概率分布替代均值估算(基础篇)
🧠

分布式 Q-学习基础:用奖励概率分布替代均值估算(基础篇)

(1/3)·传统 Q-学习只看期望均值,异常值一冲就失真,本篇拆解分布视角的底层逻辑

偏理论 第 1/3 篇
很多交易者把 Q-学习的期望奖励均值直接当决策依据,却没意识到尖锐异常值会让均值严重失真。当环境在同一动作下同时返回正负奖励时,均值接近零,模型直接跳过潜在机会。分布式视角先看概率分布,再谈风险。

用分布式 Q-学习给策略加一层概率视角

在 MT5 里做强化学习,分布式 Q-学习比普通 Q-学习多走一步:它不只估计某个动作的平均期望收益,而是估计收益的完整分布。对做价格行为突破的交易者来说,这意味着你能看到「突破后不同回报区间」的概率形状,而不是一个被均值抹平的单一数值。 这套思路在 MetaTrader 5 的 MQL5 环境里可以直接落地。2023 年 2 月发布的示例工程在社区拿到 1886 次查看、10 条讨论,说明实盘圈对把 RL 塞进 EA 是有真实需求的。外汇与贵金属杠杆高,分布尾部对应的回撤可能远超均值估计,任何基于该方法的信号都只能用「概率倾向」去理解。 真正能上手的动作很简单:打开 MT5 的 MetaEditor,新建一个 EA 框架,把状态空间定义成最近 N 根 K 线的实体与影线比例,动作空间设为「持多 / 持空 / 空仓」,奖励函数挂钩账户净值变动。先在小周期 EURUSD 上跑分布估计,看不同分位数下的回报差异,再决定阈值。

◍ 从均值 Q 函数到奖励分布视角

在早前的深度 Q-学习(DQN)方案里,我们用神经网络去近似 Q 函数,把它当成“状态 + 动作 → 期望奖励”的映射。但真实行情状态是多因子演化的,你没法把全部影响因素都塞进状态向量,于是状态、动作、奖励之间往往没有直接因果,模型吐出来的只是最可能的期望奖励均值。 均值视角有个硬伤:训练里拿到的奖励分布全被压成一个数,而外汇和贵金属这种高波动市场里,尖峰异常值会直接把均值拽歪,策略评估可能失真。

  • 年有两篇论文提出直接建模奖励价值分布,而不是只估均值。作者在 Atari 基准上把经典 Q-学习结果显著拉高——这对做价格行为建模的交易者是个信号:分布信息比单点期望更抗噪。

在 MT5 里验证这类思路时,建议先用历史 tick 跑分布统计,别直接信回测曲线的平均收益,贵金属杠杆品种的高风险会让异常样本放大误导。

「把 Q-函数拆成奖励概率分布」

分布式 Q-学习和原始 Q-学习一样,都是在近似动作功效函数,但前者不再只估一个期望奖励值,而是去近似「在某个状态下执行某动作后,整体期望奖励落在各个分位数区间的概率」。资源有限,不可能算每个单独奖励值的概率,所以把奖励范围切成 N 个分位数,用 Vmin、Vmax 和 N 三个超参数框定。分位数步长由公式 (Vmax - Vmin) / (N - 1) 给出。 这一转换把回归问题变成了标准分类问题,损失函数也从均方误差换成 LogLoss——和策略梯度里用过的那个一致。好处很直接:你不仅能拿到平均奖励的估计,还能拿到「拿到某档正面或负面奖励」的概率,从而用基于风险的方式选动作。 原始 Q-学习在同样动作有时给正奖励、有时给负奖励时,均值接近 0,模型可能直接跳过该动作;分布式版本能估出真实奖励的分布,正负面概率都看得见,决策更不容易被均值误导。注意任何可能动作的环境奖励概率总和必须等于 1,这靠对动作向量调 SoftMax 来实现。 训练仍基于贝尔曼方程,也保留体验重播缓冲区和目标网络。目标网络是主模型的冻结副本,用来预测未来状态奖励,避免两个网络并行训练互相带偏。初期可先不接目标网络,只训出「提前一步」的合理预测,再把它当目标网络去训「提前两步」的策略。 折扣因子 γ 管的是模型预见长度:γ 接近 1,模型倾向构建长线策略;γ 压到接近 0,模型遗忘远期奖励、专注短期利润,出来的就是剥头皮逻辑,实际持仓还受所用时间帧制约。外汇与贵金属杠杆高、回撤快,这类基于概率的策略只降低盲动,不消除爆仓风险,参数务必在 MT5 策略测试器里先跑分布再上实盘。

给 SoftMax 层加上分布式归一化

把原始 Q-学习改成概率式分布式版本,卡点落在输出层:原先的 CNeuronSoftMaxOCL 只在整层做一次 SoftMax,使全部动作概率和为 1;而分布式 Q-学习要求每个动作分支的分布独立归一。直接复用旧类会串味,所以给类加一个 iHeads 变量和 SetHeads 方法,默认 1 向量,保持旧逻辑兼容。 OpenCL 端改动很小但关键。原内核在一维空间跑,现在派发成二维:第一维是单个向量的长度,第二维标出向量个数(模块偏移)。每个工作组只归一化自己的那一块,局部数组不跨组共享,因此线程同步模型从『全缓冲一个组』变成『每组一个向量』。内核里只多了取第二维线程 ID、算全局偏移两处高亮行。 回到 C++ 侧,feedForward 里 global_work_size 变成 [size, iHeads],local_work_size 同步二维且第二维为 1。改动后全局线程数必须是各维局部数的整数倍,否则派发报错。calcInputGradients 同理改偏移,输出梯度内核不用动——它本来按序列元素算偏差。 新变量必须落盘。父类 Save/Load 不认 iHeads,于是在子类里先调父类方法做句柄校验与继承体保存,再读写新变量;加载时顺带限制最小向量数。外部用 step 参数把 iHeads 传进 CNet::Create,不碰网络架构其余部分。 训练 EA 叫 DistQ-learning.mq5,基于旧 Q-learning.mq5。动作数由 Action 参数定,分位数按输出层规模推;中位数对应零奖励,外部 Step 把奖惩映射成分位跨度。训练时先由目标网络取每动作最大概率分位转自然奖励,再按烛条方向写奖励:看涨烛里买动作为正、卖动作为更负、市外为负惩罚,最后把奖励转回对应分位概率 1、其余 0。外汇/贵金属行情跳空频繁,这套映射在实盘大概率需重调 Step,建议先开 MT5 用脚本跑附件 EA 看分位分布再上资金。

MQL5 / C++
class CNeuronSoftMaxOCL    :  class="kw">public CNeuronBaseOCL
  {
class="kw">protected:
   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">return true; }
class="kw">public:
                    CNeuronSoftMaxOCL(class="type">void) {};
                   ~CNeuronSoftMaxOCL(class="type">void) {};
   class="kw">virtual class="type">bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);
   class="kw">virtual class="type">bool      calcOutputGradients(CArrayFloat *Target, class="type">float& error) class="kw">override;
   class=class="str">"cmt">//---

◍ SoftMax 核里多头偏移怎么算

CNeuronSoftMaxOCL 在基类之上多了一个 iHeads 成员,默认构造时设为 1,可通过 SetHeads(int heads) 改写。这意味着同一层输出若按多头切分,每个 head 拥有独立的 total 长度缓冲区,喂前向时得自己算偏移。 看 OpenCL 核 SoftMax_FeedForward:get_global_id(1) 拿到 head 序号 h,shift_head = h * total 就是该 head 在全局 inputs/outputs 里的起点。核内先用 256 长度的本地数组 temp 做分段 exp 累加,local size 被 min 限制到 256 以内,避免越界。 最后归一化那行 outputs[shift] = exp(inputs[shift] / 10) / (sum + 1e-37f) 有两个细节:分子除以 10 等于做了一次温度缩放,sum 加 1e-37f 而非 0 是为了防除零得到 NaN。外汇与贵金属行情用这类 GPU softmax 做多 head 特征归一化时,数值稳定性直接决定训练是否发散,属高风险实验。 在 MT5 里把这段核绑到自定义神经层,改 iHeads 从 1 调到 4,观察显存占用与 forward 耗时变化,是验证多头切分是否生效的最快办法。

MQL5 / C++
class="kw">virtual class="type">int        Type(class="type">void) class="kw">override class="kw">const   { class="kw">return defNeuronSoftMaxOCL; }
};
class CNeuronSoftMaxOCL     :  class="kw">public CNeuronBaseOCL
{
class="kw">protected:
  class="type">uint                iHeads;
class="kw">public:
                     CNeuronSoftMaxOCL(class="type">void) : iHeads(class="num">1) {};
                    ~CNeuronSoftMaxOCL(class="type">void) {};
  class="kw">virtual class="type">void        SetHeads(class="type">int heads)  { iHeads = heads; }
};
__kernel class="type">void SoftMax_FeedForward(__global class="type">float *inputs,
                                  __global class="type">float *outputs,
                                  class="kw">const class="type">uint total)
  {
   class="type">uint i = (class="type">uint)get_global_id(class="num">0);
   class="type">uint l = (class="type">uint)get_local_id(class="num">0);
   class="type">uint h = (class="type">uint)get_global_id(class="num">1);
   class="type">uint ls = min((class="type">uint)get_local_size(class="num">0), (class="type">uint)class="num">256);
   class="type">uint shift_head = h * total;
   __local class="type">float temp[class="num">256];
   class="type">uint count = class="num">0;
   if(l < class="num">256)
     do
       {
        class="type">uint shift = shift_head + count * ls + l;
        temp[l] = (count > class="num">0 ? temp[l] : class="num">0) + (shift < ((h + class="num">1) * total) ? exp(inputs[shift]) : class="num">0);
        count++;
       }
     while((count * ls + l) < total);
   barrier(CLK_LOCAL_MEM_FENCE);
   count = ls;
   do
     {
      count = (count + class="num">1) / class="num">2;
      if(l < class="num">256)
        temp[l] += (l < count && (l + count) < total ? temp[l + count] : class="num">0);
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > class="num">1);
class=class="str">"cmt">//---
   class="type">float sum = temp[class="num">0];
   if(sum != class="num">0)
     {
      count = class="num">0;
      while((count * ls + l) < total)
        {
         class="type">uint shift = shift_head + count * ls + l;
         if(shift < ((h + class="num">1) * total))
            outputs[shift] = exp(inputs[shift] / class="num">10) / (sum + 1e-37f);
         count++;
        }
     }
  }
__kernel class="type">void SoftMax_HiddenGradient(__global class="type">float* outputs,
让小布替你跑这套
奖励分布诊断这类重复计算,小布盯盘的 AIGC 模块已内置,打开对应品种页即可看到分位数概率视图,你只需判断风险偏好。

常见问题

原始 Q-学习用标准差做损失,分布式 Q-学习把问题转为分类任务,采用 LogLoss 近似每个分位数内奖励的概率。
Vmin 和 Vmax 划定期望奖励范围,N 决定切分粒度;粒度越细分布越精确,但计算开销随分位数数量线性上升。
可以,小布盯盘的品种页内置了基于风险的概率视图,把分布诊断交给小布,你专注决策即可。
因为它估算的是获得正负奖励的真实概率,而非平均,基于风险的方法可据此判断是否值得承担波动。