神经网络变得简单(第 84 部分):可逆归一化(RevIN)(基础篇)
「给时序特征加一层可逆归一化」
在 MT5 里跑神经网络类策略,最容易被忽略的是训练集与实时行情的统计偏移。同一品种在不同时间段均值方差漂移,直接喂原始价格,模型在样本外常常发飘。 可逆归一化(RevIN)的思路很直接:在输入层做标准化,在输出层把均值方差逆变换回去,让网络只学残差结构、不被量纲带着走。 原文给出的实现里,归一化阶段用训练窗口的均值与标准差,推理时若用固定统计量就可能积累误差;更稳的做法是滑动更新统计量,但会增加一点计算开销。 下面这段 MQL5 片段演示了 RevIN 的正逆变换核心,复制进自定义指标即可在可视窗口比对归一前后序列。外汇与贵金属杠杆高,任何特征工程改动都先在小周期回测,实盘前务必验证样本外稳定性。
class="type">class="kw">double RevIN_Normalize(class="type">class="kw">double value, class="type">class="kw">double mean, class="type">class="kw">double std) { class="kw">return (value - mean) / std; class=class="str">"cmt">// 减均值除标准差,得到零均值单位方差 } class="type">class="kw">double RevIN_Denormalize(class="type">class="kw">double norm_val, class="type">class="kw">double mean, class="type">class="kw">double std) { class="kw">return norm_val * std + mean; class=class="str">"cmt">// 乘标准差加均值,还原原始量纲 }
◍ 编码器训练错位与RevIN补丁
前面那套构象模型在回测里表现不差,但训练逻辑其实跑偏了:我们把时间序列塞进编码器,却只用预测误差去压梯度,没让它真正去猜下一帧环境状态。编码器在训练时更像是后续策略网络的一个零件,目标是给扮演者提取好用的特征,而不是还原环境演化。 当三个任务的误差梯度方向不一致时,编码器会去找折中解,这种“中庸之道”离单任务最优通常很远。外汇与贵金属行情非平稳,用这类折中特征做策略输入,实盘踩坑概率偏高。 论文《可逆实例归一化抵消精确时间序列预测中的分布移位》给了一个直接解法:RevIN。它在输入层做归一化、输出层做逆归一化,用相等的缩放和移位量把原始分布信息还回去,对称结构能压住非平稳的均值方差、再在近似对称位置恢复。 下面这段 MT5 训练循环就暴露了原问题:编码器梯度回传时目标并非预测后续状态。 代码逐行拆解:
| if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder)) | !Encoder.backPropGradient((CBufferFloat*)NULL)) |
|---|
— 评论者网络反向传播结果到编码器,编码器自身无外部梯度目标,仅接收来自评论者的梯度。
| if(!Actor.backProp(GetPointer(bActions), GetPointer(Encoder)) | !Encoder.backPropGradient((CBufferFloat*)NULL)) |
|---|
— 扮演者用动作缓冲回传,编码器再次只做梯度汇聚,不预测环境。 Critic.TrainMode(false); — 切评论者到推理态,准备下一轮。
| if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder)) | !Actor.backPropGradient((CNet *)GetPointer(Encoder), -1, -1, false) | !Encoder.backPropGradient((CBufferFloat*)NULL)) |
|---|
— 三轮回传里编码器始终没有“预测下一状态”的显式损失,只是被上下游拽着走。 想验证的话,在 MT5 里把编码器损失加上一步状态重建项,再套一层 RevIN,看验证集分布移位是否收窄。
if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder)) || !Encoder.backPropGradient((CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } if(!Actor.backProp(GetPointer(bActions), GetPointer(Encoder)) || !Encoder.backPropGradient((CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } Critic.TrainMode(false); if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder)) || !Actor.backPropGradient((CNet *)GetPointer(Encoder), -class="num">1, -class="num">1, false) || !Encoder.backPropGradient((CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
RevIN 怎么把非稳态塞回预测里
RevIN 解决的是多元时间序列预测里的分布漂移:给输入 X_i ∈ R^{K*T_x} 和目标 Y_i ∈ R^{K*T_y},K 是变量数、T_x 是输入长度、T_y 是预测长度,N 是样本数。它用对称的一对层做归一化和逆归一化,输入先按自身均值和标准差归一化,让序列均值方差更一致、非稳态信息被压下去,模型只需学局部动态。 归一化后数据分布和原始不同,模型光看归一化输入很难还原真实尺度。RevIN 在输出层用同一套均值和标准差做逆运算,把非稳态特征显式加回预测,得到最终输出 ŷ_i,相当于把模型结果从归一化空间拉回原始时间序列值。 这层几乎可以插到网络任意对称位置,作为可训练归一化层适配任意深度网络。作者验证过加在中间层也有效,但在编码器-解码器结构的近似对称层(常直接取输入层和输出层)效果最好,因为多数时序模型编解码边界模糊,输入输出层天然构成一对。 外汇与贵金属行情属高波动高风险市场,RevIN 仅降低分布偏差、提升模型对局部动态的拟合概率,不保证预测方向,实盘前需用 MT5 历史数据做walk-forward验证。
「在 MT5 里落地 RevIN 逆归一化层」
把 RevIN 方法从纸面搬到 MQL5,核心动作是新建一个 CNeuronRevINDenormOCL 逆归一化层,它直接从已有的 CNeuronBatchNormOCL 批量归一化层派生。复用归一化阶段训练出来的均值与偏差参数做逆变换,模型输出层不再更新权重,只把误差梯度回传前层,因此类里所有参数更新方法都用空存根覆盖掉。 初始化时有个坑:描述模型架构时归一化层还没创建,只能用层序号指代。真正建对象是在 CNet::Create 里,逆归一化层位置必在归一化层之后,通过模型内部接口拿到已存在的归一化层指针再传进去;加载预训练模型则在 CNet::Load 里补一步,用 “-1” 和 NULL 先建空白对象,读盘后再按保存的标识符绑定同步参数,否则输入分布和预测之间会裂开。 前馈和反向都在 OpenCL 端写内核:RevInFeedForward 传源数据、归一化参数、结果三个缓冲区,按线程 ID 算偏移,并用线程号对归一参数缓冲区大小取余来保持环境状态结构;若批量大小 ≤1 直接透传上层数据不复制。反向内核 RevInHiddenGradient 多收前层梯度与激活函数,逆归一化导数因参数恒定而简化,调完梯度再乘前层激活导数写回。 模型架构上,编码器堆 5 层 CNeuronConformer,解码器用 tanh 输出(值域 -1 到 1),末层接逆归一化;扮演者/评论者 EA 不从编码器最后一层取数,而是用 LatentLayer 常量指向倒数第二层拿归一化预测,避免分布被还原后不可比。训练用 StudyEncoder.mq5 跑环境预测,Study.mq5 加载预训练编码器且只训策略不存编码器。外汇与贵金属杠杆高,这套实现仅降低分布错位概率,不保证样本外收益。
◍ 可逆实例归一化的反归一化层实现
在基于 OpenCL 的神经网络推理里,CNeuronRevINDenormOCL 继承自 CNeuronBatchNormOCL,专门负责把批归一化后的特征做反归一化还原。它内部只多记一个 iBatchNormLayer 索引,用来回指对应的归一化层,其余前向、权重更新接口大多直接返回 true,说明这一层不参与常规梯度权重学习。 看 Init 方法能发现一个硬约束:当传入 NormLayer > 0 时,normLayer 指针不能为空,且其 Type() 必须等于 defNeuronBatchNormOCL,否则直接返回 false。也就是说反归一化层必须绑定一个合法的批归一化层对象,运行时若乱传类型就会初始化失败。 如果 NormLayer 不大于 0,则走 else 分支,以批大小 0 和 ADAM 优化器初始化基类。两种路径最终都把 iBatchNormLayer 赋值并返回 true,MT5 里加载这类自定义层时,可先打印 Type() 是否等于 defNeuronRevInDenormOCL 来确认对象身份。 别把反归一化当成可训练层 代码里 updateInputWeights 和 WeightsUpdate 都直接 return true,意味着这层在反向传播中不更新权重。若你在 EA 里想微调该层参数,改这里不会生效,得从绑定的 normLayer 入手。
class CNeuronRevINDenormOCL : class="kw">public CNeuronBatchNormOCL { class="kw">protected: class="type">int iBatchNormLayer; class=class="str">"cmt">//--- class="kw">virtual class="type">bool feedForward(CNeuronBaseOCL *NeuronOCL); class="kw">virtual class="type">bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) { class="kw">return true; } class="kw">public: CNeuronRevINDenormOCL(class="type">void) : iBatchNormLayer(-class="num">1) {}; ~CNeuronRevINDenormOCL(class="type">void) {}; class=class="str">"cmt">//--- class="kw">virtual class="type">bool Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint numNeurons, class="type">int NormLayer, CNeuronBatchNormOCL *normLayer); class="kw">virtual class="type">int GetNormLayer(class="type">void) { class="kw">return iBatchNormLayer; } class=class="str">"cmt">//--- class="kw">virtual class="type">bool calcInputGradients(CNeuronBaseOCL *NeuronOCL); class=class="str">"cmt">//--- class="kw">virtual class="type">bool Save(class="type">int const file_handle); class="kw">virtual class="type">bool Load(class="type">int const file_handle); class=class="str">"cmt">//--- class="kw">virtual class="type">int Type(class="type">void) const { class="kw">return defNeuronRevInDenormOCL; } class="kw">virtual CLayerDescription* GetLayerInfo(class="type">void); class=class="str">"cmt">//--- class="kw">virtual class="type">bool WeightsUpdate(CNeuronBaseOCL *source, class="type">float tau) { class="kw">return true; } }; class="type">bool CNeuronRevINDenormOCL::Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint numNeurons, class="type">int NormLayer, CNeuronBatchNormOCL *normLayer) { if(NormLayer > class="num">0) { if(!normLayer) class="kw">return false; if(normLayer.Type() != defNeuronBatchNormOCL) class="kw">return false; if(BatchOptions == normLayer.BatchOptions) BatchOptions = NULL; if(!CNeuronBatchNormOCL::Init(numOutputs, myIndex, open_cl, numNeurons, normLayer.iBatchSize, normLayer.Optimization())) class="kw">return false; if(!!BatchOptions) class="kw">delete BatchOptions; BatchOptions = normLayer.BatchOptions; } else if(!CNeuronBatchNormOCL::Init(numOutputs, myIndex, open_cl, numNeurons, class="num">0, ADAM)) class="kw">return false; iBatchNormLayer = NormLayer; class=class="str">"cmt">//--- class="kw">return true; } __kernel class="type">void RevInFeedForward(__global class="type">float *inputs, __global class="type">float *options,
反归一化核的偏移与批处理落地
RevIN 的反归一化核里,shift 的取值由线程号 n 与 optimization 共同决定:当 n * optimization 为 0 时取 7,否则取 9,再对 options_size 取模。这个偏移量直接定位 mean、variance、k 等统计量在 options 缓冲中的位置,写错一步输出全乱。 核函数内部先用 k 判零,非零才做 (inputs[n] - options[shift+4]) / k * sqrt(variance) + mean 的还原;若结果为 NaN 则强制归 0,避免 GPU 上脏数据污染后续层。 feedForward 里先判断 OpenCL 与上层神经元指针,iBatchSize<=1 时直接沿用上层激活函数并返回,不走 GPU 核。批大小大于 1 才通过 SetArgumentBuffer 把输入、options、输出缓冲的索引以及 options_size、optimization 两个标量塞进 def_k_RevInFeedForward 核,任何一步失败都 printf 出错误码与行号后返回 false。 在 MT5 里把 optimization 从 0 改成 1,观察 shift 由 7 变 9 后输出序列是否整体平移——外汇与贵金属杠杆高,模型层数值异常可能放大实盘回撤,验证前先用历史数据跑通再上模拟。
__global class="type">float *output, class="type">int options_size, class="type">int optimization) { class="type">int n = get_global_id(class="num">0); class="type">int shift = (n * optimization == class="num">0 ? class="num">7 : class="num">9) % options_size; class="type">float mean = options[shift]; class="type">float variance = options[shift + class="num">1]; class="type">float k = options[shift + class="num">3]; class="type">float res = class="num">0; if(k != class="num">0) res = sqrt(variance) * (inputs[n] - options[shift + class="num">4]) / k + mean; if(isnan(res)) res = class="num">0; output[n] = res; } class="type">bool CNeuronRevINDenormOCL::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!OpenCL || !NeuronOCL) class="kw">return false; class=class="str">"cmt">//--- PrevLayer = NeuronOCL; class=class="str">"cmt">//--- if(!BatchOptions) iBatchSize = class="num">0; if(iBatchSize <= class="num">1) { activation = (ENUM_ACTIVATION)NeuronOCL.Activation(); class="kw">return true; } class="type">uint global_work_offset[class="num">1] = {class="num">0}; class="type">uint global_work_size[class="num">1] = {Neurons()}; ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_RevInFeedForward, def_k_revffinputs, NeuronOCL.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return false; } if(!OpenCL.SetArgumentBuffer(def_k_RevInFeedForward, def_k_revffoptions, BatchOptions.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return false; } if(!OpenCL.SetArgumentBuffer(def_k_RevInFeedForward, def_k_revffoutput, Output.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return false; } if(!OpenCL.SetArgument(def_k_RevInFeedForward, def_k_revffoptions_size, (class="type">int)BatchOptions.Total())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return false; } if(!OpenCL.SetArgument(def_k_RevInFeedForward, def_k_revffoptimization, (class="type">int)optimization)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); class="kw">return false; } if(!OpenCL.Execute(def_k_RevInFeedForward, class="num">1, global_work_offset, global_work_size)) {