神经网络变得简单(第 64 部分):保守加权行为克隆(CWBC)方法(基础篇)
「保守加权行为克隆在 MT5 里的落地思路」
保守加权行为克隆(CWBC)把专家策略的历史动作当成标签,但给那些「离当前策略分布太远」的样本压低权重,避免模型盲目模仿极端单子。相比普通行为克隆,它在外汇与贵金属这种高杠杆、高噪声环境里,倾向降低过拟合到异常行情的概率。
在 MT5 中验证这套逻辑,核心是先算出每个训练样本与基线的偏离度,再用偏离度倒数做样本权重。下面这段给出最小可跑的权重计算骨架,复制到脚本里改 expertAction[] 即可在策略测试器里打印权重分布。
外汇和贵金属波动受宏观事件驱动,CWBC 只降低复制偏差,不消除爆仓风险,实盘前务必用历史数据做样本外回测。
class="type">class="kw">double CWBCWeight(class="type">class="kw">double expertAction[], class="type">class="kw">double baseMean, class="type">class="kw">double baseStd, class="type">int i) { class="type">class="kw">double diff = expertAction[i] - baseMean; class=class="str">"cmt">// 当前样本与基线均值偏离 class="type">class="kw">double z = diff / baseStd; class=class="str">"cmt">// 标准化偏离度 class="type">class="kw">double w = class="num">1.0 / (class="num">1.0 + MathAbs(z)); class=class="str">"cmt">// 偏离越大权重越小 class="kw">return w; class=class="str">"cmt">// 返回保守权重 }
行为克隆在连续空间里的先天盲区
近期几篇关于决策转换器的讨论,本质上都落在行为克隆(BC)框架内:拿环境状态和目标结果去逼模型复刻“专家”轨迹里的动作。问题在于,真实市场里不同专家对同一个状态的解读常常南辕北辙,甚至我们之前的训练集压根没请专家——靠采样智能体动作再挑出最佳轨迹,而那些轨迹本身往往谈不上最优。 在外汇与贵金属这类连续动作、连续场景的空间里,穷举采样所有轨迹是不可能的。实际能采到的、部分满足需求的片段极少,大部分更像训练时可被直接丢掉的异常值。这类品种杠杆高、跳空频繁,用次优轨迹教模型,过拟合新状态的概率明显放大。 我们用的“出去探索”是用小片段拼出一条成功路径,轨迹只是次优、接近预期但最优性未实证。手工按历史标最优轨迹则滑向监督学习,优点缺点一并继承;而只喂最优样本会让模型在理想条件里打转,学了训练路线却泛化不到新行情。 BC 另一层麻烦是给模型设目标(在途回报 RTG)。此前提过用系数把训练集结果最大化,对静态问题有效,但每任务得单独调参;控制二分法是其替代思路之一。上述短板在《离线强化学习的行为克隆可靠条件》中被点名,作者给出的保守加权行为克隆(CWBC)不限于决策转换器家族,后面可拆。
◍ 离线强化学习里模型靠不靠谱的两个命门
想搞清楚哪些因素会拖垮依赖目标奖励(RTG)的离线强化学习可靠性,有研究专门跑了两组说明性实验。第一组在回报水平从近乎随机、次优到专家级的轨迹数据集上,测了不同架构模型:结果显示模型可靠性高度绑定训练数据品质。用平均和专家回报轨迹训出来的模型,在较高 RTG 条件下结果可靠;而用低分轨迹训的,RTG 越过某个临界点后性能迅速衰减——低质数据根本喂不出能按高奖励条件行动的策略。 数据品质不是唯一变量,架构同样关键。实验里 Decision Transformer(DT)在三个数据集上都稳,推测是因为它的注意力层能直接忽略掉训练分布之外的 RTG 标记,靠状态+RTG 序列做动作预测依然准。反观 MLP 架构,当前状态和 RTG 是拼在一起送进网络的,想忽略所需奖励都做不到。作者把 DT 改成每步都把环境和 RTG 向量串联的版本来验证:RTG 一出训练分布,可靠性立刻崩,假设坐实。 为了压住这两类风险,作者抛出 CWBC(保守加权行为克隆)框架,思路不复杂但能实打实提可靠性。它分两块:轨迹加权把高回报轨迹提权,把次优分布往最优估值的分布上拽;保守性 RTG 正则化逼着策略别乱跑出原始数据分布。 轨迹加权这块,核心是把训练样本重排成更盯高回报的新分布。直接删低回报轨迹会废掉大部分数据,所以按回报加权:λ 和 k 是形状超参。k 控制加权陡峭度,越小高回报权重越大;作者建议 k 取训练集最大值与第 z 百分位之差,实测 z 从 {99,90,50,0} 里选,前三个小 k 表现都好,z=0 的大 k 反而拖垮专家集。λ=0 时分布集中高回报,λ 涨上去慢慢回摆但指数项仍偏向高回报,不同 λ 实测都比原始数据训练强或持平。 保守性正则化则不靠架构保命,改从损失函数下手。思路是给高回报轨迹的 RTG 加正噪声、并惩罚预测动作与真值的 L2 距离,逼模型在分布外 RTG 时仍贴着分布内动作走。噪声调成让调整后 RTG 不低于训练集最高回报,且只对超过第 q 百分位回报的轨迹用——实验表明 q=95 在多数环境通吃。CWBC 把加权与正则拼起来,两路优势叠加。
「用 MQL5 搭两套并行训练模型」
把保守加权行为克隆(CWBC)落到 MT5,核心是并行训两个独立模型:一个决策转换器预测动作,另一个估算 RTG(回报到go)所代表的环境状态成本。作者在原文中声称,这种带轨迹加权和保守性正则化的方案,能把决策转换器的训练效率平均拉高 8%——这个数字直接在策略测试器里复跑就能验证。 架构拆分在 CreateDescriptions 里完成。单步输入由 5 块拼成:价格走势历史+指标读数、账户与持仓、时间戳、智能体上一步动作、RTG。数据先过批量归一化,再用嵌入层压到同一 N 维空间;嵌入层会保留历史深度,新数据持续追加进序列。随后 SoftMax 逐嵌入归一,过关注度模块,再经两个卷积层把维度砍半并搜稳定形态,最后全连接层吐出预测动作。 RTG 成本模型在 CreateRTGDescriptions 中单独定义,喂若干根柱线的价格变化与指标读数。它不累积历史,所以不用嵌入层,直接卷积+SoftMax 做逐柱嵌入,后面流程与前一个模型类似,但输出端接变分自编码器来模仿环境随机性。 训练主循环在 StudyAgent.mq5 的 Train 方法:GetProbTrajectories 先算轨迹累积概率(含分位数与标准差),SampleTrajectory 按累积概率抽轨迹;嵌套循环严格按时间序喂数据,保守性正则化只对高回报轨迹加噪。前馈出动作向量后反向传播最小化预测误差,进度写图表注释,跑完清屏并关 EA。下游用 Research.mq5 在置信区间内执行动作、回收经验,迭代精修政策。外汇与贵金属杠杆高,回测增益不等于实盘表现,开 MT5 挂附件 EA 前先小样本验一遍。
class="type">bool CreateDescriptions(CArrayObj *agent) { class=class="str">"cmt">//--- CLayerDescription *descr; class=class="str">"cmt">//--- if(!agent) { agent = new CArrayObj(); if(!agent) class="kw">return false; } class=class="str">"cmt">//--- Agent agent.Clear(); class=class="str">"cmt">//--- Input layer if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; class="type">int prev_count = descr.count = (BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions + NRewards); descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr;
强化学习智能体的七层网络装配
在 MT5 里用 OpenCL 后端搭 RL 智能体,网络结构靠 CLayerDescription 逐层 push 进 agent 对象。任何一层 new 失败或 Add 返回 false,都必须 delete 描述符并回退,否则显存描述符会泄漏。 第一层先挂 BatchNorm,type 设 defNeuronBatchNormOCL,count 沿用上层神经元数 prev_count,batch 写死 1000,激活给 None,优化器 ADAM。这一层只做输入归一,不引入非线性。 第二层是 Embedding,type 用 defNeuronEmbeddingOCL,count 设为 HistoryBars;windows 数组按 {BarDescr*NBarInPattern, AccountDescr, TimeDescription, NActions, NRewards} 拷入,window_out 即 EmbeddingSize。后续层都吃这个嵌入维度。 第三到七层依次叠 SoftMax、多层注意力(MLMHAttention,step=8、window_out=32、layers=4)、两层 Conv(激活 LReLU,第二层 window_out 折半)、末层 SoftMax。注意第四层 count 放大为 prev_count*5,步长乘 5,显存占用会跳一截。 跑之前把 HistoryBars、EmbeddingSize、NBarInPattern 这些宏在 mqh 里定清楚,否则 ArrayCopy 长度对不上会直接编译挂。外汇与贵金属行情高波动,这类模型过拟合概率偏高,上实盘前先用历史数据回测验证。
class="kw">return false; } class=class="str">"cmt">//--- layer class="num">1 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = class="num">1000; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">2 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronEmbeddingOCL; prev_count = descr.count = HistoryBars; { class="type">int temp[] = {BarDescr * NBarInPattern, AccountDescr, TimeDescription, NActions, NRewards}; ArrayCopy(descr.windows, temp); } class="type">int prev_wout = descr.window_out = EmbeddingSize; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">3 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronSoftMaxOCL; descr.count = EmbeddingSize; descr.step = prev_count * class="num">5; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">4 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronMLMHAttentionOCL; prev_count = descr.count = prev_count * class="num">5; descr.window = EmbeddingSize; descr.step = class="num">8; descr.window_out = class="num">32; descr.layers = class="num">4; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">5 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = EmbeddingSize; descr.step = EmbeddingSize; prev_wout = descr.window_out = EmbeddingSize; descr.optimization = ADAM; descr.activation = LReLU; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">6 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = prev_wout; descr.step = prev_wout; prev_wout = descr.window_out = prev_wout / class="num">2; descr.optimization = ADAM; descr.activation = LReLU; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">7 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronSoftMaxOCL; descr.count = prev_count; descr.step = prev_wout; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) {
◍ 堆叠隐藏层与 RTG 网络的构建收尾
上面这段把智能体(agent)的第 8 到第 11 层一次性铺开:8、9、10 层都是 defNeuronBaseOCL 类型,节点数取 LatentCount,激活函数统一用 LReLU,优化器走 ADAM;第 11 层把节点数换成 NActions,激活改为 SIGMOID,用来输出动作概率。每一层都先 new 一个 CLayerDescription,失败就 delete 并 return false,只有 agent.Add(descr) 成功才继续,这种写法在 MT5 里跑强化学习模型时基本是防内存泄漏的标配。 RTG 网络这边另起 CreateRTGDescriptions 函数,入参 rtg 为空就现 new 一个 CArrayObj 并清空。输入层节点数直接算成 ValueBars * BarDescr,激活设 None;紧接着第 1 层塞了一个 defNeuronBatchNormOCL,batch 写死 1000,做批量归一化。 第 2 层换成 defNeuronConvOCL 卷积层,节点数按 (prev_count + BarDescr - 1) / BarDescr 向下取整,window 和 step 都等于 BarDescr,window_out 记到 EmbeddingSize。你在 MT5 里改 EmbeddingSize 或 BarDescr 任意一个,卷积层输出维度会立刻跟着变,回测时特征抽取粒度也就不同了。外汇与贵金属杠杆高,这类模型信号仅作概率参考,实盘前务必用历史数据验证稳定性。
class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">8 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">9 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">10 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">11 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; descr.count = NActions; descr.activation = SIGMOID; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CreateRTGDescriptions(CArrayObj *rtg) { class=class="str">"cmt">//--- CLayerDescription *descr; class=class="str">"cmt">//--- if(!rtg) { rtg = new CArrayObj(); if(!rtg) class="kw">return false; } class=class="str">"cmt">//--- RTG rtg.Clear(); class=class="str">"cmt">//--- Input layer if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; class="type">int prev_count = descr.count = ValueBars * BarDescr; descr.activation = None; descr.optimization = ADAM; if(!rtg.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">1 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = class="num">1000; descr.activation = None; descr.optimization = ADAM; if(!rtg.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">2 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronConvOCL; prev_count = descr.count = (prev_count + BarDescr - class="num">1) / BarDescr; descr.window = BarDescr; descr.step = BarDescr; class="type">int prev_wout = descr.window_out = EmbeddingSize; descr.optimization = ADAM;