分布式 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 看分位分布再上资金。
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 耗时变化,是验证多头切分是否生效的最快办法。
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,