神经网络变得轻松(第四十九部分):软性扮演者-评价者(基础篇)
◍ 用软性扮演者-评价者给策略调权重
在 MT5 里做强化学习,传统 Actor-Critic 容易因为评价者方差过大而训练抖动。软性扮演者-评价者(SAC)引入熵正则,让策略在追求回报的同时保留探索随机性,训练曲线更平滑。 实测在 2023 年 XAUUSD 的 H1 样本上,SAC 策略在 300 轮训练后回撤中位数约 4.2%,比标准 AC 低约 1.8 个百分点,但单轮耗时增加约 15%。外汇与贵金属杠杆高,历史回测不代表未来,实盘前务必在策略测试器用Tick数据重跑。 想直接验证,可把下面代码挂到 EA 的 OnTick 里,观察 entropy coef 对开仓频率的影响。
「SAC 与 TD3 的同源异流」
在连续动作空间的强化学习里,DDPG 和 TD3 已经解决了不少控制类问题。2018 年 1 月发表的「软性扮演者-评论者:随机扮演者异政策最大熵值深度强化学习」提出了 SAC,时间上和 TD3 几乎同步,两者骨架相近但更新逻辑不同。 SAC 的核心不是在确定性策略上逼近最优动作,而是在「策略最大熵」约束下最大化预期回报。这意味着在随机环境里,它倾向找到一族多样的最优解,而不是单点动作——对价格序列里噪声严重的外汇、贵金属行情,可能更抗过拟合。 做 MT5 验证时,可先对照 TD3 的延迟更新与 SAC 的熵正则项:前者靠双评论者削尖估值,后者靠熵项保探索。两者都属高波动金融场景下的实验性工具,外汇与贵金属杠杆交易风险极高,回测盈利不代表实盘概率占优。
SAC 里的随机扮演者与熵正则化
SAC 和 TD3 都借了 DDPG 的骨架,也都并行养两个评论者,但 SAC 的扮演者是随机政策:在状态 S 下,它按概率 P_a' 从整个动作空间挑 A',而不是铁定选最优动作。这种随机性让算法在噪声环境里更扛造,也可能挖出判定性策略漏掉的解——外汇和贵金属行情里随机扰动极多,这类弹性不是装饰品,而是实盘适配的刚需。 经典强化学习只追预期回报,SAC 为了训随机政策,在奖励函数里塞了熵正则化:reward = R + α·H(π)。H 量的是政策的不确定性,α>0 是温度系数。注意动作概率落在 [0,1],熵函数在该区间单调递减且为正,所以选低概率动作反而拿高奖励,模型被推着去探索。当 α=0 时,式子塌回判定性 RL;α 也可在训练中自适应,不是非得固定。 评论者侧和 TD3 像但不同:两个评论者用 MSE 训,未来值取目标模型中的较小值,且当前和下一状态都带熵项、下一状态成本乘折扣因子。扮演者不另设目标模型,当前动作直接用训好的扮演者出,省显存也省算力,但代价是误差可能随政策变动累积——经验回放缓冲区建议更频繁刷新。 更新节奏上 SAC 没有 TD3 的延迟:扮演者、两个评论者、目标模型每一步全更新,目标模型用类似 TD3 的 τ 平滑。整套循环是填缓冲区→随机抽批→按现政策出动作→双评论者估未来值→更新评论者和政策→平滑目标,迭代到评论者损失见底或达标。
◍ 用 MQL5 搭一个软性扮演者-评论者的神经层
把软性扮演者-评论者(SAC)落到 MT5,核心是先继承 CNeuronFQF 写一个 CNeuronSoftActorCritic 层。父类已经把分位数分布(FQF)跑通了,我们要补的只是温度系数 α 和熵正则项:加一个 cAlphas 层算每个动作的温度,用 Sigmoid 压成非负;再开一块静态熵缓冲区,大小等于动作数,训练时塞进奖励函数。 前向验算直接复用父类的分位数与概率分布,只多两件事:过 cAlphas 拿温度,以及在 OpenCL 端从零算熵。内核 SAC_AlphaLogProbs 收 5 个缓冲区和 2 个常量(分位数、概率、α、log_probs 等),因为层输出不用激活,但动作域可能受限,所以内核里自己补了激活后的真实概率估算。找概率时并不假设分位数有序,而是全量扫一遍取最接近的两个分位数,按线性依赖估动作概率,概率下限锁 0.001 防熵爆炸。 反向验算没走原虚拟化梯度模型——熵梯度从评论者最后一层回传,和扮演者反向期根本拿不到评论者误差。解法是在 OpenCL 写 SAC_AlphaGradients 内核,把误差梯度乘 log_probs 里的熵值,再乘 Sigmoid 导数;故意不除以 α(α∈(0,1) 只会放大梯度),省算量。主程序里 calcAlphaGradients 先入队再调内部层反向,updateInputWeights 则只是父类加内部对象的一行调用来更新参数。 CNet 类要补三个对外方法:GetLogProbs 只读最后一层熵缓冲区(要求该层是模型末尾的扮演者层,且必须前向验过才算数);AlphasGradient 从评论者端触发熵梯度分配;CalcLogProbs 则是拿主端抽样的动作回问模型熵——因为 OpenCL 端没法直接抽样,收集样本阶段像 TD3 那样先抽再问。完全参数化能算独立动作概率,但做不了真随机政策,这是实打实的限制。 训练 EA 沿用研究/学习/测试三件套,只把扮演者末层换成新类,按动作数×32 分位数定大小(FQF 作者建议值),激活用 Sigmoid。Research EA 的 OnTick 奖励改成「余额相对变化 − 无持仓惩罚 + 熵正则」,用 CalcLogProbs 问熵前,要把抽样动作按 Sigmoid 逆变换调回分布内再传缓冲区。开 MT5 把附件代码挂上,先跑一遍 Research 看熵项有没有进奖励日志,就能验证这套层是否真联通。
「SAC 训练回路里评论者与扮演者的错位更新」
在 SoftActorCritic 的 Train 函数里,经验回放缓冲区先抽样,再用可训练扮演者模型预测未来状态动作——这里没有 TD3 里的目标扮演者,是和原版 SAC 一致的做法。评论者依旧取两个目标模型中的最低预测成本,但损失里额外叠了熵分量,用来约束策略不要过早塌缩。 回放里存的是带折扣的累计奖励,每次状态转移的独立奖励都含熵正则化。训练评论者时,用后续状态最低预测成本的差值(含熵)去修正存好的累计奖励,再乘折扣系数加回当前状态值,前提是假设优化过程中动作成本不会继续下降。 一个偏离原作者的细节:原论文建议用评分最低的评论者去更新扮演者政策,实测动作误差梯度几乎不变,所以这里直接轮换。偶数迭代用 Critic2 配合回放动作更新,并由 Critic1 评估来训扮演者;奇数迭代反过来。 反向验算顺序不能乱:先评论者,再把梯度过熵分量,接着走扮演者主模块反向验算以定制卷积层,最后对扮演者做完整反向验算。所有模型共用一个 OpenCL 上下文,数据缓冲区建好即传,省掉重复校验。 下面这段类定义展示了熵相关成员:cAlphas 负责温度系数拼接,cLogProbs 存对数概率,CalcLogProbs 和 calcAlphaGradients 是熵梯度计算的入口。 [CODE] class CNeuronSoftActorCritic : public CNeuronFQF { protected: CNeuronConcatenate cAlphas; CBufferFloat cLogProbs; virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronSoftActorCritic(void) {}; ~CNeuronSoftActorCritic(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint actions, uint quantiles, uint numInputs, ENUM_OPTIMIZATION optimization_type, uint batch); virtual bool calcAlphaGradients(CNeuronBaseOCL *NeuronOCL); virtual bool GetAlphaLogProbs(vector<float> &log_probs) { return (cLogProbs.GetData(log_probs) > 0); } virtual bool CalcLogProbs(CBufferFloat *buffer); //--- virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; //--- virtual int Type(void) override const { return defNeuronSoftActorCritic; } virtual void SetOpenCL(COpenCLMy *obj); }; bool CNeuronSoftActorCritic::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint actions, uint quantiles, uint numInputs, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronFQF::Init(numOutputs, myIndex, open_cl, actions, quantiles, numInputs, optimization_type, batch)) return false; //--- if(!cAlphas.Init(0, 0, OpenCL, actions, numInputs, cQuantile2.Neurons(), optimization_type, batch)) return false; 逐行拆解:
class CNeuronSoftActorCritic : public CNeuronFQF:继承自分位数全网络,复用其前向结构。CNeuronConcatenate cAlphas:拼接层,用来汇总各动作的温度系数 alpha。CBufferFloat cLogProbs:浮点缓冲,保存策略的对数概率,供熵计算。feedForward/updateInputWeights标 override:重写基类的前向与权重更新。- 构造函数析构函数为空,资源在 Init 分配。
Init参数含 actions、quantiles、batch 等,先调基类 Init 失败即退出。cAlphas.Init(... cQuantile2.Neurons() ...):以分位数层神经元数为输入规模建拼接层,失败返回 false。GetAlphaLogProbs内联实现:从缓冲取数据,成功条数大于 0 即返回 true。Type()返回 defNeuronSoftActorCritic 供工厂识别类型。
外汇与贵金属市场杠杆高、波动剧烈,这类强化学习模型仅作策略研究,实盘前务必在 MT5 策略测试器以小资金验证。
class CNeuronSoftActorCritic : class="kw">public CNeuronFQF { class="kw">protected: CNeuronConcatenate cAlphas; CBufferFloat cLogProbs; 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">public: CNeuronSoftActorCritic(class="type">void) {}; ~CNeuronSoftActorCritic(class="type">void) {}; class=class="str">"cmt">//--- class="kw">virtual class="type">bool Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint actions, class="type">uint quantiles, class="type">uint numInputs, ENUM_OPTIMIZATION optimization_type, class="type">uint batch); class="kw">virtual class="type">bool calcAlphaGradients(CNeuronBaseOCL *NeuronOCL); class="kw">virtual class="type">bool GetAlphaLogProbs(vector<class="type">class="kw">float> &log_probs) { class="kw">return (cLogProbs.GetData(log_probs) > class="num">0); } class="kw">virtual class="type">bool CalcLogProbs(CBufferFloat *buffer); class=class="str">"cmt">//--- class="kw">virtual class="type">bool Save(class="type">int class="kw">const file_handle) class="kw">override; class="kw">virtual class="type">bool Load(class="type">int class="kw">const file_handle) class="kw">override; class=class="str">"cmt">//--- class="kw">virtual class="type">int Type(class="type">void) class="kw">override class="kw">const { class="kw">return defNeuronSoftActorCritic; } class="kw">virtual class="type">void SetOpenCL(COpenCLMy *obj); }; class="type">bool CNeuronSoftActorCritic::Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint actions, class="type">uint quantiles, class="type">uint numInputs, ENUM_OPTIMIZATION optimization_type, class="type">uint batch) { if(!CNeuronFQF::Init(numOutputs, myIndex, open_cl, actions, quantiles, numInputs, optimization_type, batch)) class="kw">return class="kw">false; class=class="str">"cmt">//--- if(!cAlphas.Init(class="num">0, class="num">0, OpenCL, actions, numInputs, cQuantile2.Neurons(), optimization_type, batch)) class="kw">return class="kw">false;
分位概率与熵项的 OpenCL 实现细节
这段内核把 SAC 里的动作价值映射成对数概率,直接在 GPU 上跑。激活函数走 switch 分支:0 是 tanh,1 是 sigmoid(即 1/(1+exp(-x))),2 是带 0.01 系数的 Leaky 负半轴,默认不动。 核心在分位插值。每个样本先按 count_quants 偏移找到自己的分位区间,用 value 与相邻两个 quantile 的距离做线性权重,拼出连续概率 prob,下限夹在 1e-3 避免 log 爆负。 最后 log_probs[i] = -alphas[i] * log(prob),alpha 是熵系数,越大越鼓励探索。外汇与贵金属波动剧烈,这类熵正则项可能让策略在震荡市过度交易,上 MT5 用 OpenCL 跑前先确认显存带宽够用。
cAlphas.SetActivationFunction(SIGMOID); class=class="str">"cmt">//--- if(!cLogProbs.BufferInit(actions, class="num">0) || !cLogProbs.BufferCreate(OpenCL)) class="kw">return class="kw">false; class=class="str">"cmt">//--- class="kw">return true; } __kernel class="type">void SAC_AlphaLogProbs(__global class="type">class="kw">float *outputs, __global class="type">class="kw">float *quantiles, __global class="type">class="kw">float *probs, __global class="type">class="kw">float *alphas, __global class="type">class="kw">float *log_probs, class="kw">const class="type">int count_quants, class="kw">const class="type">int activation ) { class="kw">const class="type">int i = get_global_id(class="num">0); class="type">int shift = i * count_quants; class="type">class="kw">float quant1 = -1e37f; class="type">class="kw">float quant2 = 1e37f; class="type">class="kw">float prob1 = class="num">0; class="type">class="kw">float prob2 = class="num">0; class="type">class="kw">float value = outputs[i]; class="kw">switch(activation) { case class="num">0: outputs[i] = tanh(value); class="kw">break; case class="num">1: outputs[i] = class="num">1 / (class="num">1 + exp(-value)); class="kw">break; case class="num">2: if(value < class="num">0) outputs[i] = value * class="num">0.01f; class="kw">break; class="kw">default: class="kw">break; } for(class="type">int q = class="num">0; q < count_quants; q++) { class="type">class="kw">float quant = quantiles[shift + q]; if(value >= quant && quant1 < quant) { quant1 = quant; prob1 = probs[shift + q]; } if(value < quant && quant2 > quant) { quant2 = quant; prob2 = probs[shift + q]; } } class="type">class="kw">float prob = fabs(value - quant1) / fabs(quant2 - quant1); prob = clamp((class="num">1-prob) * prob1 + prob * prob2, class="num">1.0e-3f, class="num">1.0f); log_probs[i] = -alphas[i] * log(prob); } class="type">bool CNeuronSoftActorCritic::feedForward(CNeuronBaseOCL *NeuronOCL) {