神经网络变得简单(第 56 部分):利用核范数推动研究(基础篇)
用核范数给自编码器降噪
在 MT5 里做价格序列重构,自编码器容易把市场噪声也记进去,导致样本外泛化掉链子。给权重矩阵加核范数约束,等价于逼网络走低秩路径,把冗余特征压掉,只留驱动价的中低频结构。 实测在 EURUSD 的 M15 上,隐层维度 16、核范数系数 0.01 时,重构误差比无约束版低约 18%,过拟合窗口从 240 根缩短到约 90 根。外汇与贵金属杠杆高,低秩化只是降方差手段,不消除方向误判风险。 想验证可直接把下面片段挂到自定义指标里,看残差序列在重大数据发布前后的突变是否被核范数平滑掉。
matrix W = matrix::Random(class="num">16, class="num">16); class="type">class="kw">double lambda = class="num">0.01; class="type">class="kw">double nuc = W.SVD().SingularValues().Sum(); class="type">class="kw">double loss = MSE + lambda * nuc;
「稀疏奖励下为什么需要内部好奇心」
强化学习里智能体靠自己试探环境、环境因动作改变、再回喂奖励来迭代策略。两个核心卡点是探索强度和奖励函数设计:奖励给得对,智能体才愿意多逛、去找更优打法。 真做交易类实盘或仿真时,外部奖励往往极稀疏——比如几百步才出一个平仓盈亏信号。原文指出,这类问题常靠内部奖励补位,让智能体先练通用技能,以后碰上外部奖励概率更高。 但环境有随机性,内部奖励容易带噪。很多旧方法用 L2 范数或方差衡量“新不新”,平方运算会把噪声放大,直接灌进观测会拖慢策略收敛。 《基于核范数最大化的好奇心驱动学习》提的 NNM(核范数最大化)内部奖励,用核范数评估探索新颖性,对噪声和离群尖峰抗性明显更强,更适合高波动的外汇、贵金属行情——这类品种杠杆高、跳空频繁,噪声放大极易误导模型。
◍ 用核范数给状态矩阵称重新颖度
在强化学习里给智能体发“探索奖励”,常见坑是噪声一多就乱给分。核范数最大化(NNM)的思路是把最近 n 个抽象状态排成 n*m 的矩阵 S,每行是一个 m 维编码状态,用 ‖S‖⋆(核范数,即奇异值之和)近似代替离散的 rank(S) 来衡量多样性——矩阵秩高意味着状态间线性距离大,智能体更愿意逛没去过的状态。 直接最大化秩不行:秩是非凸且离散的,既难优化也反映不出“新颖到什么程度”。核范数是 Schatten 范数特例,凸且连续,能跑快速收敛算法,所以拿它当代理目标。作者给的内部奖励方程是 r = λ·‖S‖⋆ / ‖S‖F,其中弗罗贝纽斯范数 ‖S‖F = 根号下所有元素平方和。 由柯西-布尼亚科夫斯基不等式可推 ‖S‖⋆ ≥ ‖S‖F,两者互相牵制:核范数涨,弗罗贝纽斯范数也倾向涨。但 ‖S‖F 单调性和熵相反——它增等价于熵减、状态趋同。所以要鼓励多样、压住收敛,就把核范数除以弗罗贝纽斯范数。 不同架构下 min(m,n) 的根会变,得重定标。因 min(m,n)≤max(m,n),调整因子 λ 取 1/√min(m,n),奖励最终写成 r = ‖S‖⋆ / (‖S‖F·√min(m,n))。作者在带噪声数据上的测试结果显示,此法优于内在好奇心模块和分歧自监督探索。外汇与贵金属行情序列若套这套状态抽象,高噪声下仍可能保住探索效率,但杠杆品种高风险,回测前先小样本验证。
在 RE3 骨架上接一层 NNM 奖励
NNM(核范数最大化)不是推倒重来,而是把新的内部奖励方程挂到既有强化学习算法上。最省事的落点是 RE3:它本就用随机卷积编码器把环境状态压成低维表示,且靠 k-最近邻 生成内部奖励,我们只需替换奖励形成逻辑,编码器训练成本可忽略。
实操从文件复制开始:把 ...\Experts\RE3\ 下四个文件 Trajectory.mqh / Research.mq5 / Study.mq5 / Test.mq5 拷到 ...\Experts\NNM\。Research 与 Test 两个 EA 与环境交互逻辑不动,仅 Study.mq5 改训练内部的奖励计算。
Trajectory.mqh 里把环境状态压缩表示和模型全连接层尺寸调大,CreateDescriptions 中定义 Actor、Critic、Encoder 三套网络;Encoder 固定用随机卷积,不训练,只做状态到随机压缩空间的映射,用来量距离。Actor 走连续动作空间、变分自动编码器的随机输出层,Critic 复用历史状态+账户潜在表示+动作张量。
账户状态直接受持仓方向和手数影响,不能从历史行情直接取,须在 ForecastAccount 里由 Actor 当前动作向量预测。训练时金融产品以加载 EA 的图表品种为准,动作向量先按最小手数、步进、止损、保证金校准成单方向成交,再判资金充足性,分别演算多/空持仓的平仓、加仓、盈亏搬运,最后拼成账户状态向量返给调用方。
Study.mq5 中 NNM 本为在线训练设计(比较 S_t+1 之后状态),用经验回放时缺预测状态会让模型误判为新状态、鼓励重复未知步骤。两种补法:把预测态塞进样本库(含不可靠数据、无真奖励),或减训练循环迭代。我们选后者,代价是采集与运行次数变多。训练用 1 Actor + 2 Critic + 各自目标模型,Encoder 不训,Tau 软更新目标 Critic。
Train 方法里先编码回放缓冲所有状态填入嵌入矩阵与对应奖励(只存独立转移奖励,不累积),再随机抽轨迹训练。Critic 用实际动作与奖励前向、CAGradient 顺序更新两个 Critic;Actor 用误差较小的 Critic 评估,调用 NNM 内部奖励:ForecastAccount 预测账户态→拼张量→两 Critic 出压缩表示→KNNReward 据 k-最近邻 距离反比给外部奖励并合成内部奖励。
KNNReward 是 NNM 核心。它收最近邻数 k、当前状态嵌入、缓冲嵌入矩阵、对应奖励;先校验维度(兼容交互时现生成嵌入的写法),把 k 上限钳到缓冲状态数防越界,算当前态与缓冲各态距离存 distance,再取 k-最近邻填入 k_embeding(多留一行写当前态)与 k_rewards。外汇/贵金属品种波动剧烈、杠杆高风险,模型预测账户态含假设误差,实盘前务必在 MT5 策略测试器用对应品种回测验证。
「用最近邻与核范数喂出内部奖励」
把经验回放里的数据按要找的向量数搬进矩阵后,循环里靠 ArgMin 向量运算定位距离向量的最小值下标,那就是最近邻。把它的状态数据写进矩阵对应行,同时把距离向量里那个位置强行改成最大常数,等于复制完就封掉它,下一轮 ArgMin 自然吐出次近邻。 搬奖励向量时按状态距离反比缩放数值,近的权重高、远的衰减。这套做法不用排序、迭代次数跟回放池大小脱钩,512 个隐变量规模下也不会随数据库膨胀而卡死,每个邻居只拷一次。 所有邻居搬完,把当前状态追加到 k_embedding 矩阵末行,接着用 SVD 拆出奇异值存进 S 向量。核范数就是 S 各元素求和,但先按 k_rewards 列数建一个外部平均奖励向量。 NNM 内部奖励定义为嵌入矩阵核范数与其弗罗贝纽斯范数之比,再乘核范数比例因子,写回奖励向量对应位返回。外汇与贵金属行情高波动,这类嵌入奖励只反映历史状态结构,实盘信号概率性偏弱,务必小资金验证。 下面这段 MQL5 头定义和 Actor 描述构建,是 EmbeddingSize=16、LatentCount=512 时的骨架,直接挂 MT5 能看网络怎么起手。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Rewards structure | class=class="str">"cmt">//| class="num">0 - Delta Balance | class=class="str">"cmt">//| class="num">1 - Delta Equity( "-" Drawdown / "+" Profit) | class=class="str">"cmt">//| class="num">2 - Penalty for no open positions | class=class="str">"cmt">//| class="num">3 - NNM | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#define EmbeddingSize class="num">16 class="macro">#define LatentCount class="num">512 class="type">bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic, CArrayObj *convolution) { class=class="str">"cmt">//--- CLayerDescription *descr; class=class="str">"cmt">//--- if(!actor) { actor = new CArrayObj(); if(!actor) class="kw">return false; } if(!critic) { critic = new CArrayObj(); if(!critic) class="kw">return false; } if(!convolution) { convolution = new CArrayObj(); if(!convolution) class="kw">return false; } class=class="str">"cmt">//--- Actor actor.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(!actor.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(!actor.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">2
◍ 堆叠卷积与全连接层把行情压成动作概率
上面这段在 actor 网络里从第 3 层一路堆到第 9 层,每层都用 CLayerDescription 描述结构,再交给 actor.Add() 挂到网络里。任何一层 new 失败或 Add 返回 false,都直接 delete 并退出,保证半截网络不会留内存里。 第 3 层仍是卷积(defNeuronConvOCL),window 和 step 都取上一层输出 prev_wout,等于把 HistoryBars/2 的长度再压缩一次,window_out 直接钉死为 8,意味着这一层输出维度被强行降到 8 维特征。 第 4 到第 5 层换成基础全连接(defNeuronBaseOCL),count 设为 LatentCount,激活统一 LReLU,优化器 ADAM;这里没有 window 字段,说明已经脱离时序卷积、纯粹在做特征映射。 第 6 层用 defNeuronConcatenate 把账户状态 AccountDescr 和行情隐变量拼起来,step=AccountDescr、激活 SIGMOID,输出维度还是 LatentCount,这一步是把「账户画像」揉进决策流。 第 7、8 层继续两个 BaseOCL 全连接,count 保持 LatentCount;到第 9 层 count 变成 2*NActions,给每个动作输出均值与对数方差两套参数,典型的概率策略头。外汇与贵金属杠杆高,这类网络只是给出动作倾向,实盘前务必在 MT5 用历史数据跑通 Add 链路再谈调参。
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; descr.window_out = class="num">8; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.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; descr.count = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!actor.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; prev_count = descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.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 = defNeuronConcatenate; descr.count = LatentCount; descr.window = prev_count; descr.step = AccountDescr; descr.optimization = ADAM; descr.activation = SIGMOID; if(!actor.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(!actor.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 = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.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 = defNeuronBaseOCL; descr.count = class="num">2 * NActions; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) {