神经网络变得简单(第 68 部分):离线优先引导政策优化(基础篇)
「离线优先引导策略在 MT5 里的落地思路」
Dmitriy Gizlyk 在 2024 年 8 月 30 日发布的 MT5 交易系统文章里,把第 68 部分主题定为离线优先引导政策优化(Offline-Prioritized Guided Policy Optimization),核心是用历史离线数据先约束策略更新方向,再在引导阶段做小步修正,降低在线探索的踩坑成本。 外汇与贵金属市场高杠杆、跳空频繁,直接在线强化学习容易在极端行情里把权重推到不可恢复区;离线优先的做法相当于先拿已发生的 1 021 次浏览所指向的同类样本做策略先验,再有限度放开。 实际在 MT5 里验证时,建议先把历史 tick 导成离线缓冲,用 KL 散度卡住策略偏移上限,观察回测曲线是否比纯在线版更平滑。
◍ 用人类偏好替代环境奖励的 OPPO 思路
强化学习里,智能体靠最大化环境给的奖励来逼近最优政策,但奖励函数往往要人工精调,且真实目标常常无法被稀疏奖励充分表达。 论文《超越奖励:离线优先引导政策优化》提出 OPPO(Offline Priority-guided Policy Optimization),核心是用两条已跑完轨迹之间人类标注者的优先排序,替代环境奖励信号。 这意味着训练不再依赖环境即时回报,而是从离线轨迹中抽取人类判断;对交易策略搜索来说,相当于用「老手觉得哪条历史下单路径更稳」来引导模型,而不是等账户爆仓才算惩罚。外汇与贵金属杠杆高,这类偏好数据若取样偏差,可能放大回撤概率。
用高维上下文替掉标量奖励
传统离线优先引导学习常走两步:先用监督学习训出奖励函数,再拿任意离线 RL 算法去优化政策。问题在标量奖励会在复杂任务里形成信息瓶颈,政策优化容易卡在次优解;更麻烦的是离线政策会钻错误奖励函数的空子,跑出意料外的行为。 OPPO 把这两步并成一步:不单独训奖励,而是直接从离线优先数据集里学政策,同时建模离线缺失信息并做优先建模。它把政策写成上下文形式 π(A|S,Z),在高维空间 Z 里估算,而不是被压成一个数字。和标量收益比,Z 空间能装下更多任务相关信息,政策条件化在学出的优化上下文 Z' 上,更贴近人的优先判断。 作者用模型 Iθ 近似优先函数,Z=Iθ(τ) 就是优先上下文,编码器-解码器结构和离线模仿学习像,但因为没有专家演示,改靠优先标签抽回顾信息。优先建模目标里 z+ 是正轨迹上下文 Iθ(yτj+(1-y)τi),z- 是较差轨迹上下文 Iθ(yτi+(1-y)τj);底层假设是人在两条轨迹 τi、τj 间做两级比较:各自和假设最优 τ* 的相似度 l(z*,z+) 与 l(z*,z-),取更接近优先的那条。 优化这个目标会让最优上下文更靠 z+、更远离 z-。z* 来自始终优先于任何离线轨迹的 τ*,它的后验和 Iθ(•) 提取是交替更新的,训练稳定性由此来。编码器损失有两部分:和回顾信息误差比,以及和优先数据集给的二元标签对齐。更好估 z* 帮编码器抓人判定时真看重的特征,反过来好编码器又加速高维空间里找最优轨迹。
「用 MQL5 把轨迹优先写进结构体」
传统强化学习每步都存奖励,这里直接换成轨迹优先标签:只在轨迹结束时记一笔净利润,存进 STrajectory,SState 里的 rewards 数组被 scheduler 上下文数组取代,而且数组大小也改了,不是单纯改名。 优先级是挂在整条轨迹上的,不是单步动作;离线数据集里按 [0:1] 区间做轨迹两两比较,由智能系统打标签,不手动画棋盘表。作者只用了「轨迹盈利」这一个准则,但你完全可以自己加最大回撤、盈利因子和系数——准则集只影响训练结果,不影响算法实现。 结构字段一改,复制和读写这两个结构文件的方法也得跟着改,具体改动很琐碎,直接看附件代码最省事。下面这段是 SState 的骨架,注意 state 维度由 BarDescr * NBarInPattern 决定,action 长度等于 NActions。 模型端用两个 DT(决策转换器)分开训:Scheduler 学优先项,Agent 学行为策略,都带注意力机制。Scheduler 输入最后一根收盘烛条的价格、指标、账户持仓、时间戳和 Agent 上个动作,过批归一化、Embedding、两次 SoftMax 和注意力模块,输出 EmbeddingSize 长的潜向量;Agent 架构类似,多了卷积层降维,最后接 4 个全连接层,输出大小等于动作空间。 收集轨迹的 EA 用 ɛ-贪婪探索,OnTick 里等新柱、刷指标、组状态、跑 Scheduler 前馈拿上下文、再跑 Agent 前馈出动作,不加奖励只存轨迹;优先级在策略测试器 OnTester 里按验算有效性回填。训练 Scheduler 时从回放缓冲区按盈利采样正负两条轨迹,先顺向最小化优先轨迹偏差,再反向最大化负轨迹偏差(偏差还乘 0.5 往优先侧推),避开 GPT 顺序敏感带来的双模型合并坑。Agent 训法更简单:单轨迹采样,用回放里的上下文做监督,把预测动作和真实动作误差压到最小。 外汇和贵金属杠杆高、爆仓快,这套离线强化方案只是概率上提高策略可控性,实盘前务必在 MT5 策略测试器用历史数据跑通附件里的 Research / StudyScheduler / StudyAgent / Test 四个 mq5。
class="kw">struct SState { class="type">float state[BarDescr * NBarInPattern]; class="type">float account[AccountDescr]; class="type">float action[NActions];
◍ 状态结构与轨迹缓冲的底层定义
在 MT5 里做强化学习式的盯盘代理,第一步是把「状态」和「轨迹」固化成结构体。SState 内部用 float 型 scheduler[EmbeddingSize] 承载调度向量,Clear 方法靠 ArrayInitialize 把 state、account、action、scheduler 四个数组一次性归零,避免上一根 K 线的残留污染下一帧决策。 重载 operator= 时没有直接 memcpy,而是对四个数组分别 ArrayCopy,这样在回测中复制某一步状态做分支模拟时,内存是隔离的,不会改到原对象。STrajectory 则用一个 SState States[Buffer_Size] 环形缓冲装整条轨迹,外加 Total 与 Profit 双精度字段记录步数和累计盈亏。 CreateSchedulerDescriptions 是给神经网络调度器铺输入层的函数。输入层神经元数由 BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions 算出,激活函数设 None、优化器用 ADAM;若 scheduler 指针为空会先 new 一个 CArrayObj 再 Clear,保证每次重建描述时不带旧层配置。外汇与贵金属行情跳空频繁,这套结构在实盘前务必用策略测试器跑通 Save/Load 的二进制一致性。
class="type">float scheduler[EmbeddingSize]; class=class="str">"cmt">//--- SState(class="type">void); class=class="str">"cmt">//--- class="type">bool Save(class="type">int file_handle); class="type">bool Load(class="type">int file_handle); class=class="str">"cmt">//--- class="type">void Clear(class="type">void) { ArrayInitialize(state, class="num">0); ArrayInitialize(account, class="num">0); ArrayInitialize(action, class="num">0); ArrayInitialize(scheduler, class="num">0); } class=class="str">"cmt">//--- overloading class="type">void class="kw">operator=(const SState &obj) { ArrayCopy(state, obj.state); ArrayCopy(account, obj.account); ArrayCopy(action, obj.action); ArrayCopy(scheduler, obj.scheduler); } }; class="kw">struct STrajectory { SState States[Buffer_Size]; class="type">int Total; class="type">class="kw">double Profit; class=class="str">"cmt">//--- STrajectory(class="type">void); class=class="str">"cmt">//--- class="type">bool Add(SState &state); class="type">void ClearFirstN(const class="type">int n); class=class="str">"cmt">//--- class="type">bool Save(class="type">int file_handle); class="type">bool Load(class="type">int file_handle); class=class="str">"cmt">//--- overloading class="type">void class="kw">operator=(const STrajectory &obj) { Total = obj.Total; Profit = obj.Profit; for(class="type">int i = class="num">0; i < Buffer_Size; i++) States[i] = obj.States[i]; } }; class="type">bool CreateSchedulerDescriptions(CArrayObj *scheduler) { class=class="str">"cmt">//--- CLayerDescription *descr; class=class="str">"cmt">//--- if(!scheduler) { scheduler = new CArrayObj(); if(!scheduler) class="kw">return false; } class=class="str">"cmt">//--- Scheduler scheduler.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); descr.activation = None; descr.optimization = ADAM; if(!scheduler.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">1
把网络层逐个塞进调度器
下面这段是构建 OpenCL 神经网络的核心拼装过程,每一层都用 CLayerDescription 描述后交给 scheduler.Add。注意第二层 Embedding 的窗口数组直接用 ArrayCopy 从临时整型数组写入,维度组合是 BarDescr*NBarInPattern、AccountDescr、TimeDescription、NActions。 第一层 BatchNorm 把 batch 写死成 1000,优化器选 ADAM,激活函数留 None;第三层 SoftMax 的 step 用 prev_count*4,而 prev_count 来自上一层输出数量。第四层多头注意力把 count 扩到 prev_count*4,window_out 固定 32、layers 为 4、step 为 8,这是显存和计算量容易爆的地方。 第五层又接 SoftMax,step 回退到 prev_count;第六层 Base 层用 LReLU 激活、数量走 LatentCount;第七层 Base 层把 count 重置为 EmbeddingSize、激活 None。任何一层 Add 失败就 delete descr 并返 false,全部成功才返 true。 在 MT5 里跑这套,先确认 HistoryBars、EmbeddingSize、LatentCount 这些宏已在头文件定义,否则第四层 prev_count*4 会直接越界。外汇与贵金属杠杆高,这类模型只作概率参考,实盘前务必用历史数据验证过拟合程度。
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(!scheduler.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}; ArrayCopy(descr.windows, temp); } class="type">int prev_wout = descr.window_out = EmbeddingSize; if(!scheduler.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">4; descr.activation = None; descr.optimization = ADAM; if(!scheduler.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">4; descr.window = EmbeddingSize; descr.step = class="num">8; descr.window_out = class="num">32; descr.layers = class="num">4; descr.optimization = ADAM; if(!scheduler.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 = EmbeddingSize; descr.step = prev_count; descr.activation = None; descr.optimization = ADAM; if(!scheduler.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 = defNeuronBaseOCL; descr.count = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!scheduler.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 = EmbeddingSize; descr.activation = None; descr.optimization = ADAM; if(!scheduler.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- class="kw">return true; }