神经网络变得简单(第 61 部分):离线强化学习中的乐观情绪问题(基础篇)
◍ 离线强化学习里乐观偏差从哪来
在离线强化学习(Offline RL)中,智能体只能从固定历史数据集中学策略,无法与环境实时交互。这带来一个隐蔽陷阱:当某些状态-动作对在数据集中采样极少,Q 函数估值会因外推误差被高估,模型会「乐观」地偏好这些没被充分验证的动作。 这种乐观情绪不是主观设定,而是训练机制的自然产物。标准 Bellman 更新会把未见过的转移也纳入回溯,数据集覆盖稀疏的区域容易给出偏离真实的偏高期望,导致回测曲线好看、实盘立刻衰减。 在 MT5 里验证这一点很简单:取一段 XAUUSD 的 M15 历史,用仅覆盖震荡段的样本训练 RL 代理,切换至趋势段样本外测试,胜率可能从回测的 58% 掉到 41% 以下。外汇与贵金属杠杆高、跳空频繁,离线数据集若未覆盖极端行情,这类偏差会被放大,实盘风险显著。
离线强化学习里的乐观偏差陷阱
离线强化学习现在用得越来越广,从简单到复杂的问题都能靠历史数据训策略。但有个坑很实在:智能体在训练集上刷出高回报,就容易对自己选的动作过度自信。 问题在于,训练集几乎不可能覆盖环境所有状态和转移。外汇和贵金属这种随机市况里,这种信心经不起推敲,乐观策略可能把回撤和穿仓风险直接放大。 自动驾驶圈早就在对付类似问题——他们目标是降风险、少在线试错。2022年7月那篇讲SPLT转换器的论文(分离潜在轨迹),思路就是把轨迹拆开建模,缓解离线训练里的优化乖离。 开MT5接个离线RL回测,把训练集砍掉20%极端行情再跑一遍,你能直观看到智能体信心曲线和实际胜率之间的裂口。
「SPLT 双流 VAE 怎么把规划分支压平」
SPLT-转换器沿用了 Transformer 的序列生成骨架,但把决策流和环境流拆成两条独立的 VAE。它要啃下两个硬骨头:一是给智能体在任何局面下生成多种行为候选,二是覆盖环境状态迁移里大多数潜在模式,而不是只赌一条主路径。 做法是在 actor 政策与环境模型上各训一个 VAE,为每个流注入随机潜在变量,并在整个规划视野内复用。这样枚举候选轨迹时,分支数不会随步数指数爆炸,测试期搜索行为选项的成本可控。潜在政策变量对应不同层级的意图,类似分层算法的技能;环境潜在变量对应可能的趋势与状态变化倾向。 编码器架构同 Transformer,吃的是历史轨迹里的状态与动作,输出有限取值的离散潜在变量。作者建议对 Transformer 全部输出取平均,把整条轨迹压成一个向量,再过小 MLP 得到分类分布。政策解码器拿原始轨迹加潜在表示,预测下一步最可能动作;环境解码器架构类似,但输出端是三个头,分别预测后续状态、成本与转变奖励。 训练仍是监督式,拿轨迹比对后续动作与环境迁移。实盘或测试时,在规划视野内生成并评估一批含奖励的候选序列,只取最优轨迹的第一个动作去执行;环境一变就重估重排。看似每轮只走一步很浪费,但提前铺几步能压住不确定性,且每次重访状态都可即时纠偏。外汇与贵金属市场跳空频繁,这种重规划机制对滑点和突变风险有缓冲作用,但模型误判仍可能造成连续亏损。
◍ 把 SPLT-转换器拆成三个共享编码器模型
作者这一版实现刻意偏离原论文:不再构建候选轨迹,只从当前状态生成若干候选动作。原因很直白——此前所有尝试里,预测准确率只有 1-2 步,规划视野一长就趋近 0,低质量轨迹只会越推越歪。 为了比较动作与预期奖励,又不至于用评论者模型彻底架空环境模型,他让编码器、扮演者、环境模型共用一条轨迹输入。编码器输出潜在状态,并联喂给两个解码器:扮演者出动作,环境模型出未来折扣奖励。潜在表示里塞入随机性,就能一次采样出多个候选动作与估值,再按估值排位选最优权重步。 工程上做了两处省算力改动。第一,变分自编码层从编码器移到环境模型内,这样每次迭代只跑一次编码器前向,随机性在环境模型里采样产生。第二,轨迹先过嵌入层只留在编码器,其余两模型直接复用嵌入结果,避免三处重复嵌入。 经验回放缓冲区因此要多存一个实体:编码器输出的潜在表示分布(均值+方差),数组大小等于两个嵌入。以下为模型定义方法的代码片段,可见输入层规模由价格柱描述、账户描述、时间描述与动作数拼成。 高风险提示:外汇/贵金属杠杆交易亏损概率高,以下架构仅用于 MT5 内验证算法,不构成任何收益暗示。
class="type">bool CreateDescriptions(CArrayObj *agent, CArrayObj *latent, CArrayObj *world) { class=class="str">"cmt">//--- CLayerDescription *descr; class=class="str">"cmt">//--- latent.Clear(); class=class="str">"cmt">//--- Input layer if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = (BarDescr * NBarInPattern + AccountDescr + TimeDescription + NActions); descr.activation = None; descr.optimization = ADAM; if(!latent.Add(descr))
堆叠七层神经网络的隐变量结构
在 MT5 的 OpenCL 神经框架里,用 CLayerDescription 逐层往 latent 容器里塞描述符,是搭隐变量模型的标准做法。下面这段直接给出了 7 层连续定义的实参,复制进 EA 的 Create 函数就能跑通骨架。 第一层是批归一化(defNeuronBatchNormOCL),batch 写死 1000、激活函数为 None、优化器 ADAM,作用是对前层输出做标准化。第二层 Embedding 把 HistoryBars 根 K 线映射成 EmbeddingSize 维向量,窗口数组由 BarDescr*NBarInPattern、AccountDescr、TimeDescription、NActions 四项构成。 第三层用了稀疏多头注意力(defNeuronMLMHSparseAttentionOCL),count 扩到上一层 4 倍,step=8、window_out=32、layers=4,概率模式为 Sparse。第四层卷积(defNeuronConvOCL)步长等于 window 且 window_out=4,激活 LReLU。 第五到七层都是 defNeuronBaseOCL:第五层 LatentCount 个节点用 LReLU,第六层同数量改 TANH,第七层又回到 LReLU。每层的 Add 失败都会 delete descr 并返回 false,内存不会漏。 把 step 从 8 调到 4 可能让注意力层计算量翻倍,但局部依赖捕捉倾向更细;外汇与贵金属杠杆高,实盘前务必在策略测试器用历史数据验证过拟合风险。
{
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(!latent.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};
ArrayCopy(descr.windows, temp);
}
class="type">int prev_wout = descr.window_out = EmbeddingSize;
if(!latent.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 = defNeuronMLMHSparseAttentionOCL;
prev_count = descr.count = prev_count * class="num">4;
descr.window = prev_wout;
descr.step = class="num">8;
descr.window_out = class="num">32;
descr.layers = class="num">4;
descr.probability = Sparse;
descr.optimization = ADAM;
if(!latent.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 = defNeuronConvOCL;
descr.count = prev_count;
descr.window = prev_wout;
descr.step = prev_wout;
descr.window_out = class="num">4;
descr.optimization = ADAM;
descr.activation = LReLU;
if(!latent.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.optimization = ADAM;
descr.activation = LReLU;
if(!latent.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(!latent.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(!latent.Add(descr))
{
class="kw">delete descr;「变分自编码与稀疏注意力堆叠的层定义」
这段构建逻辑把潜变量空间和时序特征分别放在两个容器里:latent 装编码器侧结构,world 装完整前向网络。latent 的第 8 层直接给 2*EmbeddingSize 个 base 神经元、无激活、ADAM 优化,相当于把嵌入向量原样映射进潜层。 world 在清空后先塞入输入层,节点数同样是 2*EmbeddingSize,随后第一层用 defNeuronVAEOCL 把 prev_count 砍半,即上一节嵌入维度的对折压缩,VAE 结构在这里才真正落地。 第二层用 Concatenate 把历史窗口 4*EmbeddingSize*HistoryBars 与折压后的潜向量拼起来,prev_count 因此变成 step+prev_count,激活走 LReLU;这一步把序列记忆和压缩表示强行并轨。 第三层是多头稀疏注意力,count 被重算为 prev_count/EmbeddingSize,window=EmbeddingSize、step=8、window_out=32、layers=4、probability=Sparse——意味着每层只在大窗内按稀疏概率触发,显存占用可能明显低于稠密注意力。 第四层卷积把 window_out 设为 4、步长等于上层输出宽,输出宽再被压成 4;第五层退回 BaseOCL 给 LatentCount 个节点、LReLU 收口。开 MT5 把 EmbeddingSize 从默认改到 64,看第三层 window_out=32 时回测显存是否先涨后落,是验证这套层叠有没有写错的最快办法。外汇与贵金属行情受杠杆与跳空影响,此类模型信号仅作概率参考,实盘属高风险。
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 = class="num">2 * EmbeddingSize; descr.activation = None; descr.optimization = ADAM; if(!latent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- World if(!world) { world = new CArrayObj(); if(!world) class="kw">return false; } class=class="str">"cmt">//--- world.Clear(); class=class="str">"cmt">//--- Input layer if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = class="num">2 * EmbeddingSize; descr.activation = None; descr.optimization = ADAM; if(!world.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 = defNeuronVAEOCL; prev_count = descr.count = prev_count / class="num">2; descr.activation = None; descr.optimization = ADAM; if(!world.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 = defNeuronConcatenate; descr.step = class="num">4 * EmbeddingSize * HistoryBars; prev_count = descr.count = descr.step + prev_count; 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">3 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(!world.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 = defNeuronConvOCL; descr.count = prev_count; descr.window = prev_wout; descr.step = prev_wout; descr.window_out = class="num">4; descr.optimization = ADAM; descr.activation = LReLU; if(!world.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.optimization = ADAM; descr.activation = LReLU;