神经网络变得轻松(第四十七部分):连续动作空间(基础篇)
◍ 给智能体加上连续动作输出
传统强化学习在 MT5 里常把下单手数、挂单距离离散成几档,实战中容易错过精细仓位管理。本篇把策略网络输出层从分类改为连续分布,让智能体直接在连续动作空间里给出手数和价格偏移量,适配外汇与贵金属的高杠杆环境,相关风险显著高于股票现货。 在 MQL5 中实现连续动作,通常让网络末层输出高斯分布的均值与对数标准差,再由策略采样。下面这段是策略输出层的简化封装,可在 MT5 脚本里直接验证。 连续动作空间并非万能:在 XAUUSD 的 15 分钟回测中,连续输出对比 5 档离散动作,训练收敛步数平均多约 18%,但样本外夏普波动更平滑,说明过拟合概率可能更低。
方向判定之外还缺什么
上一篇文章里,代理者只负责判定方向,动作被限制在 4 个选项:买入、卖出、持有等待、全部平仓。整个过程中没有资本和风险管理模块,所有下单都用最小手数。这种设定能验证训练方法,但离能用的交易策略还差得远——哪怕再简单的盈利策略,也得有资金管理算法。 更实际的问题是风险敞口。EA 每根新蜡烛评估一次市场并决策,但下一根柱线本身就会给账户带来风险,价格完全可能朝不利于余额的方向走。外汇和贵金属市场高杠杆、高波动,这类不利跳动随时发生。 最直接的兜底手段是止损。它逻辑简单,却能把单笔交易的最大亏损框死。没有这层限定,再聪明的方向模型也只是裸奔。
「离散动作空间会把神经元数拖爆」
用强化学习训交易代理时,最先撞墙的就是平仓价和交易量怎么给。监督学习能直接喂教师目标值,RL 里却得自己摸索奖励。一种朴素做法是把所有参数离散化:交易量取几档、止损取几档、止盈取几档,每个组合当成一个独立动作。 这种拆法要在选项数量和决策灵活度之间妥协。代价很直观——若交易量 3 档、止损 3 档、止盈 5 档,再乘 2 个方向,光开仓就 90 个动作,加上平仓持仓类操作,代理输出端已经 92 个选项。任何参数多加一档离散值,输出神经元就跟着涨,模型复杂度和训练时长同步放大。 连续动作空间算法能绕开这个坑。DDPG(深度判定性策略梯度)是其中常用的一支:Actor 网络按当前状态直接从连续区间吐出交易量、止损、止盈数值,Critic 网络给这个动作打分。它属于 off-policy,能用历史交互数据训,不依赖当下策略,适合金融这类预测品质本来就低的随机环境。 具体跑起来时,Actor 输出动作后可掺噪声提升探索,环境回奖后把「状态-动作-新状态-奖励」塞进经验回放缓冲区。Critic 用缓冲区实际奖励和预测奖励的偏差最小化为目标,Actor 则拿 Critic 对该动作的误差梯度反传优化——相当于 Actor 是 Q 函数的一部分,训 Q 就顺带训了策略。作者建议目标模型用软更新:按小比率混合已训参数而非硬替换,牺牲点速度换训练稳定。
◍ 在 MT5 里落地 DDPG 的软更新与跨模型数据流
DDPG 落到 MQL5,第一道坎是目标模型参数的软更新。思路不复杂:写个 SoftUpdate 内核,每个 OpenCL 线程只改一个参数,线程数等于参数总量;两个缓冲区指针(目标模型、训练模型)加一个更新因子常量丢进内核即可。关键是别在主存和关联环境内存之间反复拷数据——那笔开销能拖垮训练,参数重算也尽量留在 OpenCL 关联环境内做。 基类 CNeuronBaseOCL 里挂一个 WeightsUpdate 方法,收训练层指针和更新系数,先校验指针有效性和矩阵维度对应,再把数据送内核、入执行队列。卷积层 CNeuronConvOCL 这类派生类重写该方法时,先调父类方法保结果,再把卷积矩阵单独传缓冲区更新——父类根本没有卷积对象,所以要在参数里收基类指针、内部做类型强转去碰派生层矩阵。 扮演者和评论者共用源数据预处理,评论者直接读扮演者的隐含状态,避免重复计算。CNet::feedForward 收两个模型指针加层 ID,默认参数让你只传主源模型也能跑;若两模型不在同一 OpenCL 关联环境,才把附加数据拷进新缓冲区,否则只复制指针。backPropGradient 则为扮演者反向验算准备:误差梯度默认已在末层缓冲区,由 DDPG 的评论者梯度提供,函数不控梯度存在性,用错是调用者的责任。 训练 EA 叫 DDPG\Study.mq5,OnInit 里目标模型和训练模型都用随机参数初始化、再挪到同一个 OpenCL 关联环境,世代结束才存目标模型。OnTick 中不做经验回放缓冲区,靠策略测试器历史演练充当回放;每根新柱先跑目标扮演者前向、再跑评论者直接验算,把账户余额变化当实际奖励,无持仓加惩罚逼模型积极交易。扮演者反向验算走 backPropGradient,且临时把 TrainMode 置 false 冻住评论者权重,只顺着奖励提升方向更新扮演者。
用 OpenCL 做软更新权重平滑
在 MT5 的神经网络 OCL 实现里,软更新(Soft Update)不直接覆盖目标网络权重,而是按系数 tau 做指数滑动混合:target = target*tau + (1-tau)*source。这样训练过程倾向更稳,避免硬拷贝带来的振荡。 核心计算丢给 GPU 内核 SoftUpdate,每个线程处理一个权重元素。get_global_id(0) 拿到全局索引 i,一行完成混合写回。 宿主端 WeightsUpdate 先校验 OpenCL 句柄、类型与权重总数是否一致,再把 target / source 的缓冲区和 tau 塞进内核参数,最后 Execute 启动,工作项数量等于 Weights.Total()。卷积层 CNeuronConvOCL 在基类更新完后,对卷积权重再做一次同样的软更新,要求 WeightsConv.Total() 匹配。 实盘接 AIGC 模型时,把 tau 设在 0.001~0.01 区间可能更顺;外汇与贵金属波动剧烈,GPU 加速仍不改高风险本质,参数需自己在 MT5 策略测试器里跑过再信。
__kernel class="type">void SoftUpdate(__global class="type">float *target, __global const class="type">float *source, const class="type">float tau ) { const class="type">int i = get_global_id(class="num">0); target[i] = target[i] * tau + (class="num">1.0f - tau) * source[i]; } class="type">bool CNeuronBaseOCL::WeightsUpdate(CNeuronBaseOCL *source, class="type">float tau) { if(!OpenCL || !Weights || !source || !source.Weights) class="kw">return false; if(Type() != source.Type()) class="kw">return false; if(Weights.Total() != source.Weights.Total()) class="kw">return false; class="type">uint global_work_offset[class="num">1] = {class="num">0}; class="type">uint global_work_size[class="num">1] = {Weights.Total()}; ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_target, Weights.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return false; } if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_source, source.getWeightsIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return false; } if(!OpenCL.SetArgument(def_k_SoftUpdate, def_k_su_tau, (class="type">float)tau)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return false; } if(!OpenCL.Execute(def_k_SoftUpdate, class="num">1, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); class="kw">return false; } class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CNeuronConvOCL::WeightsUpdate(CNeuronBaseOCL *source, class="type">float tau) { if(!CNeuronBaseOCL::WeightsUpdate(source, tau)) class="kw">return false; CNeuronConvOCL *temp = source; if(WeightsConv.Total() != temp.WeightsConv.Total()) class="kw">return false; class="type">uint global_work_offset[class="num">1] = {class="num">0}; class="type">uint global_work_size[class="num">1] = {WeightsConv.Total()}; ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_target, WeightsConv.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return false; } if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_source, temp.WeightsConv.GetIndex())) {
「跨设备前向传播里的缓冲兜底」
在 CNet::feedForward 的实现里,真正麻烦的不是正常同设备调用,而是 inputNet 或 secondNet 用了另一张 OpenCL 上下文。代码先判断 inputNet.opencl != opencl,若成立就从源网络取出输出缓冲,再走单样本的前向接口,避免直接把外部设备指针塞进当前 kernel。 secondNet 的处理更绕:如果它的 OpenCL 句柄和当前网络不同,会调用 GetLayerOutput 拿到 CBufferFloat,再尝试 BufferCreate 把数据搬进本地设备。一旦创建失败就 delete second 并返 false,这个 del_second 标记就是用来区分「借来的缓冲」和「自己 new 的缓冲」该不该释放。 同上下文的分支反而简单,直接取 secondLayer 第 0 个神经元的 getOutput(),连拷贝都省了。实盘跑神经网络信号时,混用多卡或多上下文加载不同子网络,这类缓冲搬迁失败的概率会随层宽上升而变大,建议在 MT5 策略测试器里故意喂异设备网络测一遍返 false 的触发路径。
if(!OpenCL.SetArgument(def_k_SoftUpdate, def_k_su_tau, (class="type">float)tau)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return false; } if(!OpenCL.Execute(def_k_SoftUpdate, class="num">1, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); class="kw">return false; } class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CNet::feedForward(CNet *inputNet, class="type">int inputLayer=-class="num">1, CNet *secondNet = NULL, class="type">int secondLayer = -class="num">1) { if(!inputNet || !opencl) class="kw">return false; if(inputLayer<class="num">0) inputLayer=inputNet.layers.Total()-class="num">1; CBufferFloat *second = NULL; class="type">bool del_second = false; if(!!secondNet) { if(secondLayer < class="num">0) secondLayer = secondNet.layers.Total() - class="num">1; if(secondNet.GetOpenCL() != opencl) { secondNet.GetLayerOutput(secondLayer, second); if(!!second) { if(!second.BufferCreate(opencl)) { class="kw">delete second; class="kw">return false; } del_second = true; } } else { if(secondNet.layers.Total() <= secondLayer) class="kw">return false; CLayer *layer = secondNet.layers.At(secondLayer); CNeuronBaseOCL *neuron = layer.At(class="num">0); second = neuron.getOutput(); } } if(inputNet.opencl != opencl) { CBufferFloat *inputs; if(!inputNet.GetLayerOutput(inputLayer, inputs)) { if(del_second) class="kw">delete second; class="kw">return false; } class="type">bool result = feedForward(inputs, class="num">1, false, second); if(del_second) class="kw">delete second; class="kw">return result; } CLayer *layer = inputNet.layers.At(inputLayer); if(!layer)