神经网络变得简单(第 69 部分):基于密度的行为政策支持约束(SPOT)·综合运用
状态表扩容与轨迹采样的底层写法
这段逻辑先把嵌入进度打到图表左上角,再按实际占用的状态数收缩三类矩阵。state 不等于 total_states 时,rewards、actions 和 state_embedding 会被 Resize / Reshape 到当前 state,total_states 同步更新,避免为没走过的状态空耗内存。 采样阶段先用 GetProbTrajectories(Buffer, 0.9) 拿到 0.9 置信下的轨迹概率分布,再在 Iterations 次循环里挑一条轨迹。i 的下标用 MathRand() 的平方除以 32767 的平方做非线性抖动,范围锁在 Buffer[tr].Total - 2 内;若算出来小于 0 就 iter-- 并重抽,防止越界读 States[i+1]。 目标账户特征从 i+1 步切出,余额变化率用 (acc[0]-PrevBalance)/PrevBalance,权益变化率用 (acc[1]-PrevEquity)/PrevEquity,另有 7 个账户字段直接入 Account 向量。时间项把 acc[7] 先除以 2023→2024 的秒数算年化相位,再用 MathSin 转成周期特征;另一路除以 PERIOD_MN1 的秒数准备月线尺度归一。外汇与贵金属行情下这类强化学习状态构造高风险,过拟合历史轨迹的概率偏高,建议直接挂 MT5 用 2024 年数据重跑核对字段偏移。
{
class="type">class="kw">string str = StringFormat("%-15s %class="num">6.2f%%", "Embedding ", state * class="num">100.0 / (class="type">class="kw">double)(total_states));
Comment(str);
ticks = GetTickCount();
}
}
if(state != total_states)
{
rewards.Resize(state, NRewards);
actions.Resize(state, NActions);
state_embedding.Reshape(state, state_embedding.Cols());
total_states = state;
}
vector<class="type">float> rewards1, rewards2, target_reward;
STarget target;
class="type">int bar = (HistoryBars - class="num">1) * BarDescr;
class=class="str">"cmt">//---
vector<class="type">float> probability = GetProbTrajectories(Buffer, class="num">0.9);
for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped()); iter ++)
{
class="type">int tr = SampleTrajectory(probability);
class="type">int i = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) * (Buffer[tr].Total - class="num">2));
if(i < class="num">0)
{
iter--;
class="kw">continue;
}
target_reward = vector<class="type">float>::Zeros(NRewards);
class=class="str">"cmt">//--- Target
if(iter >= StartTargetIter)
{
State.AssignArray(Buffer[tr].States[i + class="num">1].state);
class="type">float PrevBalance = Buffer[tr].States[i].account[class="num">0];
class="type">float PrevEquity = Buffer[tr].States[i].account[class="num">1];
Account.Clear();
Account.Add((Buffer[tr].States[i + class="num">1].account[class="num">0] - PrevBalance) / PrevBalance);
Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">1] / PrevBalance);
Account.Add((Buffer[tr].States[i + class="num">1].account[class="num">1] - PrevEquity) / PrevEquity);
Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">2]);
Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">3]);
Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">4] / PrevBalance);
Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">5] / PrevBalance);
Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">6] / PrevBalance);
class="type">class="kw">double x = (class="type">class="kw">double)Buffer[tr].States[i + class="num">1].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;);
Account.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
x = (class="type">class="kw">double)Buffer[tr].States[i + class="num">1].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1);◍ 把账户状态编码进强化学习特征
这段逻辑出现在训练回放环节,目的是把每根 K 线对应的账户变化转成神经网络可消化的浮点向量。注意它先用余弦、正弦把周线和日线维度的持仓时间周期化,再填进 Account 容器,外汇与贵金属品种因杠杆高,这类归一化特征对策略泛化影响显著,可能直接改变 Critic 输出的 reward 估计。 代码里先算周线占比:x 取下一状态账户数组第 7 位除以 PERIOD_W1 的秒数,非 0 时套 2πx 做余弦,0 时给 0,避免除零导致的 NaN。日线同理用 PERIOD_D1 做正弦,连续追加三次后若索引有效就 BufferWrite 落盘。 喂前向时 Actor 先走一遍,失败就打印函数名与行号并 break;随后两个 TargetCritic 并行评估,取 rewards 和较小的那个减掉环境真实 reward,再乘折扣因子 DiscFactor,最后一位塞入潜状态熵。这套双 Critic 取 min 的做法倾向抑制 Q 值高估。 Q 学习部分重算相对变化:用 MathMax(i-1,0) 取上一状态余额与净值,当前余额减昨余除以昨余、当前净值除以昨余等 7 个量依次 Add 进清空后的 Account。你在 MT5 里把 account[7] 的含义改成「持仓周数×7」之类,能直观验证周期特征对回测曲线的影响概率。
Account.Add((class="type">float)MathCos(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); x = (class="type">class="kw">double)Buffer[tr].States[i + class="num">1].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1); Account.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); x = (class="type">class="kw">double)Buffer[tr].States[i + class="num">1].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1); Account.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); class=class="str">"cmt">//--- if(Account.GetIndex() >= class="num">0) Account.BufferWrite(); if(!Actor.feedForward(GetPointer(State), class="num">1, false, GetPointer(Account))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } if(!TargetCritic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) || !TargetCritic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } TargetCritic1.getResults(rewards1); TargetCritic2.getResults(rewards2); target_reward.Assign(Buffer[tr].States[i + class="num">1].rewards); if(rewards1.Sum() <= rewards2.Sum()) target_reward = rewards1 - target_reward; else target_reward = rewards2 - target_reward; target_reward *= DiscFactor; target_reward[NRewards - class="num">1] = EntropyLatentState(Actor); } class=class="str">"cmt">//--- Q-function study State.AssignArray(Buffer[tr].States[i].state); class="type">float PrevBalance = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">0]; class="type">float PrevEquity = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">1]; Account.Clear(); Account.Add((Buffer[tr].States[i].account[class="num">0] - PrevBalance) / PrevBalance); Account.Add(Buffer[tr].States[i].account[class="num">1] / PrevBalance); Account.Add((Buffer[tr].States[i].account[class="num">1] - PrevEquity) / PrevEquity); Account.Add(Buffer[tr].States[i].account[class="num">2]); Account.Add(Buffer[tr].States[i].account[class="num">3]); Account.Add(Buffer[tr].States[i].account[class="num">4] / PrevBalance); Account.Add(Buffer[tr].States[i].account[class="num">5] / PrevBalance);
「把账户状态喂进神经网络的周期编码」
这段逻辑在做一件事:把单笔账户快照转成网络能吃的浮点特征,并顺手跑了一遍 Actor-Encoder-Decoder 与双 Critic 的前向推理。外汇与贵金属杠杆高,这类自研特征若直接接实盘信号,回测飘盈、实盘漂移的概率不低,先验证再谈信任。 先看特征构造。第 6 号字段除以 PrevBalance 得到余额占比;第 7 号字段分别除以一年秒数、月线秒数、周线秒数、日线秒数,再乘 2π 送进 sin/cos,等于把账户某项累计量投影到不同周期的相位上。 前向部分若任意网络 feedForward 返回 false,就 PrintFormat 打函数名加行号并 break,方便你定位是哪一层断的。开 MT5 把这段贴进 EA,把 account[7] 换成你自己的权益曲线累计值,跑 2023 全年 tick,看 LatentLayer 维度是否随周/日周期出现规律波动。
Account.Add(Buffer[tr].States[i].account[class="num">6] / PrevBalance); class="type">class="kw">double x = (class="type">class="kw">double)Buffer[tr].States[i].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;); Account.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); x = (class="type">class="kw">double)Buffer[tr].States[i].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1); Account.Add((class="type">float)MathCos(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); x = (class="type">class="kw">double)Buffer[tr].States[i].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1); Account.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); x = (class="type">class="kw">double)Buffer[tr].States[i].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1); Account.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); if(Account.GetIndex() >= class="num">0) Account.BufferWrite(); if(!Actor.feedForward(GetPointer(State), class="num">1, false, GetPointer(Account))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } if(!Encoder.feedForward((CBufferFloat *)GetPointer(State), class="num">1, false, (CNet *)GetPointer(Actor)) || !Decoder.feedForward(GetPointer(Encoder), -class="num">1, GetPointer(Encoder), class="num">1)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } Actions.AssignArray(Buffer[tr].States[i].action); if(Actions.GetIndex() >= class="num">0) Actions.BufferWrite(); class=class="str">"cmt">//--- if(!Critic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)) || !Critic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } if(!State.AddArray(GetPointer(Account)) || !State.AddArray(vector<class="type">float>::Zeros(NActions)) || !Convolution.feedForward((CBufferFloat *)GetPointer(State), class="num">1, false, (CBufferFloat *)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
双评论员网络下的反向传播与早停
这段训练循环里,Critic1 与 Critic2 各自先前向算出 rewards1、rewards2,再用 CAGrad 把目标奖励差叠回原值做反向传播;任一 backProp 或 Actor 的梯度回传失败就 PrintFormat 打出函数名与行号并 break,避免脏梯度污染后续权重。 策略侧 Actor 单独用 CAGrad(target.actions - rewards1) + rewards1 更新,Decoder 则拿 MSE 损失和 MeanCVAEError 比:只有 rewards2.Loss(rewards1, LOSS_MSE) 超阈值才把 rewards1 赋给 Actions 并联动 Encoder、Actor 回传,省下大部分无谓的 CVAE 微调。 收尾处按 getRecentAverageError 挑误差更小的评论员,若 MathAbs(critic.getRecentAverageError()) <= MaxErrorActorStudy 就让它 feedForward 去推 Actor 的隐层,等于给策略网络做一轮低误差蒸馏;外汇与贵金属行情下这类 RL 模块对过拟合极敏感,实盘前建议在 MT5 用历史 tick 把 MaxErrorActorStudy 从默认档往下调一档观察回撤。
break; } Convolution.getResults(temp); target = GetTargets(Quant, temp, state_embedding, rewards, actions); Critic1.getResults(rewards1); Result.AssignArray(CAGrad(target.rewards + target_reward - rewards1) + rewards1); if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) || !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } Critic2.getResults(rewards2); Result.AssignArray(CAGrad(target.rewards + target_reward - rewards2) + rewards2); if(!Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) || !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } class=class="str">"cmt">//--- Policy study Actor.getResults(rewards1); Result.AssignArray(CAGrad(target.actions - rewards1) + rewards1); if(!Actor.backProp(Result, GetPointer(Account), GetPointer(Gradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } Decoder.getResults(rewards2); if(rewards2.Loss(rewards1, LOSS_MSE) > MeanCVAEError) { Actions.AssignArray(rewards1); if(!Decoder.backProp(GetPointer(Actions), GetPointer(Encoder), class="num">1) || !Encoder.backPropGradient((CNet*)GetPointer(Actor)) || !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } } CNet *critic = NULL; if(Critic1.getRecentAverageError() <= Critic2.getRecentAverageError()) critic = GetPointer(Critic1); else critic = GetPointer(Critic2); if(MathAbs(critic.getRecentAverageError()) <= MaxErrorActorStudy) { if(!critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } }
◍ 目标网络与训练日志的收尾处理
这段逻辑处在双评论家(Twin Critic)强化学习训练循环的末尾,负责把梯度回传结果写回网络,并按节奏同步目标网络权重。 critic.getResults(rewards1) 先取回当前评论家的奖励估计,随后用 CAGrad 构造带目标偏移的结果数组,再切回训练模式做反向传播;若 critic 或 actor 的 backProp 任一失败,立即打印函数与行号、恢复 TrainMode(true) 并 break 跳出本轮。 目标网络更新以 StartTargetIter 为界:迭代数达到阈值后用 Tau 做软更新(WeightsUpdate(...,Tau)),在此之前用系数 1 硬拷贝权重,保证训练初期目标值与在线网络一致。 每超过 500 毫秒(GetTickCount 差值判断),就把 Critic1 / Critic2 / Actor 的近期平均误差按 iter*100.0/Iterations 的进度百分比用 Comment 打到图表;循环结束清掉 Comment,并用 PrintFormat 以 10.7f 精度输出三个网络的最终平均误差,随后 ExpertRemove 卸载智能交易系统。外汇与贵金属市场高波动,这类离线训练 EA 仅适合在策略测试器内验证,实盘直接加载风险极高。
critic.getResults(rewards1); Result.AssignArray(CAGrad(target.rewards + target_reward - rewards1) + rewards1); critic.TrainMode(false); if(!critic.backProp(Result, GetPointer(Actor)) || !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); critic.TrainMode(true); break; } critic.TrainMode(true); } class=class="str">"cmt">//--- Update Target Nets if(iter >= StartTargetIter) { TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau); TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau); } else { TargetCritic1.WeightsUpdate(GetPointer(Critic1), class="num">1); TargetCritic2.WeightsUpdate(GetPointer(Critic2), class="num">1); } if(GetTickCount() - ticks > class="num">500) { class="type">class="kw">string str = StringFormat("%-15s %class="num">5.2f%% -> Error %class="num">15.8f\n", "Critic1", iter * class="num">100.0 / (class="type">class="kw">double)(Iterations), Critic1.getRecentAverageError()); str += StringFormat("%-15s %class="num">5.2f%% -> Error %class="num">15.8f\n", "Critic2", iter * class="num">100.0 / (class="type">class="kw">double)(Iterations), Critic2.getRecentAverageError()); str += StringFormat("%-14s %class="num">5.2f%% -> Error %class="num">15.8f\n", "Actor", iter * class="num">100.0 / (class="type">class="kw">double)(Iterations), Actor.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } Comment(""); class=class="str">"cmt">//--- PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Critic1", Critic1.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Critic2", Critic2.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Actor", Actor.getRecentAverageError()); ExpertRemove(); class=class="str">"cmt">//--- }
「EURUSD H1 上跑通 SPOT 模型」
这一节把前面用 MQL5 搭好的支持政策优化(SPOT)框架直接丢进实盘历史里验。训练和测试都吃 EURUSD H1 的默认参数指标,训练窗口取 2023 年前 7 个月,测试只用 2023 年 8 月这一个月,避免前视偏差。 自动编码器先吃 500 条轨迹、每条 3591 个环境状态,合计约 180 万个状态-行动-奖励样本,跑了 5 个循环、每循环 50 万次迭代,迭代量比数据集大 40%。之后在 Study.mq5 里训主模型,耗时明显长于自编码器阶段。 光靠训练集里的旧 Agent 政策不可能超过验算成绩,所以经验回放缓冲区和自动编码器都得迭代更新。训模型的同时,在策略测试器里跑 ResearchExORL.mq5 优化,专门探训练集外的策略;主循环结束后又用 Research.mq5 做了 200 次优化。 几轮下来训出的 Actor 政策在训测两段历史都出了利润。测试月共 124 笔交易(92 空 32 多),胜率近 47%,多空盈利占比分别 50% 和 46%,平均盈利比平均亏损高 25%,最大盈利约为最大亏损 2 倍,盈利因子 1.15。外汇高杠杆品种,这种样本外单月结果只说明方法可行,不代表后续月份有同样概率。
把 Actor 锁在训练集里是把双刃剑
SPOT 的核心机制是用训练集数据密度做显式正则化,直接估测行为策略密度来约束可接受动作,等于给策略画了一道围栏。实测里这道围栏让训练过程明显更稳,基于 MQL5 实现的回测也跑出了一个可盈利的 Actor 行为策略,说明在有限数据下离线学习确实站得住。 但围栏也是天花板。把策略强行压在训练集分布内,外推被掐断,探索环境未知子空间的概率就趋近于零。从已跑完的测试看,当训练集本身已经包含次优验算时,这种方法的有效性倾向更高——因为本来就不需要模型去猜没见过的状态。 想刺激探索,可以反过来“翻折”这套正则化,主动鼓励训练集之外的动作。外汇与贵金属属高风险品种,任何离线策略在 MT5 上复现前都先想清楚:你手里的历史样本到底覆盖了多少真实波动 regime,没覆盖的那部分,模型连犯错的机会都没有。
◍ 随包附带的程序清单
这套基于神经网络的多元时序预测方案,落地时不是单文件 EA 能跑通的,作者随文给出了 9 个配套文件。其中 7 个为 EA 或类库:Research.mq5、ResearchRealORL.mq5、ResearchExORL.mq5 负责样本采集,分别覆盖基础、Real-ORL 与 ExORL 三种采集逻辑;Study.mq5 与 StudyCVAE.mq5 做智能体训练与自动编码器学习;Test.mq5 跑模型测试。 剩余两个是底层支撑:Trajectory.mqh 定义系统状态结构,NeuroNet.mqh 封装神经网络创建类,NeuroNet.cl 则是 OpenCL 内核代码库,决定 GPU 侧运算能否拉起。 整套压缩包 MQL5.zip 体积 653.77 KB,在 MT5 里解压后直接进 MetaEditor 编译即可。外汇与贵金属市场高杠杆、高波动,用这类模型前务必先开策略测试器跑历史回测,别盲信离线训练结果。