期货量化交易软件:神经网络软性扮演者模型优化2.0
生成随机值超出了我们模型的范畴,赫兹量化交易软件不会依据它们分布梯度。但是我们应该只为所选动作安排误差梯度的分布。毕竟,其它值都没有对执行的扮演者动作产生影响。因此,它们的误差梯度为 “0”。
与直接验算不同,我们不能向功能添加新方法,因为调用父类方法将覆盖我们保存的梯度。因此,赫兹量化交易软件必须彻底重新定义通过神经层元素分布误差梯度的方法。
如常,我们从创建 SAC_OutputGradient 内核开始。内核参数的结构会令您回想起父类的 FQF_OutputGradient 内核。我们以它为基础,添加了 1 个缓冲区和 2 个常量:
-
output — 前向验算结果的缓冲区
-
count_quants — 每个动作的分位数
-
activation — 所应用的激活函数。
__kernel void SAC_OutputGradient(__global float* quantiles, __global float* delta_taus, __global float* output_gr, __global float* quantiles_gr, __global float* taus_gr, __global float* output, const int count_quants, const int activation ) { size_t action = get_global_id(0); int shift = action * count_quants;
我们将根据动作的数量在一维任务空间中启动内核。
在内核主体中,赫兹量化交易软件立即识别正在分析的扮演者操作,并判定其在数据缓冲区中的偏移量。
接下来,我们安排一个循环,在循环中,我们将取每个分位数的平均值和来自层结果缓冲区中的完美动作进行比较。不过,我们应该记住,平均分位数值存储在原始值当中,结果缓冲区中的选定动作包含激活函数之后的值。因此,在比较数值之前,我们需要针对每个分位数的平均值应用激活函数。
for(int i = 0; i < count_quants; i++) { float quant = quantiles[shift + i]; switch(activation) { case 0: quant = tanh(quant); break; case 1: quant = 1 / (1 + exp(-quant)); break; case 2: if(quant < 0) quant = quant * 0.01f; break; } if(output[i] == quant) { float gradient = output_gr[action]; quantiles_gr[shift + i] = gradient * delta_taus[shift + i]; taus_gr[shift + i] = gradient * quant; } else { quantiles_gr[shift + i] = 0; taus_gr[shift + i] = 0; } } }
需要注意的是,理论上我们可以执行一次逆函数,并在激活函数之前检测结果缓冲区的值。不过,由于计算的精度误差,我们很可能会得到一个接近、但与原始值不同的值。赫兹量化交易软件将被迫按某种容错进行对比。这反过来又会令比较复杂化,并降低准确性。
当分位数匹配时,我们将误差梯度分布到分位数的平均值及其概率。对于剩余的分位数及其概率,我们将梯度设置为 “0”。
循环迭代完成后,我们关停内核。
如上所述,在主程序这端,我们要何地重新定义误差梯度分布方法 calcInputGradients。该方法是从类似的父类方法复制而来的。这些修改仅影响内核上方描述的队列模块。因此,我现在不会详述它。在附件 “..\NeuroNet_DNG\NeuroNet.mqh“ 里可以找到它。

添加图片注释,不超过 140 字(可选)
1.2调整目标模型的更新过程
您也许已经注意到,我更喜欢在我的模型中使用 Adam 方法来更新权重比。有关于此,冒出了将这种方法引入评论者目标模型的软性更新的想法。
您也许还记得,软性扮演者-评论者算法采用 (0,1} 范围内的恒定比率提供目标模型的软性更新。如果比率等于 “1”,则只需复制参数即可。不会应用 “0”,因为在这种情况下不会更新目标模型。
使用 Adam 方法允许模型独立调整每个单独训练参数的比率。这样可以快速更新往一个方向偏移的参数,这意味着目标模型将从初始值更快地偏移到第一个近似值。同时,自适应方法可以降低多向振荡的复制速度,从而降低目标模型值的噪声。
不过,应当注意模型在训练初始阶段变得不平衡的风险。复制单个参数的速度存在显著差异,会导致意外和不可预测的结果。
在评估了所有利弊之后,我决定在实践中测试这种方式的有效性。
我们在 OpenCL 关联环端执行模型优化过程。所有经过训练的模型参数的当前值都存储在关联环境内存之中。十分合乎逻辑的是,在 OpenCL 端,已训练模型和目标模型之间传输这些参数对我们来说更有好处。这种方式具有多项优点:
-
我们剔除了从关联环境加载训练模型的当前参数到主内存,以及随后将目标模型的新参数复制到关联环境内存的过程;
-
我们可以在并行数据流中并发传输若干个参数。
我们创建 SoftUpdateAdam 内核来传输数据。在内核参数中,赫兹量化交易软件将传递由该方法提供的指向 4 个数据缓冲区的指针和 3 个参数。
__kernel void SoftUpdateAdam(__global float *target, __global const float *source, __global float *matrix_m, __global float *matrix_v, const float tau, const float b1, const float b2 ) { const int i = get_global_id(0); float m, v, weight;
我们计划根据当前模型层更新的参数数量,在一维任务空间中依次启动每个神经层的内核。在此选项中,内核主体中定义的线程 ID 同时当作指向正在分析的参数,及其在数据缓冲区中的偏移量。
在此,我们还声明局部变量来存储中间数据,并将全局缓冲区中的原始数据写入其中。
m = matrix_m[i]; v = matrix_v[i]; weight=target[i];
开发 Adam 方法是为了往反梯度更新模型参数。在我们的例子中,误差梯度将是目标模型的参数与训练模型的参数的偏差。由于我们把参数值往反梯度调整,因此我们将偏差定义为训练模型的参数与训练模型相应参数之间的差值。
float g = source[i] - weight; m = b1 * m + (1 - b1) * g; v = b2 * v + (1 - b2) * pow(g, 2);
此外,我们立即判定其二次值的误差梯度的指数平均值。
接下来,我们检测所需参数的偏移量,并将其相应的元素存储在全局数据缓冲区之中。
float delta = tau * m / (v != 0.0f ? sqrt(v) : 1.0f); if(delta * g > 0) target[i] = clamp(weight + delta, -MAX_WEIGHT, MAX_WEIGHT);
在内核操作结束时,我们将误差梯度及其平方的平均值保存到全局数据缓冲区之中。在更新参数的后续迭代中,我们会需要它们。
matrix_m[i] = m; matrix_v[i] = v; }
内核创建之后,我们必须在主程序的一端安排调用它的过程。此处,我们有 2 个选项:
-
创建新方法
-
更新以前创建的方法。
在本文中,我建议创建一个新方法,我们将在 CNeuronBaseOCL::WeightsUpdateAdam 神经层的基类层面创建该方法。在方法参数中,我们将传递指向训练模型神经层的指针和更新系数,类似于之前创建的目标模型的软更新方法。我们将用指定 Adam 方法的超参数来更新默认模型。
bool CNeuronBaseOCL::WeightsUpdateAdam(CNeuronBaseOCL *source, float tau) { if(!OpenCL || !source) return false; if(Type() != source.Type()) return false; if(!Weights || Weights.Total() == 0) return true; if(!source.Weights || Weights.Total() != source.Weights.Total()) return false;
控件模块将在方法主体中实现。在此,我们检查指针与正在使用的对象的相关性。我们还要检查当前神经层的类型与生成的指针之间的对应关系。
成功通过控制模块后,我们将参数传输到内核,并放入执行队列之中。
请注意,Adam 方法需要创建两个额外的数据缓冲区。但要记住,我们在每个模型中创建了类似的缓冲区,来更新模型的可训练参数。在本例中,我们正在与目标模型打交道,在其中参数会被更新。它的优化是通过定期从训练模型传输数据来运作的。换言之,我们所拥有的模型功能有限。同时,我们没有为目标模型创建单独的对象类型,而是将先前创建的那个当作全功能模型,并创建所有必要的对象和缓冲区。这可看出是内存资源的低效使用。但我们有意识地迈出了这一步,以便统一模型。现在我们已有创建好的,且未使用的目标模型缓冲区。我们将用它们来更新参数。
uint global_work_offset[1] = {0}; uint global_work_size[1] = {Weights.Total()}; ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_target, getWeightsIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_source, source.getWeightsIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_matrix_m, getFirstMomentumIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_matrix_v, getSecondMomentumIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_SoftUpdateAdam, def_k_sua_tau, (float)tau)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_SoftUpdateAdam, def_k_sua_b1, (float)b1)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_SoftUpdateAdam, def_k_sua_b2, (float)b2)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.Execute(def_k_SoftUpdateAdam, 1, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
不要忘记在每个阶段监控操作的正确性。所有迭代成功完成后该方法结束。
创建方法后,我们需要仔细思考,并安排它的调用。我想找到一种方式,能尽可能简单地调用它,同时针对模型的整体结构修改最少。在我看来,我已找到了一个折衷方案。我没有创建一个单独的分支,来通过模型的调度程序类和神经层的动态数组从外部程序调用方法。代之,我去之前创建的 CNeuronBaseOCL::WeightsUpdate 软更新方法,并针对更新训练模型参数的方法设置一处检查,该参数由用户在描述模型架构时为每个神经层指定。如果用户指定了 Adam 方法来更新模型参数,我们只需重定向工作流即可执行新方法。对于其它参数更新方法,我们使用经典的软更新。
bool CNeuronBaseOCL::WeightsUpdate(CNeuronBaseOCL *source, float tau) { if(optimization == ADAM) return WeightsUpdateAdam(source, tau); //--- ........ ........ }
无关它事,该方式保证了我们得到必要的数据缓冲区。
1.3修改源数据结构
我还关注到源数据结构。如您所知,每个历史数据柱线的定义由 12 个元素组成:
-
开盘价和收盘价之间的差值
-
开盘价和最高价之间的差值
-
开盘价和最低价之间的差值
-
蜡烛时钟
-
工作日
-
月份
-
5 个指标参数。
State.Add((float)Rates[b].close - open); State.Add((float)Rates[b].high - open); State.Add((float)Rates[b].low - open); State.Add((float)Rates[b].tick_volume / 1000.0f); State.Add((float)sTime.hour); State.Add((float)sTime.day_of_week); State.Add((float)sTime.mon); State.Add(rsi); State.Add(cci); State.Add(atr); State.Add(macd); State.Add(sign);
在这个数据集中,我的注意力被时间戳所吸引。评估时间分量对于理解季节性,和货币在不同时段的不同行为具有重要价值。但是,它们的存在对每根蜡烛有多重要?我个人观点是,一组时间戳足以形成市场当前状态的整体“快照”。以前,当使用一个源数据缓冲区时,我们被迫重复这些数据,以便保留每根蜡烛的定义结构。现在,当我们的模型有 2 个初始数据源时,我们可以将时间戳放入帐户状态定义缓冲区当中。在此,我们只留下市场状况历史数据的快照。以这种方式,我们可以在不损失信息容量的情况下减少所分析数据的总量。由此,我们减少了需执行操作的数量,同时提高了模型的性能。
此外,我们还修改了模型的时间戳表示。我要提醒您,我们使用相对参数来描述帐户的状态。这令我们能够将它们转换为可比较、且部分常规化的形式。我们希望有一个常规化的时间戳视图。同时,保留有关过程的季节性信息也很重要。在这种情况下,人们常会冒出使用正弦和余弦函数的想法。这些函数的图形是连续且周期性的。函数的周期长度是已知的,等于 2π。

添加图片注释,不超过 140 字(可选)
为了规范化时间戳,并考虑到周期性,我们需要:
-
将当前时间除以周期大小
-
将结果值乘以 “2π” 常数
-
计算函数值(sin 或 cos)
-
将结果值添加到缓冲区
在我的实现中,我使用了年、月、周和日的周期。
double x = (double)Rates[0].time / (double)(D'2024.01.01' - D'2023.01.01'); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_MN1); Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_W1); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_D1); Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
另外,不要忘记更改一根蜡烛定义和账户状态的大小常量。它们的值将反映在我们模型的架构,和定义累积经验轨迹的缓冲区数组的大小。
更多推荐




所有评论(0)