神经网络变得轻松(第五十部分):软性扮演者-评价者(模型优化)·进阶篇
分位网络的反向梯度与软更新内核
在 MT5 的 OpenCL 内核里,SAC_OutputGradient 负责把输出梯度按分位(quantile)拆回给分位值和分位点各自的反传梯度。每个 action 对应一段长度为 count_quants 的偏移,内核用 get_global_id(0) 拿到并行序号,再逐分位比对前向输出与激活后的 quant 是否相等,相等才写梯度,否则清零——这意味着前向和反向必须严格同激活路径,否则梯度全 0。 激活分支里 case 0 走 tanh、case 1 走 sigmoid(1/(1+exp(-quant)))、case 2 对负值乘 0.01f 做 leaky 式压制;这些选择在外汇小时线回测中会影响分位分布尾部厚度,贵金属跳空时 case 2 的 0.01 系数可能让下方分位梯度衰减过快。 SoftUpdateAdam 内核接着做目标网络软更新,传入 target、source 以及一阶矩 matrix_m、二阶矩 matrix_v,典型用法是以 0.005 量级的 tau 把在线网络慢拷给目标网络,避免训练振荡。开 MT5 把这段内核塞进自定义指标或 EA 的 CL 上下文,改 case 2 的 0.01f 为 0.1f 对比 EURUSD 的 quantile 带宽,能直接看到梯度回流差异。 外汇与贵金属杠杆高、滑点随机,任何网络输出只代表条件概率倾向,实盘前请用历史数据在策略测试器跑通内核编译。
__kernel class="type">void SAC_OutputGradient(__global class="type">class="kw">float* quantiles, __global class="type">class="kw">float* delta_taus, __global class="type">class="kw">float* output_gr, __global class="type">class="kw">float* quantiles_gr, __global class="type">class="kw">float* taus_gr, __global class="type">class="kw">float* output, class="kw">const class="type">int count_quants, class="kw">const class="type">int activation ) { class="type">size_t action = get_global_id(class="num">0); class="type">int shift = action * count_quants; for(class="type">int i = class="num">0; i < count_quants; i++) { class="type">class="kw">float quant = quantiles[shift + i]; class="kw">switch(activation) { case class="num">0: quant = tanh(quant); class="kw">break; case class="num">1: quant = class="num">1 / (class="num">1 + exp(-quant)); class="kw">break; case class="num">2: if(quant < class="num">0) quant = quant * class="num">0.01f; class="kw">break; } if(output[i] == quant) { class="type">class="kw">float gradient = output_gr[action]; quantiles_gr[shift + i] = gradient * delta_taus[shift + i]; taus_gr[shift + i] = gradient * quant; } else { quantiles_gr[shift + i] = class="num">0; taus_gr[shift + i] = class="num">0; } } } __kernel class="type">void SoftUpdateAdam(__global class="type">class="kw">float *target, __global class="kw">const class="type">class="kw">float *source, __global class="type">class="kw">float *matrix_m, __global class="type">class="kw">float *matrix_v,
◍ Adam 软更新在 OpenCL 内核里的落地
这段内核函数把 Adam 优化器的动量累积搬到了 GPU 上做并行软更新:每个线程拿全局 id 对应一条权重,先取一阶动量 m 与二阶动量 v,再按 b1、b2 做指数滑动平均。梯度 g 用 source[i] 减当前权重算,m 和 v 的更新公式与标准 Adam 一致,只是把学习率换成了 tau 乘动量比。 delta 的计算做了除零保护:v 为 0 时分母取 1.0f,避免 sqrt(0) 引发 NaN。只有当 delta 与梯度同号(delta*g>0)才把权重朝目标推,并用 clamp 限制在 ±MAX_WEIGHT 内,这一步能压住外汇样本里偶发的梯度尖刺。 WeightsUpdateAdam 是 CPU 侧的调度封装:先校验 OpenCL 句柄、源层类型与权重总数一致,再设 global_work_size 为 Weights.Total()。五个 SetArgumentBuffer 分别绑 target、source、一阶与二阶动量缓冲以及 tau 标量,任何一步失败就 printf 出错函数名与行号并返回 false,方便在 MT5 策略测试器里直接定位。 实盘接这套前先确认 MAX_WEIGHT 宏在你的头文件里不是默认 1.0——贵金属波动大时权重被夹太死会拖慢收敛,调 tau 建议从 0.001 起在 EURUSD 的 M15 上跑回测看动量曲线。
class="kw">const class="type">class="kw">float tau, class="kw">const class="type">class="kw">float b1, class="kw">const class="type">class="kw">float b2 ) { class="kw">const class="type">int i = get_global_id(class="num">0); class="type">class="kw">float m, v, weight; m = matrix_m[i]; v = matrix_v[i]; weight=target[i]; class="type">class="kw">float g = source[i] - weight; m = b1 * m + (class="num">1 - b1) * g; v = b2 * v + (class="num">1 - b2) * pow(g, class="num">2); class="type">class="kw">float delta = tau * m / (v != class="num">0.0f ? sqrt(v) : class="num">1.0f); if(delta * g > class="num">0) target[i] = clamp(weight + delta, -MAX_WEIGHT, MAX_WEIGHT); matrix_m[i] = m; matrix_v[i] = v; } class="type">bool CNeuronBaseOCL::WeightsUpdateAdam(CNeuronBaseOCL *source, class="type">class="kw">float tau) { if(!OpenCL || !source) class="kw">return class="kw">false; if(Type() != source.Type()) class="kw">return class="kw">false; if(!Weights || Weights.Total() == class="num">0) class="kw">return true; if(!source.Weights || Weights.Total() != source.Weights.Total()) class="kw">return class="kw">false; class="type">uint global_work_offset[class="num">1] = {class="num">0}; class="type">uint global_work_size[class="num">1] = {Weights.Total()}; ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_target, getWeightsIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return class="kw">false; } if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_source, source.getWeightsIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return class="kw">false; } if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_matrix_m, getFirstMomentumIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return class="kw">false; } if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_matrix_v, getSecondMomentumIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return class="kw">false; } if(!OpenCL.SetArgument(def_k_SoftUpdateAdam, def_k_sua_tau, (class="type">class="kw">float)tau)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return class="kw">false; }
「GPU 上的 Adam 软更新与行情特征拼装」
在 MT5 的 OpenCL 封装里,Adam 优化器的软更新靠内核参数逐个塞入。下面这段把一阶矩衰减系数 b1、二阶矩衰减系数 b2 作为 float 写进 def_k_SoftUpdateAdam 内核,任一步 SetArgument 失败就打印函数名、错误码和行号并退出,避免脏状态继续跑。 Execute 调用只发 1 个 work-group(global_work_offset 与 global_work_size 控制覆盖范围),内核返回 true 才代表本次权重滑动更新完成。若你改了 b1/b2 的默认值,必须同步确认内核签名里的 def_k_sua_b1 / def_k_sua_b2 偏移量没错位,否则 GPU 端读到的会是相邻内存的脏数。 行情侧的特征向量按 BarDescr=9、AccountDescr=12 两条宏固定长度拼。单根 bar 贡献 9 维:收盘减开盘、最高减开盘、最低减开盘、tick_volume/1000.0、小时、星期几、月份、外加 rsi/cci/atr/macd/sign 里实际取了哪几个由上下文决定,但前缀 4 个价差与 3 个时间分量必然占头 7 位。 账户侧用 Rates[0].time 分别除以年、月、周、日周期秒数再做正余弦变换,把绝对时间压成周期信号。注意 D'2024.01.01'-D'2023.01.01' 是硬编码的 365 天秒数近似,遇闰年会产生约 0.27% 的频率漂移,回测跨年品种时倾向手动换成 PeriodSeconds(PERIOD_Y1) 类的动态值。
if(!OpenCL.SetArgument(def_k_SoftUpdateAdam, def_k_sua_b1, (class="type">class="kw">float)b1)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return class="kw">false; } if(!OpenCL.SetArgument(def_k_SoftUpdateAdam, def_k_sua_b2, (class="type">class="kw">float)b2)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return class="kw">false; } if(!OpenCL.Execute(def_k_SoftUpdateAdam, class="num">1, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); class="kw">return class="kw">false; } class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CNeuronBaseOCL::WeightsUpdate(CNeuronBaseOCL *source, class="type">class="kw">float tau) { if(optimization == ADAM) class="kw">return WeightsUpdateAdam(source, tau); class=class="str">"cmt">//--- ........ ........ State.Add((class="type">class="kw">float)Rates[b].close - open); State.Add((class="type">class="kw">float)Rates[b].high - open); State.Add((class="type">class="kw">float)Rates[b].low - open); State.Add((class="type">class="kw">float)Rates[b].tick_volume / class="num">1000.0f); State.Add((class="type">class="kw">float)sTime.hour); State.Add((class="type">class="kw">float)sTime.day_of_week); State.Add((class="type">class="kw">float)sTime.mon); State.Add(rsi); State.Add(cci); State.Add(atr); State.Add(macd); State.Add(sign); class="type">class="kw">double x = (class="type">class="kw">double)Rates[class="num">0].time / (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">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1); Account.Add((class="type">class="kw">float)MathCos(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1); Account.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1); Account.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); class="macro">#define BarDescr class="num">9 class=class="str">"cmt">//Elements for class="num">1 bar description class="macro">#define AccountDescr class="num">12 class=class="str">"cmt">//Account description
SAC 模型的两轮训练与样本库迭代
做模型改造后真正进训练,第一步是用 Research.mq5 跟环境交互攒数据。我弃了对冲,只做成交量预测差异大的方向,并给预测成交量降档。奖励主体原用账户余额相对变化,值稀薄远低于 1,而熵分量初期在 8–12 波动,量级悬殊,所以把熵除以余额并引入 LogProbMultiplier 约简率来填豁口。 第一轮数据采集跑 EURUSD H1,策略测试器全参数枚举 2023 年前 5 个月,起始资金 10000 美元,收了 200 次验算、超 50 万条「状态→动作→新状态→奖励」。这阶段没预训练模型,每次验算随机初始化,不更新参数,200 次全随机独立且都不盈利。 真正训练在 Study.mq5:账户状态向量补时间戳;评论者训练时从目标奖励剔掉熵分量,且训练扮演者时禁用在用评论者的参数更新,只传梯度,逼出客观评论者;偶数/奇数迭代交替 Critic1、Critic2,目标模型只更新一个。首轮循环 10 万次(默认),仅覆盖样本库 1/5,别指望出显著结果。 删掉随机样本库后重跑环境研究 EA 优化,但偏移代理者迭代值防缓存加载。新库用预训练模型探索,动作落在学习概率分布内,只留最后一次验算。第二轮 Study.mq5 把迭代提到 50 万次。 测模型切到 Test.mq5,用 2023 前 5 个月跑 10 次验算,盈利上限约 1/4–1/5;回 Research.mq5 设最小盈利门槛只存好验算,优化模式再跑 100 次。逐渐抬高外部参数里的盈利标准反复训,直到盈利不再随训练提升。若随机政策下几次完美验算结果几乎一致,说明已逼近确定性策略——外汇贵金属波动剧烈、杠杆高风险大,这种确定性在某些任务里可能更稳,但不等于普适优解。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Expert tick function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnTick() { class=class="str">"cmt">//--- ......... ......... class=class="str">"cmt">//--- class="type">class="kw">float atr = class="num">0; for(class="type">int b = class="num">0; b < (class="type">int)HistoryBars; b++) { class="type">class="kw">float open = (class="type">class="kw">float)Rates[b].open; class="type">class="kw">float rsi = (class="type">class="kw">float)RSI.Main(b); class="type">class="kw">float cci = (class="type">class="kw">float)CCI.Main(b); atr = (class="type">class="kw">float)ATR.Main(b); class="type">class="kw">float macd = (class="type">class="kw">float)MACD.Main(b); class="type">class="kw">float sign = (class="type">class="kw">float)MACD.Signal(b); if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE) class="kw">continue; class=class="str">"cmt">//--- class="type">int shift = b * BarDescr; sState.state[shift] = (class="type">class="kw">float)(Rates[b].close - open); sState.state[shift + class="num">1] = (class="type">class="kw">float)(Rates[b].high - open); sState.state[shift + class="num">2] = (class="type">class="kw">float)(Rates[b].low - open); sState.state[shift + class="num">3] = (class="type">class="kw">float)(Rates[b].tick_volume / class="num">1000.0f); sState.state[shift + class="num">4] = rsi; sState.state[shift + class="num">5] = cci; sState.state[shift + class="num">6] = atr; sState.state[shift + class="num">7] = macd; sState.state[shift + class="num">8] = sign; } State.AssignArray(sState.state); class=class="str">"cmt">//--- ........ ........ class=class="str">"cmt">//--- Account.Clear(); Account.Add((class="type">class="kw">float)((sState.account[class="num">0] - PrevBalance) / PrevBalance)); Account.Add((class="type">class="kw">float)(sState.account[class="num">1] / PrevBalance));
◍ 把账户状态压成强化学习的特征向量
这段逻辑干的事很直接:把每一根 K 线时的账户变化,连同时间周期的正弦余弦编码,一起塞进 Account 这个特征容器,供后续 Actor 网络推理。外汇与贵金属杠杆高,这类自编码特征若训练分布偏移,实盘可能放大回撤。 先看特征拼接的前半段:权益相对前值的增长率、账户数组第 2/3 项原样入列,第 4~6 项都除以 PrevBalance 做归一。时间项用了四个尺度——年、月、周、日,分别取 MathSin 或 MathCos 把时间戳折成周期信号,避免网络直接啃绝对时间。 推理结束后,Actor.getResults 拿出动作向量 temp,用 MathAbs(ActorResult - temp).Sum() 算与上轮动作的绝对偏移 delta,再把 temp 回写进 sState.action。若 temp[0] 与 temp[3] 谁大就减谁,把双向仓位差做互斥归零,这是多空互斥的一个粗糙处理。 奖励函数里 reward 以账户权益变化率 Account[0] 打底;若买卖价值合计为 0,就扣掉 atr/PrevBalance 作为空仓惩罚。若策略输出了对数概率,再按 LogProbMultiplier 把 temp.Sum()/PrevBalance 加进 reward,最后 Base.Add 写经验池,失败则 ExpertRemove 自停。 训练循环 Train 里每轮先 Account.Clear,再从重放缓冲 Buffer[tr].States[i+1] 重新拼一套特征:余额变化率、余额占比、权益变化率、账户项原样,和在线推理用的那套口径并不完全一致,复现时得留意两套归一顺序。
Account.Add((class="type">class="kw">float)((sState.account[class="num">1] - PrevEquity) / PrevEquity)); Account.Add(sState.account[class="num">2]); Account.Add(sState.account[class="num">3]); Account.Add((class="type">class="kw">float)(sState.account[class="num">4] / PrevBalance)); Account.Add((class="type">class="kw">float)(sState.account[class="num">5] / PrevBalance)); Account.Add((class="type">class="kw">float)(sState.account[class="num">6] / PrevBalance)); class="type">class="kw">double x = (class="type">class="kw">double)Rates[class="num">0].time / (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">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1); Account.Add((class="type">class="kw">float)MathCos(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1); Account.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1); Account.Add((class="type">class="kw">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) if(!Account.BufferWrite()) class="kw">return; class=class="str">"cmt">//--- vector<class="type">class="kw">float> temp; Actor.getResults(temp); class="type">class="kw">float delta = MathAbs(ActorResult - temp).Sum(); ActorResult = temp; class=class="str">"cmt">//--- if(temp[class="num">0] >= temp[class="num">3]) { temp[class="num">0] -= temp[class="num">3]; temp[class="num">3] = class="num">0; } else { temp[class="num">3] -= temp[class="num">0]; temp[class="num">0] = class="num">0; } class=class="str">"cmt">//--- class="type">class="kw">float reward = Account[class="num">0]; if((buy_value + sell_value) == class="num">0) reward -= (class="type">class="kw">float)(atr / PrevBalance); for(class="type">class="kw">ulong i = class="num">0; i < temp.Size(); i++) sState.action[i] = temp[i]; if(Actor.GetLogProbs(temp)) reward += LogProbMultiplier * temp.Sum() / (class="type">class="kw">float)PrevBalance; if(!Base.Add(sState, reward)) ExpertRemove(); } class="type">void Train(class="type">void) { class=class="str">"cmt">//--- for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped()); iter ++) { class=class="str">"cmt">//--- 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]);