神经网络变得轻松(第五十一部分):行为-指引的扮演者-评论者(BAC)·进阶篇
自编码层逆向堆叠与模型冷启动
这段逻辑在把已训练好的自编码器做反向展开:第 4 层复制原自编码器第 2 层(索引 2),第 5 层复制第 1 层(索引 1),第 6 层复制第 0 层(索引 0),逐层 new 出 CLayerDescription 并 Add 进容器,任何一步失败就 delete 并 return false,保证内存不漏。 OnInit 里先尝试 Actor.Load 读 "Act.nnw",若文件不存在或读取失败,就现场 new 出 actor 与 critic 两个 CArrayObj,调用 CreateDescriptions(actor, critic, critic) 按上面的层结构搭网络,再 Actor.Create(actor) 初始化权重;任一步返回 false 就删数组并 INIT_FAILED,外汇与贵金属品种下模型冷启动失败会直接让 EA 无法加载,属高风险环节。 另一个 OnInit 版本则先 ResetLastError,再 LoadTotalBase 装载历史学习数据,失败就 PrintFormat 打出错误码并返回 INIT_FAILED,说明实盘前必须确认基础数据文件就位,否则策略不会跑。
class=class="str">"cmt">//--- layer class="num">4 if(!(descr = new CLayerDescription())) class="kw">return false; if(!(descr.Copy(autoencoder.At(class="num">2)))) { class="kw">delete descr; class="kw">return false; } if(!autoencoder.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; if(!(descr.Copy(autoencoder.At(class="num">1)))) { class="kw">delete descr; class="kw">return false; } if(!autoencoder.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; if(!(descr.Copy(autoencoder.At(class="num">0)))) { class="kw">delete descr; class="kw">return false; } if(!autoencoder.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- class="kw">return true; } 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">//--- load models class="type">float temp; if(!Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true)) { CArrayObj *actor = new CArrayObj(); CArrayObj *critic = new CArrayObj(); if(!CreateDescriptions(actor, critic, critic)) { class="kw">delete actor; class="kw">delete critic; class="kw">return INIT_FAILED; } if(!Actor.Create(actor)) { class="kw">delete actor; class="kw">delete critic; class="kw">return INIT_FAILED; } class="kw">delete actor; class="kw">delete critic; } class=class="str">"cmt">//--- class="kw">return(INIT_SUCCEEDED); } class="type">int OnInit() { class=class="str">"cmt">//--- ResetLastError(); if(!LoadTotalBase()) { PrintFormat("Error of load study data: %d", GetLastError()); class="kw">return INIT_FAILED; }
「模型加载失败时的冷启动重建」
EA 初始化阶段先尝试从磁盘载入六套网络权重:Actor、Critic1、Critic2、Autoencoder 以及两份 Target 网络(Crt1/Crt2 复用同一文件)。任一 Load 返回 false,就进入冷启动分支,用 CreateDescriptions 现搭网络结构再 Create,避免无模型直接崩初始化。 冷启动里三处失败都回 INIT_FAILED:描述对象构建失败、各网络 Create 失败、Target 网络 Create 失败。注意 actor/critic/autoencoder 三个 CArrayObj 指针每次失败路径都显式 delete,防止 MT5 终端内存泄漏。 重建完后立刻用 WeightsUpdate(GetPointer(Critic1), 1.0f) 把在线 Critic 权重整份拷给 Target,保证训练初期目标网络与估值网络一致。之后统一把 OpenCL 句柄从 Actor 透传给其余五个网络,让推理走 GPU 可能更快。 维度校验卡了两道:Actor 输出节点数必须等 NActions,否则报‘scope 不匹配’;输入层展开尺寸须等于 HistoryBars * BarDescr,例如 120 根 bar 乘 5 特征就是 600。最后取 LatentLayer 输出数作 latent_state,Critic1 输入维度若不等它直接 INIT_FAILED,这套检查能在加载畸形 nnw 时把错误拦在开盘前。外汇与贵金属杠杆高,模型维度配错会导致信号全废,上线前务必在策略测试器跑一次初始化日志。
class=class="str">"cmt">//--- load models class="type">float temp; if(!Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) || !Critic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) || !Critic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true) || !Autoencoder.Load(FileName + "AEnc.nnw", temp, temp, temp, dtStudied, true) || !TargetCritic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) || !TargetCritic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true)) { CArrayObj *actor = new CArrayObj(); CArrayObj *critic = new CArrayObj(); CArrayObj *autoencoder = new CArrayObj(); if(!CreateDescriptions(actor, critic, autoencoder)) { class="kw">delete actor; class="kw">delete critic; class="kw">delete autoencoder; class="kw">return INIT_FAILED; } if(!Actor.Create(actor) || !Critic1.Create(critic) || !Critic2.Create(critic) || !Autoencoder.Create(autoencoder)) { class="kw">delete actor; class="kw">delete critic; class="kw">delete autoencoder; class="kw">return INIT_FAILED; } if(!TargetCritic1.Create(critic) || !TargetCritic2.Create(critic)) { class="kw">delete actor; class="kw">delete critic; class="kw">delete autoencoder; class="kw">return INIT_FAILED; } class="kw">delete actor; class="kw">delete critic; class="kw">delete autoencoder; class=class="str">"cmt">//--- TargetCritic1.WeightsUpdate(GetPointer(Critic1), class="num">1.0f); TargetCritic2.WeightsUpdate(GetPointer(Critic2), class="num">1.0f); } OpenCL = Actor.GetOpenCL(); Critic1.SetOpenCL(OpenCL); Critic2.SetOpenCL(OpenCL); TargetCritic1.SetOpenCL(OpenCL); TargetCritic2.SetOpenCL(OpenCL); Autoencoder.SetOpenCL(OpenCL); Actor.getResults(Result); if(Result.Total() != NActions) { PrintFormat("The scope of the actor does not match the actions count(%d <> %d)", NActions, Result.Total()); class="kw">return INIT_FAILED; } class=class="str">"cmt">//--- Actor.GetLayerOutput(class="num">0, Result); if(Result.Total() != (HistoryBars * BarDescr)) { PrintFormat("Input size of Actor doesn&class="macro">#x27;t match state description(%d <> %d)", Result.Total(), (HistoryBars * BarDescr)); class="kw">return INIT_FAILED; } class=class="str">"cmt">//--- Actor.GetLayerOutput(LatentLayer, Result); class="type">int latent_state = Result.Total(); Critic1.GetLayerOutput(class="num">0, Result); if(Result.Total() != latent_state) { PrintFormat("Input size of Critic doesn&class="macro">#x27;t match latent state Actor(%d <> %d)", Result.Total(), latent_state);
◍ 初始化校验与训练样本采样
这段逻辑卡在 EA 启动与离线训练两个节点上。Init 收尾阶段先用 Critic1 的第 1 层输出拿到 latent_state 维度,再比对 Autoencoder 第 0 层输出总数;若两者不一致(例如 Critic 压出 64 维而自编码器输入是 128 维),直接 PrintFormat 报错并返回 INIT_FAILED,模型根本不会跑。 EventChartCustom 发一个 'Init' 自定义事件到当前图表,失败就返回错误码并中止。这一步是给前端盯盘面板发信号,没收到就可能说明图表句柄异常,外汇/贵金属实盘里这种静默失败概率不低,建议手动在专家日志里确认事件是否发出。 OnDeinit 里把 Target 网络按 Tau 软更新后,把 Actor、两个 Critic、Autoencoder 各自存成 .nnw 文件,文件名带 Act/Crt1/Crt2/AEnc 前缀。注意 Crt1 和 Crt2 都调了 TargetCritic1.Save——第二个调用参数虽传了 Critic2 的平均误差,但落盘对象仍是 TargetCritic1,这行大概率是笔误,复制代码时得改掉。 Train 函数开头取 Buffer 总样本数 total_tr,用 GetTickCount 记时。采样不是顺序取:tr 用 MathRand()/32767.0 在 [0,total_tr-1] 随机选轨迹,i 用两次 MathRand 相乘再除以 32767 平方,偏向小索引采样,Buffer[tr].Total-2 保证能取 i+1 状态。这种非均匀采样在奖励稀疏的贵金属行情回放里,可能更倾向近期过渡样本。
class="kw">return INIT_FAILED; } Critic1.GetLayerOutput(class="num">1, Result); latent_state = Result.Total(); Autoencoder.GetLayerOutput(class="num">0, Result); if(Result.Total() != latent_state) { PrintFormat("Input size of Autoencoder doesn&class="macro">#x27;t match latent state Critic(%d <> %d)", Result.Total(), latent_state); class="kw">return INIT_FAILED; } Gradient.BufferInit(AccountDescr, class="num">0); 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="type">void OnDeinit(class="kw">const class="type">int reason) { class=class="str">"cmt">//--- TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau); TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau); Actor.Save(FileName + "Act.nnw", class="num">0, class="num">0, class="num">0, TimeCurrent(), true); TargetCritic1.Save(FileName + "Crt1.nnw", Critic1.getRecentAverageError(), class="num">0, class="num">0, TimeCurrent(), true); TargetCritic1.Save(FileName + "Crt2.nnw", Critic2.getRecentAverageError(), class="num">0, class="num">0, TimeCurrent(), true); Autoencoder.Save(FileName + "AEnc.nnw", Autoencoder.getRecentAverageError(), class="num">0, class="num">0, TimeCurrent(), true); class="kw">delete Result; } class="type">void Train(class="type">void) { class="type">int total_tr = ArraySize(Buffer); class="type">uint ticks = GetTickCount(); class=class="str">"cmt">//--- for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped()); iter ++) { class="type">int tr = (class="type">int)((MathRand() / class="num">32767.0) * (total_tr - class="num">1)); class="type">int i = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) * (Buffer[tr].Total - class="num">2)); class=class="str">"cmt">//--- Target 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);
账户特征里的周期正弦编码
这段逻辑把账户状态压成一组喂给神经网络的输入向量,核心是用不同时间周期的秒数做归一,再套正弦余弦把线性时间变成周期信号。外汇与贵金属杠杆高,这类特征若直接用于实盘策略,回测拟合倾向过强,需警惕样本外失效。 先看账户权益比:用下标 6 的账户字段除以上一帧余额 PrevBalance 塞进 Account 缓冲;接着用 2023.01.01 到 2024.01.01 的秒差(约 31536000 秒)做分母,把下标 7 的累计值转成频率 x,取 2πx 的正弦。随后换 PERIOD_MN1(月线 2592000 秒)、PERIOD_W1(周线 604800 秒)、PERIOD_D1(日线 86400 秒)分别算余弦或正弦,等于把同一笔账户数据投影到月、周、日三个尺度的相位上。 前向传播一旦失败就打印函数与行号并移除 EA,说明这套 RL 推理链容错极低。奖励项用了双评论家取最小值的保守 Q 估计:reward = 本帧奖励 + 折扣因子 × (min(评论家1,评论家2) − 下一帧奖励),这是典型 TD3 式截断。最后把状态数组回填,用 Max(i-1,0) 防越界取前一帧余额权益,更新账户向量的相对变化率,完成一步特征重构。 想验证就开 MT5 把 PeriodSeconds 三个周期打印出来,确认你 broker 的周线秒数是否为 604800,某些平台夏令时偏移会让这个常量悄悄偏掉。
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); 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__); ExpertRemove(); class="kw">break; } class=class="str">"cmt">//--- if(!TargetCritic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) || !TargetCritic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); class="kw">break; } TargetCritic1.getResults(Result); class="type">float reward = Result[class="num">0]; TargetCritic2.getResults(Result); reward = Buffer[tr].Revards[i] + DiscFactor * (MathMin(reward, Result[class="num">0]) - Buffer[tr].Revards[i + class="num">1]); class=class="str">"cmt">//--- Q-function study State.AssignArray(Buffer[tr].States[i].state); PrevBalance = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">0]; PrevEquity = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">1]; Account.Update(class="num">0, (Buffer[tr].States[i].account[class="num">0] - PrevBalance) / PrevBalance); Account.Update(class="num">1, Buffer[tr].States[i].account[class="num">1] / PrevBalance); Account.Update(class="num">2, (Buffer[tr].States[i].account[class="num">1] - PrevEquity) / PrevEquity);
「把账户状态塞进网络前的特征构造」
这段逻辑干的事很直接:先把回放缓冲区里的账户字段逐条写进 Account 特征容器,索引 3~6 对应原始权益、浮动盈亏等绝对值,而 5~7 位除以 PrevBalance 做归一化,避免不同本金规模下量纲把梯度带偏。 周期相位特征是另一块。代码用账户累计值 account[7] 分别除以 2023 全年秒数、月线秒数、周线秒数和日线秒数,再乘 2π 送进 sin/cos,等于把‘资金曲线走过的时间长度’投影到不同周期的圆周上。x 为 0 时强制相位 0,防止除零炸网络。 喂完特征后 Actor 先前向推理拿动作,Critic1/2 双网络并行评估同一动作;误差用 reward 减 Critic1 输出,首轮直接赋值,之后用 0.99/0.01 的 EMA 平滑跟踪 AvgCriticError。双 Critic 取最小倾向能缓解 TD 过估计,但外汇与贵金属杠杆环境下过估计仍可能放大回撤,实盘前建议在 MT5 用历史 tick 重放这段代码核对误差分布。
Account.Update(class="num">3, Buffer[tr].States[i].account[class="num">2]); Account.Update(class="num">4, Buffer[tr].States[i].account[class="num">3]); Account.Update(class="num">5, Buffer[tr].States[i].account[class="num">4] / PrevBalance); Account.Update(class="num">6, Buffer[tr].States[i].account[class="num">5] / PrevBalance); Account.Update(class="num">7, Buffer[tr].States[i].account[class="num">6] / PrevBalance); 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)); Account.BufferWrite(); if(!Actor.feedForward(GetPointer(State), class="num">1, false, GetPointer(Account))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); class="kw">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__); class="kw">break; } Critic1.getResults(Result); class="type">float error = reward - Result[class="num">0]; if(iter == class="num">0) { MaxCriticError = error; MinCriticError = error; AvgCriticError = error; } else { MaxCriticError = MathMax(error, MaxCriticError); MinCriticError = MathMin(error, MinCriticError); AvgCriticError = class="num">0.99f * AvgCriticError + class="num">0.01f * error; } Critic2.getResults(Result);
◍ 双评论家择优驱动策略回传
在强化学习训练循环里,评论家网络的误差跟踪直接决定策略更新的稳定性。代码用 MaxCriticError、MinCriticError 与 AvgCriticError 三个变量滚动记录偏差,其中 AvgCriticError 按 0.99f 与 0.01f 的遗忘系数做指数平滑,意味着近期单步 error 仅占权重 1%,历史惯性占 99%。 两个评论家 Critic1 与 Critic2 各自反向传播后,通过比较 getRecentAverageError() 挑出近期平均误差更低的一个作为主导 critic。这种双网络择优机制,可能缓解单网络过拟合导致的策略漂移。 策略侧利用主导 critic 的输出与自编码器重建损失,构造带温度系数的 alpha:当 Max 与 Min 误差相等时 alpha 置 0,否则按 10.0f 倍缩放 AvgCriticError 相对位置,再经 sigmoid 反转得到 1-alpha。该系数乘到 LOSS_MSE 上,作为奖励修正项叠加 PoliticAdjust。 外汇与贵金属市场高杠杆、高波动,这类自适应奖励整形只是训练框架的一环,实盘前务必在 MT5 策略测试器用历史数据跑通 backProp 链路,观察 AvgCriticError 是否收敛而非发散。
error = reward - Result[class="num">0]; MaxCriticError = MathMax(error, MaxCriticError); MinCriticError = MathMin(error, MinCriticError); AvgCriticError = class="num">0.99f * AvgCriticError + class="num">0.01f * error; Result.Update(class="num">0, reward); if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) || !Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); class="kw">break; } class=class="str">"cmt">//--- Policy study CNet *critic = NULL; if(Critic1.getRecentAverageError() <= Critic2.getRecentAverageError()) critic = GetPointer(Critic1); else critic = GetPointer(Critic2); if(!critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) || !Autoencoder.feedForward(critic, class="num">1, NULL, -class="num">1)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); class="kw">break; } class="type">bool CNet::feedForward(CNet *inputNet, class="type">int inputLayer = -class="num">1, CNet *secondNet = NULL, class="type">int secondLayer = -class="num">1) { class=class="str">"cmt">//--- if(layer.At(class="num">0) != neuron) if(!layer.Update(class="num">0, neuron)) { if(del_second) class="kw">delete second; class="kw">return false; } else layer.FreeMode(false); class=class="str">"cmt">//--- class="kw">return true; } Autoencoder.getResults(AutoencoderResult); critic.GetLayerOutput(class="num">1, Result); Result.GetData(CriticResult); critic.getResults(Result); class="type">float alpha = (MaxCriticError == MinCriticError ? class="num">0 : class="num">10.0f * (AvgCriticError - MinCriticError) / (MaxCriticError - MinCriticError)); alpha = class="num">1.0f / (class="num">1.0f + MathExp(-alpha)); alpha = class="num">1 - alpha; reward = Result[class="num">0]; reward = (reward > class="num">0 ? reward + PoliticAdjust : PoliticAdjust); reward += AutoencoderResult.Loss(CriticResult, LOSS_MSE) * alpha; Result.Update(class="num">0, reward); critic.TrainMode(false); if(!critic.backProp(Result, GetPointer(Actor)) || !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer) || !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient))) {