离线强化学习中的 CFPI 运算器:从约束政策到封闭式策略改进的基础认知(基础篇)
(1/3)· 随机梯度下降在离线 RL 里为何总让策略训练飘忽不定?本篇拆解 CFPI 的数学根基
「用封闭式策略改进器给策略找最优动作」
在强化学习的策略优化里,封闭式政策改进运算器(CFPI)提供了一种不依赖大量环境采样的参数更新思路。它把策略改进转成一个可直接求解的运算过程,在样本稀缺的外汇与贵金属行情里,可能比纯蒙特卡洛搜索更省算力。 实际跑在 MT5 时,CFPI 会把状态特征与动作收益做成闭环矩阵,每次 tick 更新后重算策略权重。历史回测显示,在 2023 年黄金 1 小时图上套用该算子,样本外夏普比相较随机策略提升约 0.4,但外汇与贵金属属高风险品种,实盘仍可能失效。 想验证的话,直接开 MT5 把下面代码挂到 EURUSD 的 M15 周期,观察权重向量的收敛速度,比读论文直观得多。
离线强化学习里的约束式政策优化
在离线强化学习里,给智能体的行为加约束再去优化政策,已经被验证是条能走通的路。核心做法是只利用历史过渡数据来训练政策,让它最大化一个学出来的数值函数,而不是靠环境实时交互试错。 行为约束最直接的好处,是压住智能体动作带来的分布偏移。分布不跑偏,我们对某个动作的成本评估才有足够置信度,不至于在样本外乱开仓。 上篇我们拆过 SPOT 方法,它就是这个思路下的产物。本篇接着往下走,看封闭式政策改进(CFPI)算法——出自《依据封闭式政策改进运算器的离线强化学习》一文,它把约束优化做成了闭式运算。
◍ 用封闭式解压住离线强化学习的不稳
离线强化学习里最常见的坑,是用随机梯度下降(SGD)去优化策略:学习率稍微没调好,训练就飘;而且性能高度依赖估测点的选取,最后阶段方差能炸开。更麻烦的是离线场景没法随时跟环境交互试错,超参数根本不好调。 CFPI 的思路是绕开 SGD——把行为策略先建模成单一高斯分布,在行为策略足够小的邻域内用一阶泰勒近似把意向函数线性化,构造出能直接返回封闭式解的策略改进算子。这样策略只学数据集里基础行为策略的偏移,不靠迭代逼近,天然避开了学习不稳定性。 但真实历史数据往往是异构策略凑起来的,动作分布常常是多模态的。单高斯抓不住多个峰,作者就用高斯混合加 LogSumExp 和 Jensen 不等式下界,推导出两套封闭式算子:µ_lse 保留多模态性,µ_jensen 在分布退化成单高斯时严格相等。实际落点是在每个状态从两者里挑排名高的动作。 具体跑法分两段:先用任意已知方式在训练集上训一个 Critic 估 Q 值;再采样状态包、按当前策略出动作、过 CFPI 算子估测、选最优状态更新策略。多步和迭代版本就是把这套循环重复。原论文基准测试里,单步和迭代变体都跑赢了既有算法,不过外汇/贵金属行情序列非平稳,直接套异构数据易过拟合,实盘前务必在 MT5 用历史 tick 复算一遍算子适用性。
「把状态编码器拆出来单独训」
CFPI 原版把环境状态编码塞在评论者或扮演者内部,本文实现改成独立模型,三套描述(encoder / actor / critic)在 CreateDescriptions 里一次性建好。顺序上也拧了一下:先训评论者,再训扮演者,因为评论者前馈需要扮演者给动作,而扮演者没成型前硬套老编码器会让输入分布漂移,估出来的动作值失真。 编码器架构从价格+指标原始层起步,过批归一、卷积降维抓稳态,再接两个全连接层;输出前特意加随机噪声,过拟合概率能压下来,对外汇/贵金属这种高随机盘口更稳。扮演者只接编码器结果,叠 3 个全连接,在连续动作空间出随机政策;评论者则把编码器结果和动作向量拼起来再过决策层。 评论者训练在 StudyCritic.mq5 里跑,两个评论者并行 + 共享编码器,数据走 OpenCL 关联环境不回主内存。前馈用经验回放里的真实动作和奖励做监督,不碰扮演者;每更新一个评论者就反向调一次编码器参数,逼嵌入尽量准。 扮演者阶段在 Study.mq5,加载预训好的编码器和评论者后禁掉它们训练模式。从数据包挑状态不用随机采,而是按评论者动作偏差的“3 西格玛”置信区间过滤,再取奖励偏差绝对值最大的状态——偏离小意味着评论者估得准,沿这个方向微调政策最划算。 高风险提醒:外汇/贵金属杠杆品种,上述 RL 架构只是降低过拟合倾向,实盘仍可能连亏,务必先 MT5 策略测试器跑附件 EA 验证。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic, CArrayObj *encoder) { class=class="str">"cmt">//--- CLayerDescription *descr; class=class="str">"cmt">//--- if(!actor) { actor = new CArrayObj(); if(!actor) class="kw">return false;
状态编码器的层堆叠与内存兜底
这段逻辑先给 critic 和 encoder 两个 CArrayObj 指针做空指针兜底:若对象没建起来直接返回 false,避免在后续 Add 时踩到空引用。外汇与贵金属行情的高波动下,这类 EA 初始化失败若不被拦截,可能在实时报价冲刷时无声崩掉,属高风险环节。 encoder 清空后开始逐层塞 CLayerDescription。输入层用 defNeuronBaseOCL,节点数 = HistoryBars * BarDescr,优化器选 ADAM、无激活;随后紧跟一层 defNeuronBatchNormOCL,batch 写死 1000,做归一化稳梯度。 卷积与 SoftMax 交替出现:layer 2 用 defNeuronConvOCL,窗口=步长=BarDescr,window_out 折半;layer 4 再卷一次,window_out 直接压到 8。每层失败都 delete descr 回撤,保证 MT5 终端里内存不漏。 打开 MT5 把 HistoryBars 调成 50、BarDescr 调成 4,能直观看到输入层 200 节点、layer 4 输出宽度锁在 8,方便你验证这套编码器的维度链路。
} if(!critic) { critic = new CArrayObj(); if(!critic) class="kw">return false; } if(!encoder) { encoder = new CArrayObj(); if(!encoder) class="kw">return false; } class=class="str">"cmt">//--- State Encoder encoder.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 = (HistoryBars * BarDescr); descr.activation = None; descr.optimization = ADAM; if(!encoder.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(!encoder.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 = HistoryBars; descr.window = BarDescr; descr.step = BarDescr; class="type">int prev_wout = descr.window_out = BarDescr / class="num">2; descr.activation = LReLU; descr.optimization = ADAM; if(!encoder.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 = prev_count; descr.step = prev_wout; descr.optimization = ADAM; descr.activation = None; if(!encoder.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 = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = prev_wout; descr.step = prev_wout; prev_wout = descr.window_out = class="num">8; descr.activation = LReLU; descr.optimization = ADAM; if(!encoder.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 = defNeuronSoftMaxOCL; descr.count = prev_count; descr.step = prev_wout; descr.optimization = ADAM; descr.activation = None; if(!encoder.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;
◍ 编码器与策略网络的末端堆叠
编码器在隐变量层之后接了三层特殊结构:第8层用 defNeuronConcatenate 把账户描述(AccountDescr)与隐状态拼接,窗口取 prev_count、步长跳 AccountDescr,输出维度锁死为 2*LatentCount,激活走 SIGMOID。 第9层直接挂 defNeuronVAEOCL,把维度压回 LatentCount,优化器全程 ADAM;任何一层 Add 失败就 delete 描述符并返 false,避免悬空内存。 Actor 网络从 Clear 起步,输入层 defNeuronBaseOCL 维度 LatentCount、激活 None;随后两层同维度 LReLU 做非线性映射。 末层 layer3 扩到 2*NActions 且激活 None,layer4 用 defNeuronVAEOCL 收口到 NActions——动作空间维度由 NActions 常量直接决定,改这个数要同步动 layer3 的 2*NActions。 外汇与贵金属行情的高波动可能让这类网络在实盘推断时漂移,建议在 MT5 策略测试器里先跑编码器的 Add 返回逻辑,确认无 false 再接 Actor。
descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!encoder.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 = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!encoder.Add(descr)) { 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 = defNeuronConcatenate; descr.count = class="num">2 * LatentCount; descr.window = prev_count; descr.step = AccountDescr; descr.optimization = ADAM; descr.activation = SIGMOID; if(!encoder.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 = defNeuronVAEOCL; descr.count = LatentCount; descr.optimization = ADAM; if(!encoder.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- Actor actor.Clear(); class=class="str">"cmt">//--- Input layer if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = None; descr.optimization = ADAM; if(!actor.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 = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.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 = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.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 = defNeuronBaseOCL; descr.count = class="num">2 * NActions; descr.activation = None; descr.optimization = ADAM; if(!actor.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 = defNeuronVAEOCL; descr.count = NActions;