神经网络变得轻松(第三十二部分):分布式 Q-学习·进阶篇
「SoftMax 层在 OpenCL 下的并行反向实现」
把 SoftMax 放到 MT5 的 OpenCL 环境里跑,核心不是公式本身,而是如何用二维全局 ID 切分多头(heads)与输出维度。下面这段内核把 h 作为头索引、i 作为输出索引,shift = h * outputs_total 直接定位每块头的起始偏移,避免多头数据在显存里交错。 内核 SoftMax_HiddenGradient 里那句 result += outputs[shift+j] * output_gr[shift+j] * ((float)(i==j) - output) 就是标准 SoftMax Jacobian 的逐项累加:当 j 等于 i 时系数为 (output - output^2),否则为 -output_i * output_j。在 GPU 上用 for 循环扫完 outputs_total 个元素,复杂度仍是 O(n^2) 但并行摊薄了。 feedForward 与 calcInputGradients 里都出现了 size = Output.Total() / iHeads,并把 global_work_size 设成 {size, iHeads}、local_work_size 设成 {size, 1}。这意味着每个头占满一个本地工作组的一维,头间并行、头内串行归约。若你改 iHeads 参数,必须同步确认 Output.Total() 能整除,否则 GPU 偏移会越界报 GetLastError()。 SoftMax_OutputGradient 则简单得多:output_gr[i] = targets[i] / (outputs[i] + 1e-37f),加 1e-37f 是为防除零。这个内核只用一维全局 ID,适合最后一层直接拿目标概率做梯度。
__kernel class="type">void SoftMax_HiddenGradient(__global class="type">float* outputs, __global class="type">float* output_gr, __global class="type">float* input_gr) { class="type">size_t i = get_global_id(class="num">0); class="type">size_t outputs_total = get_global_size(class="num">0); class="type">size_t h = get_global_id(class="num">1); class="type">uint shift = h * outputs_total; class="type">float output = outputs[shift + i]; class="type">float result = class="num">0; for(class="type">int j = class="num">0; j < outputs_total ; j++) result += outputs[shift + j] * output_gr[shift + j] * ((class="type">float)(i == j) - output); input_gr[shift + i] = result; } __kernel class="type">void SoftMax_OutputGradient(__global class="type">float* outputs, __global class="type">float* targets, __global class="type">float* output_gr) { class="type">size_t i = get_global_id(class="num">0); output_gr[i] = targets[i] / (outputs[i] + 1e-37f); } class="type">bool CNeuronSoftMaxOCL::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!OpenCL || !NeuronOCL) class="kw">return class="kw">false; class="type">uint global_work_offset[class="num">2] = {class="num">0, class="num">0}; class="type">uint size = Output.Total() / iHeads; class="type">uint global_work_size[class="num">2] = { size, iHeads }; class="type">uint local_work_size[class="num">2] = { size, class="num">1 }; OpenCL.SetArgumentBuffer(def_k_SoftMax_FeedForward, def_k_softmaxff_inputs, NeuronOCL.getOutputIndex()); OpenCL.SetArgumentBuffer(def_k_SoftMax_FeedForward, def_k_softmaxff_outputs, getOutputIndex()); OpenCL.SetArgument(def_k_SoftMax_FeedForward, def_k_softmaxff_total, size); if(!OpenCL.Execute(def_k_SoftMax_FeedForward, class="num">2, global_work_offset, global_work_size, local_work_size)) { printf("Error of execution kernel SoftMax FeedForward: %d", GetLastError()); class="kw">return class="kw">false; } class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CNeuronSoftMaxOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID) class="kw">return class="kw">false; class="type">uint global_work_offset[class="num">2] = {class="num">0, class="num">0}; class="type">uint size = Output.Total() / iHeads; class="type">uint global_work_size[class="num">2] = {size, iHeads}; OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_input_gr, NeuronOCL.getGradientIndex()); OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_output_gr, getGradientIndex()); OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_outputs, getOutputIndex());
◍ SoftMax 层的持久化与网络装配细节
在 OpenCL 版的 SoftMax 神经元里,反向传播结束后会调用 Execute 跑隐藏层梯度核,维度参数写死为 2。若执行失败,直接 printf 打出错误码并 return false,上层训练循环必须捕获这个 false 否则可能带着脏梯度继续更新。 Save 与 Load 负责把层状态落盘。Save 先写基类再写 iHeads 这个 uint,FileWriteInteger 返回值 ≤0 就判失败;Load 反过来读,若 iHeads≤0 则兜底置 1,避免旧模型或破损文件让多头划分变成 0 导致后续卷积或注意力头计算直接崩。 网络装配在 CNet::Create 里按 Description 数组循环。遇到 defNeuronSoftMaxOCL 类型时 new 出 CNeuronSoftMaxOCL,Init 的第二个参数填 0 表示无偏置,desc.count / desc.optimization / desc.batch 透传;之后那行高亮的 softmax.SetHeads(desc.step) 把多头数从描述结构灌进去,这一步漏掉的话 Load 虽能兜底成 1,但训练时多头并行逻辑就废了。 任何 Add 失败的分支都先把 softmax 和 temp 双删再 return,防止半初始化对象挂在数组里。开 MT5 把这段塞进自己的 OCL 网络类,断点打在 SetHeads 前后看 iHeads 是否等于 desc.step,能立刻验证多头配置有没有生效。
if(!OpenCL.Execute(def_k_SoftMax_HiddenGradient, class="num">2, global_work_offset, global_work_size)) { printf("Error of execution kernel SoftMax InputGradients: %d", GetLastError()); class="kw">return class="kw">false; } class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CNeuronSoftMaxOCL::Save(class="kw">const class="type">int file_handle) { if(!CNeuronBaseOCL::Save(file_handle)) class="kw">return class="kw">false; if(FileWriteInteger(file_handle, iHeads) <= class="num">0) class="kw">return class="kw">false; class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CNeuronSoftMaxOCL::Load(class="kw">const class="type">int file_handle) { if(!CNeuronBaseOCL::Load(file_handle)) class="kw">return class="kw">false; iHeads = (class="type">uint)FileReadInteger(file_handle); if(iHeads <= class="num">0) iHeads = class="num">1; class=class="str">"cmt">//--- class="kw">return true; } class="type">void CNet::Create(CArrayObj *Description) { class=class="str">"cmt">//--- 循环体内对 SoftMaxOCL 的处理 if(!!opencl) { CNeuronSoftMaxOCL *softmax = NULL; class="kw">switch(desc.type) { case defNeuronSoftMaxOCL: softmax = new CNeuronSoftMaxOCL(); if(!softmax) { class="kw">delete temp; class="kw">return; } if(!softmax.Init(outputs, class="num">0, opencl, desc.count, desc.optimization, desc.batch)) { class="kw">delete softmax; class="kw">delete temp; class="kw">return; } softmax.SetHeads(desc.step); if(!temp.Add(softmax)) { class="kw">delete softmax; class="kw">delete temp; class="kw">return; } softmax = NULL; break; } } }
初始化里先卡死动作分布边界
这段 OnInit 的逻辑核心,是先给双神经网络(StudyNet / TargetNet)加载 .nnw 权重文件,任何一次 Load 或 TrainMode 失败都直接 INIT_FAILED,避免后续拿空模型跑行情。 加载完后从 StudyNet 第 0 层取输出,用 TempData.Total() 除以 12 反推 HistoryBars,再用 TempData.Total() / Actions 算 action_dist。Actions 在外部写死为 3,所以 action_dist 实际等于样本总数除以 3。 action_dist 若小于等于 0 会返回 INIT_PARAMETERS_INCORRECT,这是硬边界:你改了 Step 或样本量导致整除出 0,EA 根本起不来。action_midle 取 (action_dist+1)/2,作为后续奖励映射的中轴。 训练循环里,TargetNet 用 State2 做前馈,取结果 reshape 成 Actions×action_dist 的矩阵,用 ArgMax(1) 减中轴再乘 Step 和 DiscountFactor 得到 add 偏移量。奖励直接取单根 K 线 close-open,非负时按 (2*reward+add[0])/Step+action_midle 夹取到 [0, action_dist-1] 区间写进 Rewards。外汇与贵金属杠杆高,这套映射若 Step=5e-4 配错品种波动,奖励索引容易越界,开 MT5 前先核对品种点值。
class="type">int Actions = class="num">3; class="kw">input class="type">class="kw">double Step = class="num">5e-4; class="type">int OnInit() { class=class="str">"cmt">//--- class="type">float temp1, temp2; if(!StudyNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, class="kw">false) || !TargetNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, class="kw">false)) class="kw">return INIT_FAILED; if(!StudyNet.TrainMode(true)) class="kw">return INIT_FAILED; class=class="str">"cmt">//--- if(!StudyNet.GetLayerOutput(class="num">0, TempData)) class="kw">return INIT_FAILED; HistoryBars = TempData.Total() / class="num">12; StudyNet.getResults(TempData); action_dist = TempData.Total() / Actions; if(action_dist <= class="num">0) class="kw">return INIT_PARAMETERS_INCORRECT; action_midle = (action_dist + class="num">1) / class="num">2; class=class="str">"cmt">//--- class="kw">return(INIT_SUCCEEDED); } class="type">void Train(class="type">void) { class=class="str">"cmt">//--- for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped()); iter ++) { class=class="str">"cmt">//--- for(class="type">int batch = class="num">0; batch < (Batch * UpdateTarget); batch++) { class=class="str">"cmt">//--- vectorf add = vectorf::Zeros(Actions); if(use_target) { if(!TargetNet.feedForward(GetPointer(State2), class="num">12, true)) class="kw">return; TargetNet.getResults(TempData); vectorf temp; TempData.GetData(temp); matrixf target = matrixf::Zeros(class="num">1, temp.Size()); if(!target.Row(temp, class="num">0) || !target.Reshape(Actions, action_dist)) class="kw">return; add = DiscountFactor * (target.ArgMax(class="num">1) - action_midle) * Step; } Rewards.BufferInit(Actions * action_dist, class="num">0); class="type">class="kw">double reward = Rates[i].close - Rates[i].open; if(reward >= class="num">0) { class="type">int rew = (class="type">int)fmax(fmin((class="num">2 * reward + add[class="num">0]) / Step + action_midle, action_dist - class="num">1), class="num">0); if(!Rewards.Update(rew, class="num">1))
「奖励分桶的偏移写法」
这段逻辑把连续 reward 压进离散动作桶,核心是用 fmax/fmin 做截断,再叠 action_dist 偏移区分多组奖励。 先看空头分支:第一桶用 -5*reward 配 add[1],除 Step 后加 action_midle,下限锁 0、上限锁 action_dist-1,再补 action_dist 写入 Rewards;任意 Update 失败立即 return,避免脏数据进表。 多头分支对称处理:首桶用 5*reward 配 add[0],第二桶用 -2*reward 配 add[1] 并偏移 action_dist,第三桶用 reward+add.Max() 偏移 2*action_dist。三组系数(5 / -2 / 1)和偏移(0 / 1x / 2x action_dist)直接决定 RL 样本在桶里的分布密度,调 Step 可改分辨率。 开 MT5 把这段代码贴进 EA 的奖励更新段,故意让某次 Rewards.Update 返回 false,能看到后续桶全部跳过——印证了早退逻辑。外汇与贵金属杠杆高,这类样本构造错误可能让模型学到偏态,回测前务必单步跟一遍。
class="kw">return; rew = (class="type">int)fmax(fmin((-class="num">5 * reward + add[class="num">1]) / Step + action_midle, action_dist - class="num">1), class="num">0) + action_dist; if(!Rewards.Update(rew, class="num">1)) class="kw">return; rew = (class="type">int)fmax(fmin((-reward + add.Max()) / Step + action_midle, action_dist - class="num">1), class="num">0) + class="num">2 * action_dist; if(!Rewards.Update(rew, class="num">1)) class="kw">return; } else { class="type">int rew = (class="type">int)fmax(fmin((class="num">5 * reward + add[class="num">0]) / Step + action_midle, action_dist - class="num">1), class="num">0); if(!Rewards.Update(rew, class="num">1)) class="kw">return; rew = (class="type">int)fmax(fmin((-class="num">2 * reward + add[class="num">1]) / Step + action_midle, action_dist - class="num">1), class="num">0) + action_dist; if(!Rewards.Update(rew, class="num">1)) class="kw">return; rew = (class="type">int)fmax(fmin((reward + add.Max()) / Step + action_midle, action_dist - class="num">1), class="num">0) + class="num">2 * action_dist; if(!Rewards.Update(rew, class="num">1)) class="kw">return; }
◍ 用两周样本外数据压一下模型成色
训练好的网络结构固定为:3 层卷积做数据预处理,接 3 个各含 1000 神经元的全连接隐藏层,再经 45 神经元决策层(买卖持三类动作各 15 个)与 SoftMax 输出概率分布。训练吃的是 EURUSD 的 H1 历史两年数据,指标和参数与前面几篇完全一致。 为验证不是过拟合,模型拿到策略测试器里跑过去两周、且未参与训练的样本。测试 EA 基本复制了原 Q-learning 版本,只多嵌了一个 GetAction 函数,负责把模型对当前状态的概率估算转成具体动作。 函数先取概率缓冲区放进矩阵并 reshape 成「行数=可能动作数」的表格,再逐动作算最可能奖励分位数。若买、卖预期回报相等,挑获奖概率高的;否则贪心选预期回报最大的。 两周最低手数跑下来账面利润约 20 美元,余额曲线明显向上,胜率近 56%。但外汇和高贵金属属高风险,这仅是样本外回测,EA 还没碰过真实盘,不能直接拿来实盘。 代码里 prob[0]==prob[1] 的判定就是买卖回报打平时走概率优先分支;任何一步缓冲区取数失败都直接 return -1,调用层要自己兜底。
class="type">int GetAction(CBufferFloat* probability) { vectorf prob; if(!probability.GetData(prob)) class="kw">return -class="num">1; matrixf dist = matrixf::Zeros(class="num">1, prob.Size()); if(!dist.Row(prob, class="num">0)) class="kw">return -class="num">1; if(!dist.Reshape(Actions, prob.Size() / Actions)) class="kw">return -class="num">1; prob = dist.ArgMax(class="num">1); if(prob[class="num">0] == prob[class="num">1]) { if(prob[class="num">2] > prob[class="num">0]) class="kw">return class="num">2; if(dist[class="num">0, (class="type">int)prob[class="num">0]] >= dist[class="num">1, (class="type">int)prob[class="num">1]]) class="kw">return class="num">0; else class="kw">return class="num">1; } class=class="str">"cmt">//--- class="kw">return (class="type">int)prob.ArgMax(); }