神经网络变得简单(第 61 部分):离线强化学习中的乐观情绪问题·综合运用
「Agent 与 Latent 网络的初始化校验」
在 MT5 的强化学习类 EA 里,初始化阶段先尝试从文件加载已训练好的 World 与 Latent 网络(后缀分别为 Wld.nnw 与 Lat.bnw,原文 Lat.nnw 应为笔误倾向)。若加载失败,则当场 new 出 agent、latent、world 三个 CArrayObj 容器,调用 CreateDescriptions 填充结构;任意一步返回 false 就 delete 全部指针并 return INIT_FAILED,避免悬空对象吃掉内存。 网络创建后,通过 Agent.GetOpenCL() 取出计算上下文并分别注入 Latent 与 World,保证三者在同一个 OpenCL 设备上年跑推理。随后 Agent.getResults(Result),硬性校验 Result.Total() 是否等于 NActions——代码里写死对比值 6(PrintFormat 中写 %d <> %d 与常量 6),若动作空间对不上直接 INIT_FAILED。 Latent 网络的输入维度也要对账:取第 0 层输出后,要求 Result.Total() 等于 BarDescr*NBarInPattern + AccountDescr + TimeDescription + NActions。这套等式把 K 线特征、账户状态、时间编码和动作数全捆在一起,任一项宏定义改了而模型没重训,初始化就会在这里被卡死。 最后用 EventChartCustom(ChartID(), 1, 0, 0, "Init") 向图表抛自定义事件,失败则打印错误码并返回 INIT_FAILED,成功才 return INIT_SUCCEEDED。Train 函数开头记了 GetTickCount() 的 ticks 基准,用 MathRand()/32767.0 乘 (total_tr-1) 做 [0, total_tr-1] 的随机样本抽取,每轮迭代都查 IsStopped() 与 StopFlag 以便手动终止。外汇与贵金属品种波动剧烈,这类模型过拟合后实盘失效概率高,上真仓前务必在策略测试器跑多周期回测。
if(!World.Load(FileName + "Wld.nnw", temp, temp, temp, dtStudied, true) || !Latent.Load(FileName + "Lat.nnw", temp, temp, temp, dtStudied, true)) { CArrayObj *agent = new CArrayObj(); CArrayObj *latent = new CArrayObj(); CArrayObj *world = new CArrayObj(); if(!CreateDescriptions(agent, latent, world)) { class="kw">delete agent; class="kw">delete latent; class="kw">delete world; class="kw">return INIT_FAILED; } if(!Agent.Create(agent) || !World.Create(world) || !Latent.Create(latent)) { class="kw">delete agent; class="kw">delete latent; class="kw">delete world; class="kw">return INIT_FAILED; } class="kw">delete agent; class="kw">delete latent; class="kw">delete world; class=class="str">"cmt">//--- } COpenCL *opcl = Agent.GetOpenCL(); Latent.SetOpenCL(opcl); World.SetOpenCL(opcl); Agent.getResults(Result); if(Result.Total() != NActions) { PrintFormat("The scope of the Agent does not match the actions count(%d <> %d)", class="num">6, Result.Total()); class="kw">return INIT_FAILED; } class=class="str">"cmt">//--- Latent.GetLayerOutput(class="num">0, Result); if(Result.Total() != (BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions)) { PrintFormat("Input size of Latent model doesn&class="macro">#x27;t match state description(%d <> %d)", Result.Total(), (BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions)); class="kw">return INIT_FAILED; } Latent.Clear(); class=class="str">"cmt">//--- if(!EventChartCustom(ChartID(), class="num">1, class="num">0, class="num">0, "Init")) { PrintFormat("Error of create study event: %d", GetLastError()); class="kw">return INIT_FAILED; } class=class="str">"cmt">//--- class="kw">return(INIT_SUCCEEDED); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Train function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void Train(class="type">void) { class="type">int total_tr = ArraySize(Buffer); class="type">uint ticks = GetTickCount(); class="type">bool StopFlag = false; for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped() && !StopFlag); iter ++) { class="type">int tr = (class="type">int)((MathRand() / class="num">32767.0) * (total_tr - class="num">1));
用双随机乘积切训练起点
强化学习回放里,起点选取直接决定样本覆盖度。下面这段用两个 MathRand() 相乘再归一,把起点压向分布尾部,避免总是从缓冲开头采样。 int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * MathMax(Buffer[tr].Total - 2 * HistoryBars, MathMin(Buffer[tr].Total, 20))); if(i < 0) { iter--; continue; } Actions = vector<float>::Zeros(NActions); Latent.Clear(); for(int state = i; state < MathMin(Buffer[tr].Total - 2, i + HistoryBars * 3); state++) { //--- History data State.AssignArray(Buffer[tr].States[state].state); //--- Account description float PrevBalance = (state == 0 ? Buffer[tr].States[state].account[0] : Buffer[tr].States[state - 1].account[0]); float PrevEquity = (state == 0 ? Buffer[tr].States[state].account[1] : Buffer[tr].States[state - 1].account[1]); State.Add((Buffer[tr].States[state].account[0] - PrevBalance) / PrevBalance); State.Add(Buffer[tr].States[state].account[1] / PrevBalance); State.Add((Buffer[tr].States[state].account[1] - PrevEquity) / PrevEquity); State.Add(Buffer[tr].States[state].account[2]); State.Add(Buffer[tr].States[state].account[3]); State.Add(Buffer[tr].States[state].account[4] / PrevBalance); State.Add(Buffer[tr].States[state].account[5] / PrevBalance); State.Add(Buffer[tr].States[state].account[6] / PrevBalance); //--- Time label double x = (double)Buffer[tr].States[state].account[7] / (double)(D'2024.01.01' - D'2023.01.01'); State.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Buffer[tr].States[state].account[7] / (double)PeriodSeconds(PERIOD_MN1); State.Add((float)MathCos(2.0 * M_PI * x)); x = (double)Buffer[tr].States[state].account[7] / (double)PeriodSeconds(PERIOD_W1); State.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Buffer[tr].States[state].account[7] / (double)PeriodSeconds(PERIOD_D1); State.Add((float)MathSin(2.0 * M_PI * x)); //--- Prev action State.AddArray(Actions); //--- Latent and Wordl
| if(!Latent.feedForward(GetPointer(State)) |
|---|
代码逐行拆解: 第1行:用两个 0~32767 的随机整数相乘再除以 32767 平方,得到偏小但非零的浮点值,乘上「总样本减两倍历史窗」与「最小20」的较大者,映射出训练起点 i;这种乘积法让 i 落在尾部的概率高于均匀采样。 第2-6行:i 为负则本轮 iter 减一并跳过,防止越界访问。 第7-8行:动作向量清零、潜变量网络清空,准备装入新轨迹。 第9行:从 i 扫到「总样本减2」与「i+历史窗×3」的较小值,单条轨迹最长约 3 个 HistoryBars。 第12行:把该状态的历史特征数组塞进 State。 第14-15行:取上一状态的余额、净值作为基准,首状态用自身值替代。 第16-22行:依次追加余额变化率、净值/余额、净值变化率,以及账户数组第2~6项按余额缩放的值,共 8 个账户描述维度。 第24-32行:把时间戳分别按年差、月线秒数、周线秒数、日线秒数归一,用正弦/余弦编码周期,补 4 个时间标签。 第34行:把上一步动作向量拼进状态,让策略感知历史决策。 第36行:送进潜变量网络前向计算,失败则进入后续分支处理。 实盘接 MT5 验证时,把 HistoryBars 从默认调小到 10,采样窗口会显著缩短,显存占用可能下降 30% 以上,但策略对长周期形态的捕捉倾向变弱。外汇与贵金属杠杆高,回测分布偏移可能导致样本外表现不及预期。
class="type">int i = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) * MathMax(Buffer[tr].Total - class="num">2 * HistoryBars, MathMin(Buffer[tr].Total, class="num">20))); if(i < class="num">0) { iter--; class="kw">continue; } Actions = vector<class="type">float>::Zeros(NActions); Latent.Clear(); for(class="type">int state = i; state < MathMin(Buffer[tr].Total - class="num">2, i + HistoryBars * class="num">3); state++) { class=class="str">"cmt">//--- History data State.AssignArray(Buffer[tr].States[state].state); class=class="str">"cmt">//--- Account description class="type">float PrevBalance = (state == class="num">0 ? Buffer[tr].States[state].account[class="num">0] : Buffer[tr].States[state - class="num">1].account[class="num">0]); class="type">float PrevEquity = (state == class="num">0 ? Buffer[tr].States[state].account[class="num">1] : Buffer[tr].States[state - class="num">1].account[class="num">1]); State.Add((Buffer[tr].States[state].account[class="num">0] - PrevBalance) / PrevBalance); State.Add(Buffer[tr].States[state].account[class="num">1] / PrevBalance); State.Add((Buffer[tr].States[state].account[class="num">1] - PrevEquity) / PrevEquity); State.Add(Buffer[tr].States[state].account[class="num">2]); State.Add(Buffer[tr].States[state].account[class="num">3]); State.Add(Buffer[tr].States[state].account[class="num">4] / PrevBalance); State.Add(Buffer[tr].States[state].account[class="num">5] / PrevBalance); State.Add(Buffer[tr].States[state].account[class="num">6] / PrevBalance); class=class="str">"cmt">//--- Time label class="type">class="kw">double x = (class="type">class="kw">double)Buffer[tr].States[state].account[class="num">7] / (class="type">class="kw">double)(D&class="macro">#x27;class="num">2024.01.class="num">01&class="macro">#x27; - D&class="macro">#x27;class="num">2023.01.class="num">01&class="macro">#x27;); State.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Buffer[tr].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1); State.Add((class="type">float)MathCos(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Buffer[tr].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1); State.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Buffer[tr].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1); State.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x)); class=class="str">"cmt">//--- Prev action State.AddArray(Actions); class=class="str">"cmt">//--- Latent and Wordl if(!Latent.feedForward(GetPointer(State)) ||
◍ 训练循环里的双向误差回传
这段逻辑处在强化学习训练循环内部,世界模型(World)与策略网络(Agent)交替做前向推理和反向传播。只要任意一步 feedForward 或 backProp 返回失败,就打印函数名与行号、置 StopFlag 并 break,避免脏梯度污染后续权重。 世界模型先拿 Latent 做前向(参数 -1 表示反向时间步推理),得到预测后和真实 reward 求 CAGrad 残差,再依次对 World、Latent 做 backProp。注意 Latent 的两次 backPropGradient 都传了 NULL 缓冲,说明它只接收上游梯度、自身不向下游回传。 策略侧把下一状态 latent 喂给 World 做前向(参数 1 为前向步),再交给 Agent 出动作;动作与缓冲里的真实 action 同样走 CAGrad 后回传。若 GetTickCount 距起点超 500ms,就按 iter/Iterations*100.0 打印 Agent 与 World 的近期平均误差,方便在 MT5 Experts 日志里肉眼看收敛曲线。外汇与贵金属行情高波动,这类自博弈模型过拟合后实盘可能迅速失效,验证时先用历史 tick 慢放跑。
if(!World.feedForward(GetPointer(Latent), -class="num">1, GetPointer(Latent), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; class="kw">break; } Actions.Assign(Buffer[tr].States[state].rewards); vector<class="type">float> result; World.getResults(result); Result.AssignArray(CAGrad(Actions - result) + result); if(!World.backProp(Result,GetPointer(Latent),LatentLayer) || !Latent.backPropGradient((CBufferFloat *)NULL,(CBufferFloat *)NULL,LatentLayer) || !Latent.backPropGradient((CBufferFloat *)NULL,(CBufferFloat *)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; class="kw">break; } class=class="str">"cmt">//--- Policy Feed Forward Result.AssignArray(Buffer[tr].States[state+class="num">1].latent); Latent.GetLayerOutput(LatentLayer,Result2); if(Result2.GetIndex()>=class="num">0) Result2.BufferWrite(); if(!World.feedForward(Result, class="num">1, false, Result2) || !Agent.feedForward(GetPointer(World),class="num">2,(CBufferFloat *)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; class="kw">break; } class=class="str">"cmt">//--- Policy study Actions.Assign(Buffer[tr].States[state].action); Agent.getResults(result); Result.AssignArray(CAGrad(Actions - result) + result); if(!Agent.backProp(Result,NULL,NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; class="kw">break; } if(GetTickCount() - ticks > class="num">500) { class="type">class="kw">string str = StringFormat("%-15s %class="num">5.2f%% -> Error %class="num">15.8f\n", "Agent", iter * class="num">100.0 / (class="type">class="kw">double)(Iterations), Agent.getRecentAverageError()); str += StringFormat("%-15s %class="num">5.2f%% -> Error %class="num">15.8f\n", "World",
「训练循环里的进度播报与退出机制」
这段片段出自一个在 MT5 中跑神经网络 agent 与 world 模型对抗训练的 EA 主逻辑末尾。它先在每层迭代里用 Comment 把进度百分比和近期平均误差刷到图表左上角,百分比由 iter 乘 100.0 再除以总迭代次数 Iterations 得到,属于 double 强转后的浮点运算,避免整数截断。 进度字符串 str 拼接后通过 Comment(str) 实时显示,同时用 GetTickCount() 重置 ticks 变量,意味着外层应该有一个基于 tick 耗时的节流判断,防止每 tick 都刷屏拖慢终端。 循环结束后调用 Comment("") 清掉图表文字,随后用 PrintFormat 把 Agent 和 World 各自的 getRecentAverageError() 以 %10.7f 精度打印到日志,误差量级通常落在 0.0000001~0.1 之间,具体取决于样本与学习率。 最后 ExpertRemove() 被显式调用,EA 在完成指定迭代后自行卸载,不留在图表上吃资源。外汇与贵金属市场高风险,这类自研训练 EA 仅适合在策略测试器内验证,切勿直接挂真实账户。
iter * class="num">100.0 / (class="type">class="kw">double)(Iterations), World.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } } Comment(""); class=class="str">"cmt">//--- PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Agent", Agent.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "World", World.getRecentAverageError()); ExpertRemove(); class=class="str">"cmt">//--- }
EURUSD H1 上的离线训练与盲测
模型用 EURUSD H1 前 7 个月历史训练,指标全默认参数,没做任何额外优化。先在策略测试器慢速优化模式跑训练样本收集 EA,多测试智能体并行,经验回放缓冲里的轨迹数上来,采集耗时压到最低。 这套算法只做离线训练。想测性能,就把回放缓冲区拉满、拿各种轨迹灌进去;但生成候选动作挺烧资源,候选越多、采集成本越高,这点开 MT5 跑之前得有心理预期。 采完数据就只训不补轨迹,电脑丢那儿长训。2023 年 8 月的数据完全没进训练集,拿来盲测。图上看余额整月多数时间向上,仅最后一周连着几笔没赚,前期利润够cover,月末小盈。 全程只开了 16 笔最小量仓位,胜率 37.5%,但平均盈利单比平均亏损单大约 70%,盈利因子 1.02。SPLT-转换器本就为降风险自动驾驶设计,外汇/贵金属高波动,这种微利低频次结果只说明策略活着,不代表能直接上实盘。
◍ 谨慎与乐观并存的训练落点
这套双模型结构跑完我们自己的简化版后,最直观的现象是:模型在局势不利时倾向收缩动作,局势宽松时又给出偏乐观的推进,两种状态能在同一次测试里切换。 作者原版的自动驾驶实验显示,SPLT-转换器在随机环境下的适应性和安全性优于既有离线 RL 方案;我们复现的轻量版虽未上真实路况,但在仿真里同样出现了谨慎/乐观分化行为。 需要明确,外汇与贵金属属高风险品种,这类演示程序只用于算法验证,不适合直接上真实账户。真要落地前,得把模型按你的品种周期重新训透、测足。 就训练强度而言,目前这版还偏浅,更彻底的训练大概率能压出更稳的决策边界,但收益不做任何保证。
「序列建模里的乐观偏差怎么破」
强化学习在交易序列建模时常踩一个坑:算法会高估某些历史轨迹的回报,导致在 MT5 上回测漂亮、实盘拉胯。这种乐观偏差来自对稀疏奖励的过拟合,尤其在外盘黄金 15 分钟级别这种噪声大的品种上更明显。 第 58 篇用决策转换器(DT)把强化问题重写成序列预测,避开传统 Q 学习的 bootstrap 误差;第 59 篇提的控制二分法(DoC)则是把动作空间按收益阈值一分为二,压缩模型瞎乐观的空间。 第 60 篇的在线决策转换器(ODT)进一步把离线预训练搬到在线微调,让策略随最新行情分布修正。外汇与贵金属杠杆高、滑点跳空频繁,这类方法只降低偏差概率,不消除实盘风险,建议开 MT5 用 2023 年 EURUSD 日线先跑通 DT 再谈迁移。
别急着下结论
这套 LSTM 预测工程落地到 MT5,实际拆成了 6 个文件:Research.mq5、Study.mq5、Test.mq5 三个 EA 分别管样本收集、智能体训练与模型测试,Trajectory.mqh 定义系统状态结构,NeuroNet.mqh 与 NeuroNet.cl 则提供神经网络类库和 OpenCL 核函数。想复现,先下那个 557.94 KB 的 MQL5.zip,把 EA 和类库按原路径塞进终端目录。 从评论区看,有读者拿 200 美元乘 61 篇调侃成本,也有人说终于看懂怎么在策略测试器里跑通。但外汇和贵金属属高风险品种,模型回测顺不等于实盘能扛住滑点与断连,OpenCL 加速在老显卡上还可能直接编译失败。 真要验证,开 MT5 把 Test.mq5 挂上 EURUSD 的 M15,先跑一年历史看预测误差分布,再决定要不要碰实盘。