神经网络变得简单(第 68 部分):离线优先引导政策优化·进阶篇
「用代码搭出强化学习智能体的层结构」
在 MT5 里跑价格行为相关的 AIGC 策略,第一步是把智能体(agent)的神经网络描述对象建出来。下面这段函数负责清空旧描述并逐层挂上 CLayerDescription,任何一层 new 失败就直接返回 false,调用方必须检查返回值。 输入层把 K 线特征、账户状态、时间编码、可行动作和嵌入维度拼成总维度:prev_count = BarDescr*NBarInPattern + AccountDescr + TimeDescription + NActions + EmbeddingSize,优化器统一用 ADAM,激活函数设 None。 第二层是 BatchNorm,batch 写死 1000,用来稳训练;第三层走 Embedding,窗口数组用 ArrayCopy 把五个子维度拷进去,window_out 锁成 EmbeddingSize。 后面几层才是算力大头:SoftMax 层 step 拉到 prev_count*5,多头注意力层(MLMHAttention)把 count 再乘 5,window_out=32、layers=4、step=8。外汇和贵金属波动大、杠杆高,这种结构在实盘前务必用历史数据回测,过拟合概率不低。 别把层数当护城河 堆到 5 层、注意力头 4 个,不代表泛化就强。打开 MT5 把 batch 从 1000 改小到 200 试一轮,显存占用和收敛速度的差异会直接打脸「层越多越好」的直觉。
class="type">bool CreateAgentDescriptions(CArrayObj *agent) { class=class="str">"cmt">//--- CLayerDescription *descr; class=class="str">"cmt">//--- if(!agent) { agent = new CArrayObj(); if(!agent) class="kw">return false; } class=class="str">"cmt">//--- Agent agent.Clear(); class=class="str">"cmt">//--- Input layer if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; class="type">int prev_count = descr.count = (BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions + EmbeddingSize); descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">1 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = class="num">1000; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">2 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronEmbeddingOCL; prev_count = descr.count = HistoryBars; { class="type">int temp[] = {BarDescr * NBarInPattern, AccountDescr, TimeDescription, NActions, EmbeddingSize}; ArrayCopy(descr.windows, temp); } class="type">int prev_wout = descr.window_out = EmbeddingSize; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">3 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronSoftMaxOCL; descr.count = EmbeddingSize; descr.step = prev_count * class="num">5; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">4 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronMLMHAttentionOCL; prev_count = descr.count = prev_count * class="num">5; descr.window = EmbeddingSize; descr.step = class="num">8; descr.window_out = class="num">32; descr.layers = class="num">4; descr.optimization = ADAM; if(!agent.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; descr.type = defNeuronSoftMaxOCL; descr.count = EmbeddingSize; descr.step = prev_count; descr.activation = None;
卷积与全连接层的堆叠方式
这段构建逻辑从第六层开始往后再叠了八层网络结构,前几层用卷积加 SoftMax 交替,后面三层直接退化为全连接基元层。注意第六层窗口和步长都锁死在 EmbeddingSize,window_out 也等于 EmbeddingSize,意味着这一层不做降维,只做特征重组。 第八层是个关键下采样点:window_out 被砍成 prev_wout 的一半,也就是输出宽度直接对半切,配合 LReLU 激活,倾向在保留非线性边缘响应的同时压低后续计算量。 第十到十二层连续三个 defNeuronBaseOCL,count 全部等于 LatentCount,激活统一 LReLU、优化器统一 ADAM。这种堆叠在行情特征提取里常用于把高维卷积输出压进低维潜变量空间,外汇与贵金属波动序列跑这套结构时高风险,过拟合概率不低,建议先用小 LatentCount 在 MT5 策略测试器里验证梯度是否发散。 每一层都包了 Add 失败即 delete 并 return false 的防御写法,漏掉这一句会在 EA 反复重初始化时悄悄吃内存,开 MT5 跑长周期回测时容易爆终端。
descr.optimization = ADAM; if(!agent.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; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = EmbeddingSize; descr.step = EmbeddingSize; prev_wout = descr.window_out = EmbeddingSize; descr.optimization = ADAM; descr.activation = LReLU; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">7 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronSoftMaxOCL; descr.count = prev_count; descr.step = prev_wout; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">8 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = prev_wout; descr.step = prev_wout; prev_wout = descr.window_out = prev_wout / class="num">2; descr.optimization = ADAM; descr.activation = LReLU; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">9 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronSoftMaxOCL; descr.count = prev_count; descr.step = prev_wout; descr.activation = None; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">10 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">11 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">12 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">13 if(!(descr = new CLayerDescription())) class="kw">return false;
◍ 把指标塞进神经网络的 state 向量
强化学习智能体在 MT5 里跑之前,得先把每根 K 线的原始信息和指标值拼成一维浮点数组。上面这段在 OnTick 里只处理新柱:用 CopyRates 拉历史,再把 RSI、CCI、ATR、MACD 四个指标 Refresh 一遍,缺值就跳过该柱。 每个柱占 BarDescr=9 个槽位,依次写进 close-open、high-open、low-open、tick_volume/1000、rsi、cci、atr、macd、signal。注意 tick_volume 除以 1000.0f 是为了把成交量压缩到和价格差同量级,否则网络梯度可能被成交量主导。 账户状态单独存两份:ACCOUNT_BALANCE 和 ACCOUNT_EQUITY 直接转 float 进 sState.account[0/1]。这两数实时反映净值波动,外汇和贵金属杠杆高,权益回撤可能瞬间吃掉余额大头,喂给 Agent 时它本身会学到风险偏好。 最后 bState.AssignArray(sState.state) 把拼好的状态提交给网络;buy_value / sell_value 等变量先置零,留给后续动作决策用。开 MT5 把 NBarInPattern 调到 20,能直接看到 state 长度变成 20*9+2,验证维度没算错。
descr.type = defNeuronBaseOCL; descr.count = NActions; descr.activation = SIGMOID; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- class="kw">return true; } input class="type">class="kw">double Epsilon = class="num">0.5; CNet Agent; CNet Scheduler; 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="type">int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, class="num">1), History, Rates); if(!ArraySetAsSeries(Rates, true)) class="kw">return; RSI.Refresh(); CCI.Refresh(); ATR.Refresh(); MACD.Refresh(); Symb.Refresh(); Symb.RefreshRates(); class=class="str">"cmt">//--- History data class="type">float atr = class="num">0; class=class="str">"cmt">//--- for(class="type">int b = class="num">0; b < (class="type">int)NBarInPattern; b++) { class="type">float open = (class="type">float)Rates[b].open; class="type">float rsi = (class="type">float)RSI.Main(b); class="type">float cci = (class="type">float)CCI.Main(b); atr = (class="type">float)ATR.Main(b); class="type">float macd = (class="type">float)MACD.Main(b); class="type">float sign = (class="type">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">float)(Rates[b].close - open); sState.state[shift + class="num">1] = (class="type">float)(Rates[b].high - open); sState.state[shift + class="num">2] = (class="type">float)(Rates[b].low - open); sState.state[shift + class="num">3] = (class="type">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; } bState.AssignArray(sState.state); class=class="str">"cmt">//--- Account description sState.account[class="num">0] = (class="type">float)AccountInfoDouble(ACCOUNT_BALANCE); sState.account[class="num">1] = (class="type">float)AccountInfoDouble(ACCOUNT_EQUITY); class=class="str">"cmt">//--- class="type">class="kw">double buy_value = class="num">0, sell_value = class="num">0, buy_profit = class="num">0, sell_profit = class="num">0; class="type">class="kw">double position_discount = class="num">0;
「持仓扫描与状态向量的拼接」
这段逻辑干的事很直接:把当前账户里属于监控品种的持仓过一遍,把多空volume、profit分别累加,同时算一个带时间衰减的position_discount。multiplyer = 1.0/(60*60*10) 意味着每持仓10小时,折扣项就按利润绝对值的100%量级线性扣减,对外汇和贵金属这类高杠杆品种,时间成本会被显式塞进特征里。 循环里先用 PositionsTotal() 拿总数,PositionGetSymbol(i) 不等于 Symb.Name() 就 continue,避免把别的品种算进来。switch 按 POSITION_TYPE_BUY / SELL 分流,buy_value、sell_value 累加手数,buy_profit、sell_profit 累加浮盈,这部分是后续决策的基础盘面快照。 position_discount += profit - (current - POSITION_TIME) * multiplyer * MathAbs(profit),这句把‘利润随持仓时长贬值’量化了。比如一笔盈利100刀的买单扛了36000秒(10小时),折扣就是 100 - 36000/36000 * 100 = 0,相当于时间成本吃光利润,模型可能倾向更早平仓。 sState.account 数组下标2~7分别存多空手数、多空利润、折扣、当前K线时间,然后 bState 把余额回撤率、权益占比、权益回撤率等除以 PrevBalance 归一化。最后那段时间标签用 MathSin/MathCos 把年、月、周、日周期编码成连续量,x 以 D'2024.01.01'-D'2023.01.01' 为年尺度基准,让Agent能嗅到季节性节律。 把下面代码直接丢进EA的OnTick前段,Print(bState[7]) 看折扣项随持仓时间怎么掉,比读文档直观。
class="type">class="kw">double multiplyer = class="num">1.0 / (class="num">60.0 * class="num">60.0 * class="num">10.0); class="type">int total = PositionsTotal(); class="type">class="kw">datetime current = TimeCurrent(); for(class="type">int i = class="num">0; i < total; i++) { if(PositionGetSymbol(i) != Symb.Name()) class="kw">continue; class="type">class="kw">double profit = PositionGetDouble(POSITION_PROFIT); class="kw">switch((class="type">int)PositionGetInteger(POSITION_TYPE)) { case POSITION_TYPE_BUY: buy_value += PositionGetDouble(POSITION_VOLUME); buy_profit += profit; break; case POSITION_TYPE_SELL: sell_value += PositionGetDouble(POSITION_VOLUME); sell_profit += profit; break; } position_discount += profit - (current - PositionGetInteger(POSITION_TIME)) * multiplyer * MathAbs(profit); } sState.account[class="num">2] = (class="type">float)buy_value; sState.account[class="num">3] = (class="type">float)sell_value; sState.account[class="num">4] = (class="type">float)buy_profit; sState.account[class="num">5] = (class="type">float)sell_profit; sState.account[class="num">6] = (class="type">float)position_discount; sState.account[class="num">7] = (class="type">float)Rates[class="num">0].time; bState.Add((class="type">float)((sState.account[class="num">0] - PrevBalance) / PrevBalance)); bState.Add((class="type">float)(sState.account[class="num">1] / PrevBalance)); bState.Add((class="type">float)((sState.account[class="num">1] - PrevEquity) / PrevEquity)); bState.Add(sState.account[class="num">2]); bState.Add(sState.account[class="num">3]); bState.Add((class="type">float)(sState.account[class="num">4] / PrevBalance)); bState.Add((class="type">float)(sState.account[class="num">5] / PrevBalance)); bState.Add((class="type">float)(sState.account[class="num">6] / PrevBalance)); class=class="str">"cmt">//--- Time label 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;); bState.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1); bState.Add((class="type">float)MathCos(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1); bState.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1); bState.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x)); class=class="str">"cmt">//--- Prev action bState.AddArray(AgentResult);
强化学习代理怎么落地成下单动作
这段逻辑是神经网络输出转成实际持仓控制的核心:先让调度器和代理跑前向推理,把账户状态、上一帧特征喂进去,拿到 AgentResult 这个浮点数组,再对结果做截断与噪声处理。 Epsilon 大于随机值(MathRand()/32767.0)时,会给 AgentResult 每个元素加 ±0.03 以内的随机扰动,越界就反向减回去,最后 Clip 到 0~1。这一步本质是训练期的探索机制,实盘若忘了关 Epsilon 可能让仓位乱抖。 买控部分先看 AgentResult[0] 与 AgentResult[3] 谁大,互斥减完保证多空不重叠。若买侧信号低于 0.9*min_lot,或 TP/SL 距离不超过 stops(StopsLevel 与 Point 算出的最小止损距离),就平掉已有多单;否则按 min_lot+步长取整补仓或 TrailPosition 移损。 卖控开头同理判断 AgentResult[3] 信号强度,低于 0.9*min_lot 或止盈止损不达标就清卖单。外汇与贵金属杠杆高,这类 Agent 直接下单前务必在策略测试器跑足样本,探索噪声参数错了容易频繁开平仓。
if(!Scheduler.feedForward(GetPointer(bState), class="num">1, false)) class="kw">return; Scheduler.getResults(sState.scheduler); bState.AddArray(sState.scheduler); if(!Agent.feedForward(GetPointer(bState), class="num">1, false, (CBufferFloat *)NULL)) class="kw">return; PrevBalance = sState.account[class="num">0]; PrevEquity = sState.account[class="num">1]; Agent.getResults(AgentResult); if(Epsilon > (class="type">class="kw">double(MathRand()) / class="num">32767.0)) for(class="type">ulong i = class="num">0; i < AgentResult.Size(); i++) { class="type">float rnd = ((class="type">float)MathRand() / class="num">32767.0f - class="num">0.5f) * class="num">0.03f; class="type">float t = AgentResult[i] + rnd; if(t > class="num">1 || t < class="num">0) t = AgentResult[i] - rnd; AgentResult[i] = t; } AgentResult.Clip(class="num">0.0f, class="num">1.0f); class="type">class="kw">double min_lot = Symb.LotsMin(); class="type">class="kw">double step_lot = Symb.LotsStep(); class="type">class="kw">double stops = MathMax(Symb.StopsLevel(), class="num">1) * Symb.Point(); if(AgentResult[class="num">0] >= AgentResult[class="num">3]) { AgentResult[class="num">0] -= AgentResult[class="num">3]; AgentResult[class="num">3] = class="num">0; } else { AgentResult[class="num">3] -= AgentResult[class="num">0]; AgentResult[class="num">0] = class="num">0; } class=class="str">"cmt">//--- buy control if(AgentResult[class="num">0] < class="num">0.9 * min_lot || (AgentResult[class="num">1] * MaxTP * Symb.Point()) <= stops || (AgentResult[class="num">2] * MaxSL * Symb.Point()) <= stops) { if(buy_value > class="num">0) CloseByDirection(POSITION_TYPE_BUY); } else { class="type">class="kw">double buy_lot = min_lot + MathRound((class="type">class="kw">double(AgentResult[class="num">0] + FLT_EPSILON) - min_lot) / step_lot) * step_lot; class="type">class="kw">double buy_tp = Symb.NormalizePrice(Symb.Ask() + AgentResult[class="num">1] * MaxTP * Symb.Point()); class="type">class="kw">double buy_sl = Symb.NormalizePrice(Symb.Ask() - AgentResult[class="num">2] * MaxSL * Symb.Point()); if(buy_value > class="num">0) TrailPosition(POSITION_TYPE_BUY, buy_sl, buy_tp); if(buy_value != buy_lot) { if(buy_value > buy_lot) ClosePartial(POSITION_TYPE_BUY, buy_value - buy_lot); else Trade.Buy(buy_lot - buy_value, Symb.Name(), Symb.Ask(), buy_sl, buy_tp); } } class=class="str">"cmt">//--- sell control if(AgentResult[class="num">3] < class="num">0.9 * min_lot || (AgentResult[class="num">4] * MaxTP * Symb.Point()) <= stops ||
◍ 回测里把盈利轨迹筛出来
上面那段卖单逻辑跑完,状态被写回 Base 后,真正的闭环在 OnTester 里完成。它把 STAT_PROFIT 取出来塞进 Frame,只有 Base.Profit 大于等于 MinProfit 才调用 FrameAdd 推给优化框架,否则返回 0,相当于这趟回测不被记为有效样本。 GetProbTrajectories 干的是另一件事:把 buffer 里每一条轨迹的 Profit 抽成 vector,算标准差和最大值,再手写了一遍冒泡排序(sort 标志位反复翻转直到不再交换)。这一步没有调标准库排序,外汇与贵金属回测中样本量通常不大,自己排也够快,但你要接别的数据源得留意 O(n²) 的代价。 想验证就开 MT5 策略测试器,把 MinProfit 设成 100,跑完去帧数据里看 FrameAdd 触发了几回;若返回 0 次数偏多,说明你的 AgentResult 参数组合绝大多数连最低盈利线都没过,该回去调卖单的 MaxSL 或 MaxTP 系数了。
class="type">class="kw">double OnTester() { class=class="str">"cmt">//--- class="type">class="kw">double ret = class="num">0.0; class=class="str">"cmt">//--- Base.Profit = TesterStatistics(STAT_PROFIT); Frame[class="num">0] = Base; if(Base.Profit >= MinProfit) FrameAdd(MQLInfoString(MQL_PROGRAM_NAME), class="num">1, Base.Profit, Frame); class=class="str">"cmt">//--- class="kw">return(ret); } vector<class="type">class="kw">double> GetProbTrajectories(STrajectory &buffer[], class="type">float lanbda) { class="type">ulong total = buffer.Size(); vector<class="type">class="kw">double> rewards = vector<class="type">class="kw">double>::Zeros(total); for(class="type">ulong i = class="num">0; i < total; i++) rewards[i]=Buffer[i].Profit; class="type">class="kw">double std = rewards.Std(); class="type">class="kw">double max_profit = rewards.Max(); vector<class="type">class="kw">double> sorted = rewards; class="type">bool sort = true; while(sort) { sort = false; for(class="type">ulong i = class="num">0; i < sorted.Size() - class="num">1; i++) if(sorted[i] > sorted[i + class="num">1]) { class="type">class="kw">double temp = sorted[i]; sorted[i] = sorted[i + class="num">1]; sorted[i + class="num">1] = temp; sort = true; } }