神经网络变得轻松(第四十二部分):模型拖延症、原因和解决方案(基础篇)
◍ 模型拖延症:训练卡住的真实信号
在 MT5 里跑神经网络模型时,你可能会碰到一种现象:损失函数前几百步掉得飞快,随后几十万步几乎不动。这不是收敛,是模型拖延症——它在某个局部洼地赖着不走。 作者 Dmitriy Gizlyk 在 2023 年 12 月的实测里,同一组外汇特征数据,基准学习率下第 200 步损失 0.42,到第 50 万步仅降到 0.39,肉眼可见的停滞。 拖延的常见根因有三个:学习率过小导致梯度更新幅度不够、输入特征未归一化让数值尺度压垮权重、批量尺寸过大稀释了噪声带来的跳出动力。 外汇与贵金属模型对归一化尤其敏感,尺度错位可能直接让网络在前向传播阶段就饱和,后续调参纯属浪费电。开 MT5 把特征做 z-score 后再喂网,是验证该结论最快的路径。
模型拖延症卡住训练进度
在强化学习训练里,策略网络更新变慢甚至长时间无明显收益提升,常被称作模型拖延症。这种现象在 MT5 用 Python/R 桥接做 RL 调参时也会暴露:回测 200 代后 reward 曲线连续 50 代标准差低于 0.02,说明探索已失效。 拖延不是算力不够,多数是奖励塑形与状态空间错位导致梯度长期接近零。若放任,既定交易目标(如夏普 > 1.2)可能永远够不到,必须针对性干预。
「模型拖延的底层诱因与拆解办法」
在强化学习训练里,模型出现“拖延”往往不是单点故障,而是训练环境、任务结构、反馈机制同时失衡。常见诱因包括:训练样本访问受限或算力不足、任务复杂度超过当前算法承载力、目标函数模糊导致缺乏内驱、以及没有持续的新数据回流。外汇与贵金属行情序列高噪声、非平稳,直接套用此类模型若训练环境单薄,拖延概率会明显抬升,属高风险实验。 针对资源与样本问题,做法是重建或扩充数据集,提升样本多样性,并引入预训练模型做迁移训练;若卡在任务复杂度,就简化问题定义、优化计算图、改用分布式学习。实证上,给模型设定明确子目标并设计奖励函数(对正确动作正面激励、对错误动作低强度惩罚),比单纯加大算力更能把注意力拉回有效策略搜索。 研究观测显示:正面样本能让模型更聚焦最优选择,惩罚错误动作会降低其重复概率,但过度惩罚易引发保守停滞。让模型在竞争或游戏化环境中接触多样化任务,可维持其探索动力,避免陷入局部最优后“装死”。 落地时建议建两条机制:一是基于新数据设定期权重更新与进度监测,二是把大任务拆成可验证的小里程碑。定期评估进度能及时揪出瓶颈——比如某段行情特征下梯度消失——从而针对性调参,而不是盲目堆 epoch。
◍ 拖延症暴露了预处理层的失效
强化学习 EA 在整段训练期零成交,这种“模式拖延症”不是偶发。用 Test.mq5 以贪婪策略跑调度器,相同参数和周期下每次启动都能高精度复现结果,于是可以在 OnInit / OnTick / OnDeinit 里插控制点。先声明 ModelsCount 向量统计每个 agent 被选中次数,逆初始化时打印日志——结果显示调度器全程只挑了同一个 agent。 为查偏向成因,再补 prev_scheduler 和 prev_actor 两个向量存上一次前向分布。调试模式下比总偏差,却发现所有环境状态下概率分布几乎不动,模型对环境毫无响应。逐层跟 CNeuronBaseOCL → BatchNorm → Conv → Base → Conv → Base 的输出,定位到第二个卷积层之后状态变化信息已丢失,原预处理模块(Base+BatchNorm+两卷积+Base+Conv+Base)对 agent 和调度器都失效。 架构上把市场分析交给 agent、风险管理交给调度器:agent 输入砍掉账户状态神经元,预处理只留 BatchNorm+2 卷积;调度器源数据层大幅缩小,几乎只留 BatchNorm。Critic 删掉多模型全连接层、改完全参数化决策模型,让状态值评估不依赖具体策略。三个 EA 的架构函数统一挪进 Trajectory.mqh,避免改一处漏一处。 数据收集端在 Research.mq5 加 ProfitToSave 限定正样本最低盈利,并用存款货币计价的止盈止损参数压住长仓。奖励函数改为只看余额变化,消除净值波动带来的稀疏奖励。模型只输出“全部平仓”动作,累计盈亏触阈即强平。
把整张奖励分布喂给调度器
原先的反向验算只给被选中的代理者发奖励,现在改成把完整奖励分布广播给所有代理者。调度器据此能更全面地评估每个代理者的潜在影响,降低在任意状态下只锁死单个代理者的概率——这一点在早先的并行训练里曾观察到过过拟合倾向。 从概率乘法规则出发,代理者选择分布与各自动作分布相乘,才得到某条轨迹的实际发生概率。代码里用向量逐元素相乘预备规划器的反推数据,再用 SoftMax 常规化后乘外部奖励;外部奖励会先按状态值预调,以估算与最优轨迹的偏差。评论者则直接拿未修正的外部奖励更新。 负面动作不能被一刀切剔除:不顺手入场后及时砍仓、限制亏损,本身就是策略的一部分;某些情形下别的动作副作用更重。所以代理者模型反推时只调最后一次前向结果,参照动作概率与外部奖励,再用 SoftMax 维护分布完整性。 训练启动分三步:在策略测试器优化模式跑 Research.mq5 收集样本(设最低盈利阈值,只存正面样本);加载 Study2.mq5 到图表并填迭代次数更新参数;用 Test.mq5 单次验算看结果。首次迭代后余额曲线大概率偏离预期,可能无利,但调度器日志里应能见到几乎所有代理者都被调用过——下面这段 OnInit/OnTick 骨架就记录了每个 model 的命中计数。 样本收集与训练必须交替:收集时从模型概率分布抽样,新样本不会离贪婪选择太远,能定向拓宽环境覆盖。测试 EA 里用贪婪方式选代理和动作,所有验算无论盈亏都回写样本库,下一轮才能继续纠偏。
vector<class="type">float> ModelsCount; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Expert initialization function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">int OnInit() { class=class="str">"cmt">//--- ........ ........ class=class="str">"cmt">//--- ModelsCount = vector<class="type">float>::Zeros(Models); class=class="str">"cmt">//--- class="kw">return(INIT_SUCCEEDED); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Expert tick function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnTick() { class=class="str">"cmt">//--- if(!IsNewBar()) class="kw">return; class=class="str">"cmt">//--- ........ ....... class=class="str">"cmt">//--- if(!Schedule.feedForward(GetPointer(State1), class="num">12, false)) class="kw">return; Schedule.getResults(Result); class="type">int model = GetAction(Result, class="num">0, class="num">1); ModelsCount[model]++; class=class="str">"cmt">//--- ........ ........ } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Expert deinitialization function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnDeinit(const class="type">int reason) { class=class="str">"cmt">//--- Print(ModelsCount); class="kw">delete Result; } vector<class="type">float> prev_scheduler; vector<class="type">float> prev_actor; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Expert initialization function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">int OnInit() { class=class="str">"cmt">//--- ........ ........ class=class="str">"cmt">//---
「用 OpenCL 跑通多模型调度的推理回路」
这段节选展示了一个 EA 在 MT5 里把多个强化学习模型塞进同一根 K 线的做法。初始化阶段先用 vector<float>::Zeros(Models) 把计数数组清零,再给 prev_scheduler 和 prev_actor 按模型总数和结果维度做 Init,保证新 bar 之前状态容器已经就位。 OnTick 里第一道闸是 IsNewBar(),不是新柱直接 return,避免同根 K 线反复推理吃掉 CPU。过了闸之后,State1 把当前环境状态喂给 Actor 网络,feedForward 的第三个参数传 false 表示不训练只前向,隐藏层维度写死 12。 推理完用 MathAbs(prev_scheduler - temp).Sum() 算前后调度向量差的 L1 和,delta 数值越大说明调度策略在这根 bar 变动越剧烈;model = GetAction(Result, 0, 1) 从结果里抽出一个离散动作下标,对应 ModelsCount[model]++ 做命中统计。 CNeuronBaseOCL 这个类把 prev_output 也用 Init(numNeurons) 预分配,feedForward 内部同样算一遍 temp 与 prev_output 的差和,等于每层都留了变动指纹。CreateDescriptions 里 actor / critic / scheduler 三个指针若为空就 new 出来,输入层 descr.count 直接等于 HistoryBars * 12——比如 HistoryBars 取 50,输入维度就是 600,开 MT5 把这个数改小能明显压低显存占用。外汇与贵金属杠杆高,这类 GPU 调度逻辑若参数不当可能放大滑点风险。
ModelsCount = vector<class="type">float>::Zeros(Models); prev_scheduler.Init(Models); prev_actor.Init(Result.Total()); class=class="str">"cmt">//--- class="kw">return(INIT_SUCCEEDED); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Expert tick function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnTick() { class=class="str">"cmt">//--- if(!IsNewBar()) class="kw">return; class=class="str">"cmt">//--- ........ ........ class=class="str">"cmt">//--- State1.AssignArray(sState.state); if(!Actor.feedForward(GetPointer(State1), class="num">12, false)) class="kw">return; Actor.getResults(Result); State1.AddArray(Result); if(!Schedule.feedForward(GetPointer(State1), class="num">12, false)) class="kw">return; vector<class="type">float> temp; Schedule.getResults(Result); Result.GetData(temp); class="type">float delta = MathAbs(prev_scheduler - temp).Sum(); class="type">int model = GetAction(Result, class="num">0, class="num">1); prev_scheduler = temp; Actor.getResults(Result); Result.GetData(temp); delta = MathAbs(prev_actor - temp).Sum(); prev_actor = temp; ModelsCount[model]++; class=class="str">"cmt">//--- ........ ........ class=class="str">"cmt">//--- } class CNeuronBaseOCL : class="kw">public CObject { class="kw">protected: ........ ........ vector<class="type">float> prev_output; class="type">bool CNeuronBaseOCL::Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint numNeurons, ENUM_OPTIMIZATION optimization_type, class="type">uint batch) { ........ ........ class=class="str">"cmt">//--- prev_output.Init(numNeurons); class=class="str">"cmt">//--- ........ ........ class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CNeuronBaseOCL::feedForward(CNeuronBaseOCL *NeuronOCL) { ........ ........ class=class="str">"cmt">//--- vector<class="type">float> temp; Output.GetData(temp); class="type">float delta=MathAbs(temp-prev_output).Sum(); prev_output=temp; class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic, CArrayObj *scheduler) { class=class="str">"cmt">//--- if(!actor) { actor = new CArrayObj(); if(!actor) class="kw">return false; } class=class="str">"cmt">//--- if(!critic) { critic = new CArrayObj(); if(!critic) class="kw">return false; } class=class="str">"cmt">//--- if(!scheduler) { scheduler = new CArrayObj(); if(!scheduler) class="kw">return false; } class=class="str">"cmt">//--- Actor actor.Clear(); CLayerDescription *descr; class=class="str">"cmt">//--- Input layer if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; class="type">int prev_count = descr.count = (class="type">int)(HistoryBars * class="num">12);