神经网络变得简单(第 61 部分):离线强化学习中的乐观情绪问题·进阶篇
◍ 堆叠世界层与智能体层的代码骨架
这段构建逻辑把网络拆成 world 和 agent 两套容器,各自用 CLayerDescription 逐层 Add。world 从 layer 6 起挂潜变量层,agent 则先铺输入层再做注意力与卷积。 world 的 layer 6 用 TANH 激活、LatentCount 个节点,layer 7 换 LReLU 同节点数,layer 8 输出层 NRewards 个节点且激活为 None,优化器全走 ADAM。任何一次 Add 失败就 delete 描述符并 return false,避免野指针。 agent 输入层维度算得直接:EmbeddingSize * (4 * HistoryBars + 1),例如 EmbeddingSize=32、HistoryBars=10 时输入宽 1312。随后 layer 1 用稀疏多头注意力,步长 8、窗口输出 32、层数 4;layer 2 卷积窗口 32、步长 32、输出宽 4,激活 LReLU。 在 MT5 里把这段粘进 EA 初始化,改 HistoryBars 从 10 到 20,输入层节点会翻倍到 2592,显存占用可能明显上升,建议先在小样本回测验证。外汇与贵金属杠杆高,这类模型信号仅作概率参考,实盘须控仓。
if(!world.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 = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = TANH; descr.optimization = ADAM; if(!world.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 = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!world.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 = defNeuronBaseOCL; descr.count = NRewards; descr.activation = None; descr.optimization = ADAM; if(!world.Add(descr)) { class="kw">delete descr; class="kw">return false; } 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 = EmbeddingSize * (class="num">4 * HistoryBars + class="num">1); 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 = defNeuronMLMHSparseAttentionOCL; prev_count = descr.count = prev_count / EmbeddingSize; descr.window = EmbeddingSize; descr.step = class="num">8; descr.window_out = class="num">32; descr.layers = class="num">4; descr.probability = Sparse; 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 = defNeuronConvOCL; descr.count = prev_count; descr.window = EmbeddingSize; descr.step = EmbeddingSize; descr.window_out = class="num">4; 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">3 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.optimization = ADAM;
隐空间层与状态存盘的接法
上面这段把第 4~6 层依次挂进智能体:第 4 层用 TANH、维度等于 LatentCount;第 5 层换回 LReLU、同样 ADAM 优化;第 6 层输出维 NActions、激活走 SIGMOID,负责把隐变量映射到动作概率。任何一层 Add 失败就 delete 描述符并回 false,避免半残网络被后续逻辑误用。 SState 结构体里 latent 数组长度固定为 2 * EmbeddingSize,构造函数和 Clear 都调 ArrayInitialize(latent, 0) 清零,operator= 用 ArrayCopy 做深拷贝,保证回放或分叉时状态不串味。 Save 先写数组总长再逐元素 FileWriteFloat,Load 则先读总长并与本地 ArraySize 比对,不一致直接 false——这一步能拦掉大部分版本错配的模型文件。若读取中途 FileIsEnding 触发也会返回失败,外汇与贵金属行情下模型热更新频繁,这种校验可明显降低加载到残缺权重的概率,但信号仍可能因过拟合而失效,属高风险实验。
descr.activation = LReLU; 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 = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = TANH; 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 = 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">6 if(!(descr = new CLayerDescription())) class="kw">return false; 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; } class="kw">struct SState { class="type">float latent[class="num">2 * EmbeddingSize]; } SState::SState(class="type">void) { ArrayInitialize(latent, class="num">0); } class="type">void Clear(class="type">void) { ArrayInitialize(latent, class="num">0); } class="type">void class="kw">operator=(class="kw">const SState &obj) { ArrayCopy(latent, obj.latent); } class="type">bool SState::Save(class="type">int file_handle) { total = ArraySize(latent); if(FileWriteInteger(file_handle, total) < class="kw">sizeof(class="type">int)) class="kw">return false; for(class="type">int i = class="num">0; i < total; i++) if(FileWriteFloat(file_handle, latent[i]) < class="kw">sizeof(class="type">float)) class="kw">return false; class="kw">return true; } class="type">bool SState::Load(class="type">int file_handle) { total = FileReadInteger(file_handle); if(total != ArraySize(latent)) class="kw">return false; for(class="type">int i = class="num">0; i < total; i++) { if(FileIsEnding(file_handle)) class="kw">return false; latent[i] = FileReadFloat(file_handle); } class=class="str">"cmt">//---
「多指标状态向量的逐根填充」
EA 在 OnTick 里先靠 IsNewBar() 拦掉非新K线,避免每跳都重算;随后用 CopyRates 按上一根 K 线时间 iTime(...,1) 拉取 NBarInPattern 根历史,并 ArraySetAsSeries(Rates,true) 把数组倒序,使 Rates[0] 对应最新根。 指标句柄 RSI / CCI / ATR / MACD 以及 Symb 都要 Refresh,否则拿到的可能是上一 tick 的缓存值。这里输入参数 Agents=5、OptimizationAgents=1,代表常规并行智能体数量与优化专用体数量,外汇与贵金属波动大,参数不当会放大回测过拟合风险。 真正把行情压成向量是在 for 循环里:每根 b 抽取 open、rsi、cci、atr、macd、signal,任一为 EMPTY_VALUE 就 continue 跳过。shift = b * BarDescr 定位该根在 sState.state 的起始偏移,依次写入 close-open、high-open、low-open、tick_volume/1000、rsi、cci、atr 共 7 个浮点特征。 开 MT5 把 BarDescr 设成 7 核对偏移,若改了特征维度忘了同步 BarDescr,向量会错位导致后续决策逻辑读错字段。
class="kw">input class="type">int Agents = class="num">5; class="kw">input class="type">int OptimizationAgents = class="num">1; 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="type">int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, class="num">1), NBarInPattern, Rates); if(!ArraySetAsSeries(Rates, true)) class="kw">return; class=class="str">"cmt">//--- 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; 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.account 的前两个槽,再用 PositionsTotal 遍历当前品种持仓,把多空volume和浮动盈亏分别累加。注意这里只处理 Symb.Name() 匹配的持仓,跨品种仓位不会被算进来,做多币种组合时需要自己改过滤条件。 position_discount 这行有点意思:用持仓利润减去「持仓时长 × multiplyer × 利润绝对值」做衰减。multiplyer 固定为 1/(60*60*10)=2.7778e-5,相当于把「小时数 ÷ 10」作为折扣系数,老仓位对状态的贡献会被随时间压低,模型可能倾向更关注新鲜仓位。 account[7] 存的是 Rates[0].time 的 float 化时间戳,随后 bState.Add 把余额变化率、净值/余额比、权益变化率等 8 个归一化值依次入列。最后用 Rates[0].time 除以 2023 全年秒数得到 x,取 sin(2πx) 作周期特征——这条正弦项让状态向量带上了「年内时间位置」的节律信号,回测时换品种周期要重算 x 的分母。 开 MT5 把这段贴进 EA 的 OnTick,打印 bState 数组长度,应该看到技术指标段(shift+9) + 账户8项 + 正弦2项的结构;若发现 account[6] 长期为负,说明持仓折扣侵蚀已大于浮盈,贵金属这类高杠杆品种风险会被放大。
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; 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; class="kw">break; case POSITION_TYPE_SELL: sell_value += PositionGetDouble(POSITION_VOLUME); sell_profit += profit; class="kw">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; class=class="str">"cmt">//--- 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,再让潜变量网络、世界模型和智能体网络依次前向推理,产出各子智能体的动作与价值矩阵。 代码里先用 Rates[0].time 除以 PeriodSeconds(PERIOD_W1) 得到周线归一化时间 x,再取 2.0*M_PI*x 的 cos 与 sin 加入状态;日线同理用 PERIOD_D1。这样网络能感知「现在处在周/日周期的哪个相位」,对外汇和贵金属这种带周期节律的品种,可能提升状态表征的区分度,但高频噪声下也可能过拟合,实盘前务必在 MT5 策略测试器跑多周期回测。 前向部分先 ResetLastError,调 Latent.feedForward 把 bState 送进潜网络;若返回 false 就打印错误码并 return。随后建 actions(Agents×NActions)和 values(Agents×NRewards)零矩阵,循环里 World 与 Agent 逐级 feedForward,把每个子智能体结果按行写回。 最后用 values.Sum(1) 求各智能体价值总和,ArgMax 挑出最优动作行赋给 temp,并计算与上一帧 AgentResult 的绝对差 delta 作为变动幅度。temp[0] 与 temp[3] 互减实现多空仓位净敞口裁剪,之后据 min_lot、step_lot 与 stops 做下单前的边界守卫——买量低于最小手数或 TP/SL 距离不超过 stops 就平买仓。 别把相位编码当万能药 周/日余弦特征只提供周期位置先验,遇到央行决议这类跳空,相位信号会瞬间失效。建议把 MaxTP、MaxSL 和 PeriodSeconds 周期参数化,让小布盯盘脚本扫一组参数看夏普衰减曲线再定标。
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); class=class="str">"cmt">//--- Latent representation ResetLastError(); if(!Latent.feedForward(GetPointer(bState), class="num">1, false)) { PrintFormat("Error of Latent model feed forward: %d",GetLastError()); class="kw">return; } matrix<class="type">float> actions = matrix<class="type">float>::Zeros(Agents, NActions); matrix<class="type">float> values = matrix<class="type">float>::Zeros(Agents, NRewards); for(class="type">class="kw">ulong i = class="num">0; i < (class="type">class="kw">ulong)Agents; i++) { if(!World.feedForward(GetPointer(Latent), -class="num">1, GetPointer(Latent), LatentLayer) || !Agent.feedForward(GetPointer(World), class="num">2,(CBufferFloat *)NULL)) class="kw">return; vector<class="type">float> result; Agent.getResults(result); actions.Row(result, i); World.getResults(result); values.Row(result, i); } vector<class="type">float> temp = values.Sum(class="num">1); temp = actions.Row(temp.ArgMax()); class=class="str">"cmt">//--- PrevBalance = sState.account[class="num">0]; PrevEquity = sState.account[class="num">1]; class=class="str">"cmt">//--- 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(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="type">float delta = MathAbs(AgentResult - temp).Sum(); AgentResult = temp; class=class="str">"cmt">//--- buy control if(temp[class="num">0] < min_lot || (temp[class="num">1] * MaxTP * Symb.Point()) <= stops || (temp[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)(temp[class="num">0] - min_lot) / step_lot) * step_lot; class="type">class="kw">double buy_tp = Symb.NormalizePrice(Symb.Ask() + temp[class="num">1] * MaxTP * Symb.Point());
「买卖双向调仓与状态落库的实现尾段」
上面这段把多空两侧的仓位再平衡写完了:买侧先用 Ask 减掉 temp[2]*MaxSL*Point 算出止损价并规范化,若已有买仓就 TrailPosition trailing;当 buy_value 与目标 lot 不等时,多出就 ClosePartial 平部分,少了就 Trade.Buy 补到目标。卖侧逻辑对称,temp[3] 低于 min_lot 或 TP/SL 距离不足 stops 时直接 CloseByDirection 清掉卖仓,否则按 step_lot 取整后下 Sell 或平 partial。 末尾的 state 回填值得在 MT5 里单步看:shift 取 BarDescr*(NBarInPattern-1),把上一模式尾部的奖励写回 sState.rewards[0] 和 [1];无持仓时 rewards[2] 扣 atr/PrevBalance 作为空仓成本,有仓则置 0。最后 Base.Add 失败就 ExpertRemove,避免脏数据继续跑。 OnInit 里先 ResetLastError,LoadTotalBase 失败直接返回 INIT_FAILED 并打印错误码;随后 Agent.Load 读 Act.nnw 权重,temp 作占位传入。外汇与贵金属杠杆高,这类自学习调仓逻辑在实盘前必须用策略测试器跑足样本,参数误配可能快速放大回撤。
class="type">class="kw">double buy_sl = Symb.NormalizePrice(Symb.Ask() - temp[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(temp[class="num">3] < min_lot || (temp[class="num">4] * MaxTP * Symb.Point()) <= stops || (temp[class="num">5] * MaxSL * Symb.Point()) <= stops) { if(sell_value > class="num">0) CloseByDirection(POSITION_TYPE_SELL); } else { class="type">class="kw">double sell_lot = min_lot + MathRound((class="type">class="kw">double)(temp[class="num">3] - min_lot) / step_lot) * step_lot;; class="type">class="kw">double sell_tp = Symb.NormalizePrice(Symb.Bid() - temp[class="num">4] * MaxTP * Symb.Point()); class="type">class="kw">double sell_sl = Symb.NormalizePrice(Symb.Bid() + temp[class="num">5] * MaxSL * Symb.Point()); if(sell_value > class="num">0) TrailPosition(POSITION_TYPE_SELL, sell_sl, sell_tp); if(sell_value != sell_lot) { if(sell_value > sell_lot) ClosePartial(POSITION_TYPE_SELL, sell_value - sell_lot); else Trade.Sell(sell_lot - sell_value, Symb.Name(), Symb.Bid(), sell_sl, sell_tp); } } class=class="str">"cmt">//--- class="type">int shift = BarDescr * (NBarInPattern - class="num">1); sState.rewards[class="num">0] = bState[shift]; sState.rewards[class="num">1] = bState[shift + class="num">1] - class="num">1.0f; if((buy_value + sell_value) == class="num">0) sState.rewards[class="num">2] -= (class="type">float)(atr / PrevBalance); else sState.rewards[class="num">2] = class="num">0; for(class="type">class="kw">ulong i = class="num">0; i < NActions; i++) sState.action[i] = AgentResult[i]; Latent.getResults(sState.latent); if(!Base.Add(sState)) ExpertRemove(); class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Expert initialization function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">int OnInit() { class=class="str">"cmt">//--- ResetLastError(); if(!LoadTotalBase()) { PrintFormat("Error of load study data: %d", GetLastError()); class="kw">return INIT_FAILED; } class=class="str">"cmt">//--- load models class="type">float temp; if(!Agent.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) ||