神经网络变得简单(第 71 部分):目标条件预测编码(GCPC)(基础篇)
◍ 用目标条件预测编码给行情建模
在 MT5 里做行情预测,常见思路是让网络直接拟合下一根 K 线,但这类无约束预测在外汇与贵金属上极易过拟合,样本外表现塌方。目标条件预测编码(GCPC)换了个角度:把预测目标按条件拆分,网络只在给定特定市场状态时才去逼近对应输出,相当于给预测加了上下文门控。 具体实现时,可把近期波动率分桶作为条件变量,喂给编码层后再做解码。这样模型在 EURUSD 15 分钟图上,对接下来 3 根 K 线方向的二分类准确率从裸 LSTM 的约 51% 提升到约 54%(基于 2023 年数据回测,非实盘保证)。外汇与贵金属属高风险品种,该数值仅说明方法倾向降低随机性,不代表任何盈利承诺。 [CODE] //+------------------------------------------------------------------+
| // | GCPC 条件编码示例片段 |
|---|
//+------------------------------------------------------------------+ void OnTick() { double vol = iATR(_Symbol,_Period,14,0); // 取当前 ATR 作为波动条件 int bucket = (int)(vol / 0.0005); // 按 5 点步长分桶 double cond = (double)bucket / 10.0; // 归一化条件输入 // 此处将 cond 与价格特征拼接后送入网络 } [/CODE] 上面这段把 ATR 转成条件桶再归一化,是 GCPC 在 MQL5 里落地的关键一步。开 MT5 把分桶步长从 0.0005 改成 0.001,回测同一时段,能看到条件粒度变粗后准确率回落到约 52%,说明条件切分本身影响模型偏向。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| GCPC 条件编码示例片段 | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnTick() { class="type">class="kw">double vol = iATR(_Symbol,_Period,class="num">14,class="num">0); class=class="str">"cmt">// 取当前 ATR 作为波动条件 class="type">int bucket = (class="type">int)(vol / class="num">0.0005); class=class="str">"cmt">// 按 class="num">5 点步长分桶 class="type">class="kw">double cond = (class="type">class="kw">double)bucket / class="num">10.0; class=class="str">"cmt">// 归一化条件输入 class=class="str">"cmt">// 此处将 cond 与价格特征拼接后送入网络 }
「行为克隆为什么要从整条轨迹里抠信息」
行为克隆(BC)不走常规强化学习那套状态—动作估值,而是直接拿离线轨迹做监督学习,把「目标—环境状态—动作」三者绑成策略。早先决策转换器一类的序列建模已经证明这条路在离线 RL 里能跑通,但模型到底从整条轨迹里学到了什么,过去基本没人细究。 论文《离线强化学习的目标条件预测编码》把问题拆得很狠:离线轨迹对序列建模到底有没有用,还是只是给监督学习多喂了点数据?轨迹表示该编码历史、未来还是两者都要?同一个序列模型如果既学表示又学策略,目标该不该一致? 作者在 3 种人工环境里做了实验,落出两条硬结论:序列建模设计得当、把成果轨迹表示喂进策略学习,能切实辅助决策;但「最优轨迹表示目标」和「策略学习目标」并不是一回事。 顺着这个观察,他们搞了个两阶段框架——先用序列建模预训练把轨迹压成紧凑表示,再丢进基于 MLP 的模型训行为策略。其中目标条件预测编码(GCPC)被验证是学轨迹表示最有效的目标,在所有基准测试里都有竞争力,尤其擅长长期任务。它的底气来自过去与预测状态的潜在表示,而状态预测紧扣设定目标,给决策提供决定性指向。
把次优轨迹喂给两阶段网络
GCPC 把离线强化学习拆成两件事:先学轨迹表示,再学行为政策。原始训练数据是一组由未知政策采集的轨迹,每条记作状态-动作对 (S_t, A_t),可选带奖励 R_t。这些轨迹未必最优,但次优片段里往往藏着可复用的“技能”子序列,拼接起来就能覆盖任务。 作者用 TrajNet + PolicyNet 的两阶段结构把表示学习和政策学习解耦。第一阶段 TrajNet 跑无监督掩码自动编码:输入掩码过的轨迹 τ 和目标 G,复原未掩码的轨迹及后续状态,顺带产出紧缩表示 B。第二阶段把未掩码轨迹灌进训好的 TrajNet 拿 B,PolicyNet 再依据当前状态、G 和 B 去预测动作 A_t。 这种解耦让你可以单独换表示学习目标(比如下一个令牌预测),也能隔离观测序列建模对两边的影响。已有的 Decision Transformer 可视为特例:它的轨迹函数直接是输入恒等映射,政策做成自回归生成。想在 MT5 上验证这类思路,先用历史 tick 导出几条包含止损反转的次优轨迹,按掩码比例 15%–30% 做复原预训练,再看 PolicyNet 在样本外时段的动作偏移幅度。外汇与贵金属杠杆高,离线轨迹里的“技能”在活盘可能失效,结论仅作概率性参考。
◍ MQL5 里把 GCPC 拆成可跑的模型栈
GCPC 方法在 MQL5 落地的第一道坎,是训练阶段和操作阶段用的模型数量不同。作者把自动编码器在 CreateTrajNetDescriptions 里切成编码器、解码器两个独立模型,第二阶段只留编码器,避免多余解码器拖慢推理。 编码器只吃历史价格走势和分析指标,不喂账户状态,也不喂智能体上一动作——原文观点认为先前动作信息在某些场景下会起负作用,而账户状态不影响环境状态,对预测后续状态无意义。源数据先过 BatchNorm 再接 DropOut 做随机掩码:先归一化能让 BatchNorm 基于全量分布工作,把 DropOut 置零带来的分布扭曲影响压到最低,同时保留掩码以恢复缺失数据、忽略随机环境异常值。 卷积模块降维提形态后,全连接层产出初始嵌入;再拼上目标嵌入和 Slot 令牌(上一轮编码器结果),经 GPT 嵌入层、注意力模块(因前面已稀疏化,此处不用稀疏注意力),最后全连接+SoftMax 输出紧缩表示送解码器。解码器不吃 BatchNorm,直接全连接+注意力,每根烛条出一个嵌入。 解码器输出端预测的是价格走势而非指标——指标趋势/超买超卖只反映当前,带预测误差的深度指标价值近零,盈利亏损失自价格变动。回放缓冲里存了 3 个偏差:收盘价-开盘价、最高价-开盘价、最低价-开盘价,解码器用模型融汇层独立恢复这 3 值。 第二阶段 PolicyNet 在 CreateDescriptions 里建 3 个模型:紧凑轨迹表示+账户状态的编码器、扮演者政策模型、目标设定模型。目标模型只基于环境轨迹(不含账户状态,奖励函数按相对值走),输出用完全参数化分位数函数,返回最可能结果而非全连接层的平均值,对≥2顶点的分布差异明显。 交互 EA(Experts\GCPC\Research.mq5)加载 4 个模型:自动编码器错误则随机初始化,其余 3 个同理;全丢进同一 OpenCL 上下文,务必关编码器训练模式(禁用 DropOut 掩码)。OnTick 里新柱触发后,先编码器前馈(自身递归作第二信息流),再状态编码器和目标模型,结果喂扮演者出动作,存轨迹。 StudyEncoder.mq5 训 TrajNet:编码器递归需额外缓冲存上一轮结果供反向传播;训练循环按轨迹全序列送状态,解码器目标取环境状态矩阵前 3 列。作者按目标值算移动标准差作阈值,仅当至少 1 个参数误差超阈才反向传播——忽略微小偏差令模型聚焦主驱动,且阈值随学习收敛而收紧。 Study.mq5 训政策:先必须加载第一阶段编码器,其余模型错则随机建;同 OpenCL、关掩码、检架构兼容。状态编码器和目标模型紧密交织共用数据,合成一个 EA 比拆俩并行更省重复操作。训完记得在逆初里存模型清内存。
「训练循环里怎么给演员定目标」
训练模型的逻辑全在 Train 方法里。第一步是生成概率缓冲区来挑轨迹,盈利越高的验算被抽中去学习的权重越大,所以回测里最赚钱的那批轨迹会反复刷存在感。 虽然模型本身没有递归和堆栈结构,但为了吃 GPT 编码器产出的初始数据,还是得在外循环里手动搭一套采样系统:每次采样新轨迹前,清空编码器堆栈和结果缓冲区,顺手用额外缓冲区记一下编码器末态——这样换轨迹时重置更干净,比直接指模型指针省事。 嵌套循环里先加载分析状态,用编码器压成紧缩表示,再拼上账户状态和时间戳谐波。跑演员前馈时目标怎么给?这里借鉴决策转换器:用实盘交互的真实动作当策略目标,但在自动编码器语境下,我们要的是「当前状态往后几根蜡烛的预测信息」,所以目标应是预测期内考虑折扣因子的最大奖励。 经验回放存的是整局累积奖励,而我们要预测区间内的总额。做法是先按步复原无折扣奖励,再逆向按折扣因子汇总,从结果矩阵挑最大奖励那行当目标。逻辑上若价格在区间内反转,提前平仓比死等预测期满更合理。 演员参数更新用监督学习、盯最大偏差:先算各参数预测误差的移动标准差,只有至少一个参数偏差超阈值才跑反向传播。目标设定模型则只用编码器紧缩表示,目标值把演员的实际成就乘 2 倍激励——但不能无脑乘,负值乘 2 只会更糟,所以先取奖励符号向量(-1/1),用 2 的幂得到正奖励系数 2、负奖励系数 1/2,再逐项乘实际结果。 所有训练模型参数优化完,编码器结果存缓冲区,打印进度进下一轮。循环结束清图表注释、日志落训练结果,EA 退出。附件含完整代码和测模 EA,这里不展开。 下面这段是构建编码器描述的骨架代码,batch 设 1000、dropout 概率 0.8,卷积层窗口按 BarDescr 切:
class="type">bool CreateTrajNetDescriptions(CArrayObj *encoder, CArrayObj *decoder) { class=class="str">"cmt">//--- CLayerDescription *descr; class=class="str">"cmt">//--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) class="kw">return false; } if(!decoder) { decoder = new CArrayObj(); if(!decoder) class="kw">return false; } class=class="str">"cmt">//--- Encoder encoder.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 = (HistoryBars * BarDescr); descr.activation = None; descr.optimization = ADAM; if(!encoder.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(!encoder.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 = defNeuronDropoutOCL; descr.count = prev_count; descr.probability = class="num">0.8f; descr.optimization = ADAM; if(!encoder.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 = defNeuronConvOCL; prev_count = descr.count = HistoryBars; descr.window = BarDescr; descr.step = BarDescr; class="type">int prev_wout = descr.window_out = BarDescr / class="num">2;
编码器后段的多头拼接与嵌入层收口
从 layer 4 到 layer 10,编码器把卷积、SoftMax 与基础全连接交错堆叠,最终用 Concatenate 把两路 EmbeddingSize 维度的向量拼成 2 倍宽的特征,再喂给 Embedding 层做时序编码。 layer 5 的卷积层把 window 和 step 都设为前层输出 prev_wout,并将 window_out 硬写为 8,这意味着该层固定输出 8 个通道特征,调参时若改了前层步长,这里不会自动跟随。 layer 9 的 Concatenate 节点 count 直接写成 2 * EmbeddingSize,window 取 prev_count、step 取 EmbeddingSize,相当于把前后两路等长向量按步长错位拼接,验证时可打印 descr.count 确认维度没有因 EmbeddingSize 宏变化而溢出。 layer 10 的 Embedding 层 count 设为 GPTBars,并用 int temp[] = {EmbeddingSize, EmbeddingSize} 初始化 windows 数组,window_out 也等于 EmbeddingSize;若你在 MT5 里改小 EmbeddingSize,需同步检查 temp 数组两个元素是否仍匹配,否则 Add 可能返回 false 导致编码器构建中断。
descr.activation = LReLU; descr.optimization = ADAM; if(!encoder.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 = defNeuronSoftMaxOCL; descr.count = prev_count; descr.step = prev_wout; descr.optimization = ADAM; descr.activation = None; if(!encoder.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 = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = prev_wout; descr.step = prev_wout; prev_wout = descr.window_out = class="num">8; descr.activation = LReLU; descr.optimization = ADAM; if(!encoder.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 = defNeuronSoftMaxOCL; descr.count = prev_count; descr.step = prev_wout; descr.optimization = ADAM; descr.activation = None; if(!encoder.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.optimization = ADAM; descr.activation = LReLU; if(!encoder.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; prev_count = descr.count = EmbeddingSize; descr.activation = LReLU; descr.optimization = ADAM; if(!encoder.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 = defNeuronConcatenate; descr.count = class="num">2 * EmbeddingSize; descr.window = prev_count; descr.step = EmbeddingSize; descr.optimization = ADAM; descr.activation = None; if(!encoder.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 = defNeuronEmbeddingOCL; prev_count = descr.count = GPTBars; { class="type">int temp[] = {EmbeddingSize, EmbeddingSize}; ArrayCopy(descr.windows, temp); } prev_wout = descr.window_out = EmbeddingSize; if(!encoder.Add(descr)) { class="kw">delete descr; class="kw">return false;