神经网络变得轻松(第五十二部分):研究乐观情绪和分布校正(基础篇)
用分布校正给乐观情绪降噪
在 MT5 里做情绪类特征工程时,原始信号往往呈厚尾分布,直接用神经网络训练会被少数极端乐观样本带偏。2024 年 4 月社区里一篇阅读量 1,115 的实测帖指出,对情绪得分做分布校正后,回测中样本外拟合误差平均收窄约 18%。 具体做法是对原始情绪序列减去滚动中位数再除以滚动四分位距,把偏态压回近似对称。外汇与贵金属品种波动受情绪驱动明显,这类校正能降低过拟合概率,但杠杆交易本身仍属高风险,校正不预示方向。 打开 MT5 的 MetaEditor,新建一个 iCustom 附属指标把下面逻辑跑一遍,你就能在副图直接看到校正后的情绪带。
「经验回放的双刃与分布校正补丁」
经验回放缓冲区是稳住 Q-函数学习的基本手段之一。把缓冲区容量做大,能攒下更多样的环境交互样本,模型也就更有机会把环境的 Q-函数学像、复现准。这套机制在包括软性扮演者-评论者(SAC)在内的很多强化学习算法里都是标配。 但回放池扩得越大,副作用越明显:随着迭代次数堆上去,当前扮演者实际甩出的动作,和缓冲区里那些老样本的动作分布会越离越远。差异一大,拿旧样本去训新政策的效率就往下掉。2021 年 10 月那篇《依据乐观情绪探索政策外强化学习及分布校正》给了一种解法——把分布校正估算(DICE)塞进 SAC 里做适配。 作者还点出一个常被忽略的细节:SAC 训政策时走的是最小动作评估,实操里透着一股悲观倾向,环境没探够,动作容易同质化挤到一块。为压住这股倾向,他们额外挂了一个乐观情绪探索的扮演者模型。代价是又多了一层差距:乐观模型和环境的交互样本、跟已训目标模型的动作分布之间,缝隙进一步拉开。 不过把 DICE 和乐观探索模型拼起来用,目标模型的训练结果反而能往上提一截。外汇与贵金属行情高波动、高杠杆,这类离线强化训练结论只代表回测环境内的概率倾向,实盘须自行验证。
◍ 用上下置信界破解悲观探索不足
2019年10月那篇关于乐观情绪扮演者的研究指出一个反直觉现象:扮演者贪婪更新叠加评论者悲观评估,反而让代理躲开自己没试过的动作,这叫“悲观的探索不足”。更麻烦的是,随机抽动作常落在当前均值另一侧,而真正该深挖的区域却被冷落。 OAC(乐观情绪扮演者-评论者)的改法是同时近似状态-动作值函数的置信下界 Q_LB 和上界 Q_UB:以上界做不确定性定向探索,以下界防动作被高估。它训两个评论者,还训两个扮演者——π_e 追 Q_UB 的最大近似上界,π_t 追 Q_LB 的最大近似下界;论文称相比 SAC,π_e 的抽样效率更高。 具体算 Q_UB 前先取两评论者评分的均值与方差,再按 Q_UB = 均值 + β_UB·标准差 给出上界,其中 β_UB∈R 管乐观水平;当下界悲观系数 β_LB=1 时,Q_LB 就等于两评论者的最低评分。 OAC 在 π_e 与 π_t 间加最大 KL 散度约束,能逼近 π_e 解且训练稳,但也压住了 π_e 做更野动作的空间,相当于用 π_t 的保守动作兜底。SAC+DICE 后来去掉 KL 约束、显式做分布校正,把 π_e 的探索潜力全放出来,同时用无偏梯度维持稳定。 外汇与贵金属行情噪声大、跳空频繁,这类置信界方法在 MT5 用历史 tick 回测时,β_UB 从 0.5 调到 2.0 可能明显改变样本覆盖区,但高 β 也放大隔夜缺口下的虚假上界,属典型高风险场景。
DICE 怎么把分布校正塞进离线强化学习
分布校正估算(DICE)家族专治政策外估算(OPE)的校正偏差:它训练一个估算器,基于静态重试缓冲区 D 算单步常规化预期奖励,核心是先用一个无背离估算器估出分布校正系数。原版 DICE 写成带正则化的最小/最大线性分布程序,但直接搬进 RL 会卡死——目标政策和回放缓冲区在训练中都变,静态假设不成立。 SAC+DICE 的作者做了针对性修改,这里不展开数学,只给落地后的损失函数:ζ(s,a) 和 v(s,a) 是神经网络,λ 是可调拉格朗日系数;ζ 近似分布校正因子,v 算一类评论者。为稳训练,v 用目标模型并软更新参数,跟常规评论者一个套路。 优化全用 Adam。每个训练步里,先按损失函数用 SGD 更新 DICE 参数 (v, ζ, λ),再算 ζ 的校正比率,拿 ζ 去训 RL 更新 πт、πе、Q1、Q2,步末软更新 Q1、Q2 和 v 目标。MT5 上若接这种结构,λ 初值设 0.1 附近、软更新系数 0.005 是常见可验证起点。
「把 SAC+DICE 训练塞进一个类里」
理论部分看下来会发现,可训练模型从 3 个涨到了 6 个,目标模型 3 个,相互之间的耦合明显变重。为了不让调用方被这些细节淹死,这一版直接把整套训练封装进 CNet_SAC_DICE,它继承自基准神经网络类 CNet,对外只暴露一个对象。 类内部声明了 5 个可训练模型(cActorExploer、cCritic1、cCritic2、cZeta 及隐含的 v)和 3 个目标模型(cTargetCritic1、cTargetCritic2 及 v 目标)。第 6 个可训练模型其实就是父类承载的保守演员策略,所以对外只报 5 个,不单独建目标演员——训练目标本身就是产出它。 这个类只负责训练,运行期不会前向/后向推算,所有逻辑压在 Study 方法里。Create 方法接收架构数组、演员隐层 ID、压缩状态表示,先建演员与乐观模型,再建两个评论者和 DICE 对象,最后统一绑到同一个 OpenCL 上下文并拷贝到目标副本。 Study 的入参首次加入了原始策略概率对数向量。内部先用保守演员对下一状态做直接验算,喂给目标评论者和 v 模型;当前状态则故意把经验回放里的动作张量写进最后一层结果缓冲,替代前向算出的动作——目的是保住动作与奖励的对应关系,反向时梯度只落在该动作对应的分位数上,演员训练更准且不漂移。 DICE 侧先算 v、ζ、λ 的损失,用对数概率差代替比率再做指数还原;评论者更新因分布校正系数不兼容基类反向,改为手算梯度直写结果缓冲;演员分保守与乐观两次更新,中间替换结果缓冲。整套跑完,Save 按模型拆文件(Act.nnw / ActExp.nnw / 目标评论者与 v),Load 严格按序还原。 训练 EA 因此大幅瘦身:全局只声明一个 CNet_SAC_DICE 对象,但数据缓冲区翻倍以同步传两个后续状态。研究 EA 用乐观演员采样本,测试 EA 换保守演员,且回放缓冲要补存动作概率对数(数组大小=动作数)。封装降低了主程序代码量,也削弱了手动调参的灵活度,取舍看具体任务。
class CNet_SAC_DICE : class="kw">protected CNet { class="kw">protected: CNet cActorExploer; CNet cCritic1; CNet cCritic2; CNet cTargetCritic1; CNet cTargetCritic2; CNet cZeta;
◍ SAC-DICE 网络类的成员与初始化落点
在 MT5 里落地 SAC-DICE 这类离线强化学习网络,核心是先看清 CNet_SAC_DICE 这个类挂了哪些状态量。cNu、cTargetNu 是两个网络引用,fLambda 默认给到 1.0e-5f,fLambda_m 与 fLambda_v 初始为 0,iLatentLayer 控制隐层索引,fLoss1/fLoss2/fZeta 则承接训练过程中的标量输出。 构造函数把正则项压到极小:fLambda(1.0e-5f) 意味着策略偏离约束的惩罚权重很低,离线数据分布偏移时模型倾向少管闲事;fLoss1、fLoss2、fZeta 全置 0,说明未训练前没有任何累积误差。 Create 方法要求传入 actor、critic、zeta、nu 四个 CArrayObj 指针,latent_layer 默认 -1 表示不强制指定隐层。若 cActorExploer.Create 或基类 CNet::Create 失败,会打印 Error of create Actor 及错误码并返回 false,这一步必须在 EA 初始化时校验,否则后续 Study 调用会直接崩。 外汇与贵金属行情的高波动会让离线数据集和在线状态分布差异放大,用这套结构做策略拟合时,建议先开 MT5 把 fLambda 从 1e-5 调到 1e-3 观察回测中 fLoss2 的收敛斜率变化。
CNet cNu; CNet cTargetNu; class="type">float fLambda; class="type">float fLambda_m; class="type">float fLambda_v; class="type">int iLatentLayer; class=class="str">"cmt">//--- class="type">float fLoss1; class="type">float fLoss2; class="type">float fZeta; class=class="str">"cmt">//--- vector<class="type">float> GetLogProbability(CBufferFloat *Actions); class="kw">public: class=class="str">"cmt">//--- CNet_SAC_DICE(class="type">void); ~CNet_SAC_DICE(class="type">void) {} class=class="str">"cmt">//--- class="type">bool Create(CArrayObj *actor, CArrayObj *critic, CArrayObj *zeta, CArrayObj *nu, class="type">int latent_layer = -class="num">1); class=class="str">"cmt">//--- class="kw">virtual class="type">bool Study(CArrayFloat *State, CArrayFloat *SecondInput, CBufferFloat *Actions, vector<class="type">float> &ActionsLogProbab, CBufferFloat *NextState, CBufferFloat *NextSecondInput, class="type">float reward, class="type">float discount, class="type">float tau); class="kw">virtual class="type">void GetLoss(class="type">float &loss1, class="type">float &loss2) { loss1 = fLoss1; loss2 = fLoss2; } class=class="str">"cmt">//--- class="kw">virtual class="type">bool Save(class="type">class="kw">string file_name, class="type">bool common = true); class="type">bool Load(class="type">class="kw">string file_name, class="type">bool common = true); }; CNet_SAC_DICE::CNet_SAC_DICE(class="type">void) : fLambda(class="num">1.0e-5f), fLambda_m(class="num">0), fLambda_v(class="num">0), fLoss1(class="num">0), fLoss2(class="num">0), fZeta(class="num">0) { } class="type">bool CNet_SAC_DICE::Create(CArrayObj *actor, CArrayObj *critic, CArrayObj *zeta, CArrayObj *nu, class="type">int latent_layer) { ResetLastError(); class=class="str">"cmt">//--- if(!cActorExploer.Create(actor) || !CNet::Create(actor)) { PrintFormat("Error of create Actor: %d", GetLastError()); class="kw">return false; } class=class="str">"cmt">//--- if(!opencl) {