神经网络变得简单(第 95 部分):降低变换器模型中的内存消耗
概述
于 2017 年引入的变换器架构导致了大型语言模型(LLM)的出现,其在解决自然语言处理问题方面展现出极高的成果。很快,自关注方式的优势,已在机器学习的几乎所有领域被研究人员所采用。
不过,由于其自回归性质,变换器解码器受制于在每个时间步加载和存储 Key 和 Value 实体的内存带宽(称为 KV 缓存)。鉴于该缓存随模型大小、批量大小、和上下文长度线性伸缩,它甚至可能超出模型权重的内存用量。
这个问题并不新鲜。有不同的方式可以解决它。最广泛运用的方法意味着直接减少所用的 KV 头。于 2019 年,论文《快速变换器解码:一个记录头就是您所需的一切》的作者提出了多查询关注(MQA)算法,其针对一层级别的所有关注头只用到一个 Key 和 Value 投影。这会将 KV 缓存的内存消耗降低 1/头。资源消耗的显著降低,则会导致模型品质和稳定性有所下降。
分组查询关注度(GQA)方法的作者在论文《GQA:从多头检查点训练广义多查询变换器模型》(2023)中阐述了一种将多个 KV 头划分到若干关注度分组的中间解决方案。当 GQA 等于 分组/头 之时,KV 缓存大小降低效率。配以合理数量的头,GQA 可在各种测试中达成与基本模型几乎等同的效果。不过,在运用 MQA 时,KV 缓存大小降低仍被限制为 1/头。对于某些应用来说,这或许还不够。
为了超越这个限制,论文《MLKV:多层键-值头,内存高效变换器解码》的作者提出了一种多层键-值共享算法(MLKV)。他们将 KV 的共用迈进一步。MLKV 不仅在一层的关注头之间划分 KV 头,也在其它等级的关注头之间划分。KV 头可用于一层中的关注头分组,和/或后续层中的关注头分组。在极端情况下,一个 KV 头可用于所有层的所有关注头。该方法的作者试验了各种配置,他们在同一级别、及不同级别之间使用了分组查询。即使配置的 KV 头数量少于层数的也是如此。论文中的演示实验表明,这些配置可在性能和达成的内存节省之间提供合理的权衡。将内存用量实际降低到原始 KV 缓存大小的 2/层 不会导致模型品质的显著下滑。
1. MLKV 方法
MLKV 方法是 MQA 和 GQA 算法的逻辑延续。在指定的方法中,由于 KV 头的减少,KV 缓存大小亦会减小,并在单个自关注层内的一个关注头分组共享。一个完全预期的步骤是在自关注层之间共享 Key 和 Value 实体。最近 FeedForward 模块在变换器算法中的作用的研究,或许证明了这一步的合理性。它假设指定的模块模拟 “键-值” 内存,处理不同的信息级别。然而,对我们来说最有趣的是观察到连续层分组计算相似的东西。更准确地说,较低级别处理表面形态,而较高级别处理更多的语义细节。因此,可以得出结论,即关注力能够委派给各层的分租,同时在 FeedForward 模块中保留必要的计算。直观上,KV 头可以在具有相似目标的层之间共享。
为了开拓这些思路,MLKV 方法的作者提供了多级键交换。MLKV 不仅在同一个自关注层的 Query 关注头之间共享 KV 头,而且在其它层的关注头亦如此。这样就能减少变换器中 KV 头的总数,从而允许更小的 KV 缓存。
MLKV 可以写成如下:

下面是作者对 KV 缓存大小缩减方法比较的可视化。

该方法作者进行的实验表明,内存和准确性之间存在清晰的权衡。设计师可以选择牺牲什么。甚至,还有许多因素需要参考。至于 KV 头的数量大于或等于层数,最好使用 GQA/MQA 替代 MLKV。该方法作者假设,在多层中存在多个 KV 头,比之在一层中有多个 KV 头更重要。换言之,您应当首先牺牲首层级(GQA/MQA),及跨层第二个(MLKV)的 KV 头。
对于需要 KV 头数量小于层数的内存密集型状况,唯有 MLKV 一条路。这种设计方案是可行的。该方法作者发现,当关注头减少到不到层数的一半时,MLKV 的工作非常接近 MQA。这意味着如果您需要 KV 缓存的大小是 MQA 提供的一半,它应该是一个相对简单的解决方案。
如果需要更低值,我们可用比层数少 6 倍的 KV 头数量,而品质不会急剧下滑。低于它所有的情况都会变得有问题。
2. 利用 MQL5 实现
我们已简要研究了所提议方法的理论描述。现在,我们可以转到使用 MQL5 实际实现。于此,我们将实现 MLKV 方法。以我观点,这是一种更常用的方式,而 MQA 和 GQA 可以表示为 MLKV 的特殊情况。
即将到来的实现最严重的问题是如何在神经层之间传递信息。在这种情况下,我决定不要令现有的神经层对象之间数据交换的算法复杂化。取而代之,我们将使用多层序列模块,其已在本系列文章中多次实现。我们将用 CNeuronMLMHAttentionOCL 作为父类,打造即将实现的类。
2.1在 OpenCL 端实现
我们从 OpenCL 程序端准备内核开始。注意,在选定的父类中,我们用到一个串联张量,并行生成 Query、Key 和 Value 实体。整个关注度机制就是建立在这一点上的。不过,由于我们对 Query 和 键-值 使用不同数量的头,以及从另一个级别使用 键-值,故我们应当考虑将所述实体划分为 2 个单独的张量。在构造交叉关注度模块时,我们曾做过类似的事情。
这意味着我们可以取现有的代码优点,稍微调整一下交叉关注度内核算法。我们只需添加另一个内核参数,指示 KV 头的数量(在代码中以红色高亮显示)。
__kernel void MH2AttentionOut(__global float *q, ///<[in] Matrix of Querys __global float *kv, ///<[in] Matrix of Keys __global float *score, ///<[out] Matrix of Scores __global float *out, ///<[out] Matrix of attention int dimension, ///< Dimension of Key int heads_kv )
在内核主体中,为了判定正在分析的 KV 头,我们需要取当前关注度头除以 KV 头的总数所剩的余数。
const int h_kv = h % heads_kv;
在 键-值 张量缓冲区中加上偏移调整。
const int shift_k = 2 * dimension * (k + h_kv); const int shift_v = 2 * dimension * (k + heads_kv + h_kv);
进一步的内核代码保持不变。针对反向传播内核代码 MH2AttentionInsideGradients 进行类似编辑。附件中提供了这些内核的完整代码。
我们在 OpenCL 端的工作到此结束。我们转至主程序端。于此,我们首先需要恢复之前所创建代码的功能。因为如上在内核中指定的额外参数,在调用时会导致错误。故此,我们要找到这些内核的所有调用处,并添加把数据传送到新参数。
我要提醒您,以前我们对 Query 和 键-值 使用了相同数量的目标。
if(!OpenCL.SetArgument(def_k_MH2AttentionOut, def_k_mh2ao_heads_kv, (int)iHeads)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
if(!OpenCL.SetArgument(def_k_MH2AttentionInsideGradients, def_k_mh2aig_heads_kv, (int)iHeads)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
2.2创建 MLKV 类
我们继续我们的项目。在下一步中,我们将用 MLKV 方式创建一个多层关注模块类:CNeuronMLMHAttentionMLKV。如前所述,新类将是 CNeuronMLMHAttentionOCL 类的直接子类。新类的结构如下所示。
class CNeuronMLMHAttentionMLKV : public CNeuronMLMHAttentionOCL { protected: uint iLayersToOneKV; uint iHeadsKV; CCollection KV_Tensors; CCollection KV_Weights; CBufferFloat Temp; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool AttentionOut(CBufferFloat *q, CBufferFloat *kv, CBufferFloat *scores, CBufferFloat *out); virtual bool AttentionInsideGradients(CBufferFloat *q, CBufferFloat *q_g, CBufferFloat *kv, CBufferFloat *kv_g, CBufferFloat *scores, CBufferFloat *gradient); //--- virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); public: CNeuronMLMHAttentionMLKV(void) {}; ~CNeuronMLMHAttentionMLKV(void) {}; virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint heads_kv, uint units_count, uint layers, uint layers_to_one_kv, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) const { return defNeuronMLMHAttentionMLKV; } //--- virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau); virtual void SetOpenCL(COpenCLMy *obj); };
如您所见,在所提供类结构中,我们引入了 2 个变量来存储 KV 头数量(iHeadsKV) 、及 键-值 张量更新频率(iLayersToOneKV)。
我们还为其形成添加了 键-值 张量存储集合、及权重矩阵(分别为 KV_Tensors 和 KV_Weights)。
此外,我们还添加了一个 Temp 缓冲区来记录误差梯度的中间值。
类的方法集非常标准,我认为您已经明白它们的用途。我们将在实现过程中更细致地研究它们。
我们把所有内部对象声明为静态,如此我们就可将类构造函数和析构函数留空。所有嵌套对象和变量的初始化都在 Init 方法中执行。如常,该方法参数包含创建所需对象的所有必要信息。
bool CNeuronMLMHAttentionMLKV::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint heads_kv, uint units_count, uint layers, uint layers_to_one_kv, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count, optimization_type, batch)) return false;
在方法主体中,我们立即调用所有神经层基类的相关方法 CNeuronBaseOCL。
注意,我们访问的是基类对象,而非直接父类。这与将 Query、Key、和 Value 实体分离为 2 个张量有关,这会导致某些数据缓冲区的大小发生变化。不过,这种方式强制我们不仅初始化新对象,还有从父类继承的对象。
基类初始化方法成功执行后,我们将接收到的类参数保存到内部变量之中。
iWindow = fmax(window, 1); iWindowKey = fmax(window_key, 1); iUnits = fmax(units_count, 1); iHeads = fmax(heads, 1); iLayers = fmax(layers, 1); iHeadsKV = fmax(heads_kv, 1); iLayersToOneKV = fmax(layers_to_one_kv, 1);
下一步是计算需创建的所有缓冲区的大小。
uint num_q = iWindowKey * iHeads * iUnits; //Size of Q tensor uint num_kv = 2 * iWindowKey * iHeadsKV * iUnits; //Size of KV tensor uint q_weights = (iWindow + 1) * iWindowKey * iHeads; //Size of weights' matrix of Q tenzor uint kv_weights = 2 * (iWindow + 1) * iWindowKey * iHeadsKV; //Size of weights' matrix of KV tenzor uint scores = iUnits * iUnits * iHeads; //Size of Score tensor uint mh_out = iWindowKey * iHeads * iUnits; //Size of multi-heads self-attention uint out = iWindow * iUnits; //Size of out tensore uint w0 = (iWindowKey + 1) * iHeads * iWindow; //Size W0 tensor uint ff_1 = 4 * (iWindow + 1) * iWindow; //Size of weights' matrix 1-st feed forward layer uint ff_2 = (4 * iWindow + 1) * iWindow; //Size of weights' matrix 2-nd feed forward layer
接下来,我们加入一个循环,其迭代次数等于正在创建的关注度模块中的内部层数。
for(uint i = 0; i < iLayers; i++) {
在循环主体中,我们创建另一个嵌套循环,在其中我们首先创建存储数据的缓冲区。在嵌套循环的第二次迭代中,我们将创建缓冲区来记录相应的误差梯度。
CBufferFloat *temp = NULL; for(int d = 0; d < 2; d++) { //--- Initilize Q tensor temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(num_q, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!QKV_Tensors.Add(temp)) return false;
于此,我们首先创建 Query 实体张量。然后,我们创建记录 键-值 实体的相关张量。不过,后者应在循环的每次 iLayersToOneKV 迭代创建一次。
//--- Initilize KV tensor if(i % iLayersToOneKV == 0) { temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(num_kv, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!KV_Tensors.Add(temp)) return false; }
接下来,遵循变换器算法,我们创建缓冲区来存储依赖系数矩阵的张量、多头关注度、及其压缩表示。
//--- Initialize scores temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(scores, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!S_Tensors.Add(temp)) return false;
//--- Initialize multi-heads attention out temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(mh_out, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!AO_Tensors.Add(temp)) return false;
//--- Initialize attention out temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(out, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Tensors.Add(temp)) return false;
接下来,我们添加 FeedForward 模块缓冲区。
//--- Initialize Feed Forward 1 temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(4 * out, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Tensors.Add(temp)) return false;
//--- Initialize Feed Forward 2 if(i == iLayers - 1) { if(!FF_Tensors.Add(d == 0 ? Output : Gradient)) return false; continue; } temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(out, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Tensors.Add(temp)) return false; }
注意,当创建缓冲区来存储 FeedForward 模块的第 2 层的输出和误差梯度时,我们首先检查层编号。鉴于我们不会为最后一层创建新的缓冲区,故我们将保存指向 CNeuronMLMHAttentionMLKV 类的已创建结果和误差梯度缓冲区的指针。因此,在与下一层交换数据时,我们避免了不必要的数据复制。
在创建存储中间结果、及相应误差梯度的缓冲区之后,我们将为类的可训练参数矩阵创建缓冲区。我必须声明,此处它们也要有足够数量。首先,我们以随机参数创建、并初始化权重矩阵,以便生成 Query 实体。
//--- Initialize Q weights temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(q_weights)) return false; float k = (float)(1 / sqrt(iWindow + 1)); for(uint w = 0; w < q_weights; w++) { if(!temp.Add(GenerateWeight() * 2 * k - k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!QKV_Weights.Add(temp)) return false;
我们以类似的方式生成 “键-值” 张量生成参数。再次,它们会在每个内层的 iLayersToOneKV 创建一次。
//--- Initialize KV weights if(i % iLayersToOneKV == 0) { temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(kv_weights)) return false; float k = (float)(1 / sqrt(iWindow + 1)); for(uint w = 0; w < kv_weights; w++) { if(!temp.Add(GenerateWeight() * 2 * k - k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!KV_Weights.Add(temp)) return false; }
接下来,我们为多头关注度的结果生成压缩参数。
//--- Initialize Weights0 temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(w0)) return false; for(uint w = 0; w < w0; w++) { if(!temp.Add(GenerateWeight() * 2 * k - k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false;
最后但不可或缺的是 FeedForward 模块的参数。
//--- Initialize FF Weights temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(ff_1)) return false; for(uint w = 0; w < ff_1; w++) { if(!temp.Add(GenerateWeight() * 2 * k - k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false;
temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(ff_2)) return false; k = (float)(1 / sqrt(4 * iWindow + 1)); for(uint w = 0; w < ff_2; w++) { if(!temp.Add(GenerateWeight() * 2 * k - k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false;
在模型训练过程中,我们将需要缓冲区来记录上述所有参数的瞬间。我们将在嵌套循环中创建这些缓冲区,其迭代次数取决于所选的优化方法。
//--- for(int d = 0; d < (optimization == SGD ? 1 : 2); d++) { temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(q_weights, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!QKV_Weights.Add(temp)) return false;
if(i % iLayersToOneKV == 0) { temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(kv_weights, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!KV_Weights.Add(temp)) return false; }
temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(w0, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false;
//--- Initialize FF Weights temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(ff_1, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false;
temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(ff_2, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false; } }
在创建了关注度模块缓存区的所有集合之后,我们初始化另一个辅助缓冲区,我们将用它来写入中间值。
if(!Temp.BufferInit(MathMax(num_kv, out), 0)) return false; if(!Temp.BufferCreate(OpenCL)) return false; //--- return true; }
在每步中,确保控制操作的过程。在方法末尾,我们将操作的逻辑结果返回至调用者。
AttentionOut 和 AttentionInsideGradients 方法把我们调整过的内核放入执行队列。不过,我们将不再赘述其算法。把任何内核放入执行队列的算法保持不变:
- 定义任务空间
- 将所有必要的参数传递给内核。
- 将内核放入执行队列之中。
该算法的代码在本系列文章中已讲述多次。内核的原版排队方法,经我们修改后在专门讲述 ADAPT 方法的文章中进行了讲解。故此,请研究随附的代码,以便获取更多详细信息。
现在我们转到研究前向传递方法 feedForward 的算法。在方法参数中,我们收到一个指向上一层对象的指针,在本例中它提供输入。
bool CNeuronMLMHAttentionMLKV::feedForward(CNeuronBaseOCL *NeuronOCL) { if(CheckPointer(NeuronOCL) == POINTER_INVALID) return false;
在方法主体中,我们首先检查所接收指针的相关性。之后,我们声明一个指向 键-值 张量缓冲区的局部指针,并在模块的所有内层中运行一个循环。
CBufferFloat *kv = NULL; for(uint i = 0; (i < iLayers && !IsStopped()); i++) { //--- Calculate Queries, Keys, Values CBufferFloat *inputs = (i == 0 ? NeuronOCL.getOutput() : FF_Tensors.At(6 * i - 4)); CBufferFloat *q = QKV_Tensors.At(i * 2); if(IsStopped() || !ConvolutionForward(QKV_Weights.At(i * (optimization == SGD ? 2 : 3)), inputs, q, iWindow, iWindowKey * iHeads, None)) return false;
在循环主体中,我们首先生成 Query 实体张量。然后我们生成 键-值 张量。注意,我们不是在内层的每次迭代中生成后者,而是在每个 iLayersToOneKV 层。数学上,这个条件的控制非常简单:确保当前层的索引可以被一个 键-值 张量的层数整除,没有余数。应当注意的是,对于索引为 “0” 的第一层,除法的余数也没有。
if((i % iLayersToOneKV) == 0) { uint i_kv = i / iLayersToOneKV; kv = KV_Tensors.At(i_kv * 2); if(IsStopped() || !ConvolutionForward(KV_Weights.At(i_kv * (optimization == SGD ? 2 : 3)), inputs, kv, iWindow, 2 * iWindowKey * iHeadsKV, None)) return false; }
我们将指向所生成实体缓冲区的指针保存在我们之前声明的局部变量之中。如此这般,我们就能在循环的后续迭代中轻松访问它们。
在生成所有必要的实体后,我们执行前馈交叉关注操作。它们的结果被写入多头关注度的输出缓冲区。
//--- Score calculation and Multi-heads attention calculation CBufferFloat *temp = S_Tensors.At(i * 2); CBufferFloat *out = AO_Tensors.At(i * 2); if(IsStopped() || !AttentionOut(q, kv, temp, out)) return false;
然后,我们将结果数据压缩到原始数据的大小。
//--- Attention out calculation temp = FF_Tensors.At(i * 6); if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9)), out, temp, iWindowKey * iHeads, iWindow, None)) return false;
之后,遵循变换器算法,我们将自关注模块的操作结果、与输入数据进行汇总,并归一化获得的数值。
//--- Sum and normilize attention if(IsStopped() || !SumAndNormilize(temp, inputs, temp, iWindow, true)) return false;
接下来,我们经由 FeedForward 模块通验数据。
//--- Feed Forward inputs = temp; temp = FF_Tensors.At(i * 6 + 1); if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 1), inputs, temp, iWindow, 4 * iWindow, LReLU)) return false; out = FF_Tensors.At(i * 6 + 2); if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 2), temp, out, 4 * iWindow, iWindow, activation)) return false;
然后我们再次汇总来自 2 个线程的数据,并对其归一化。
//--- Sum and normalize out if(IsStopped() || !SumAndNormilize(out, inputs, out, iWindow, true)) return false; } //--- return true; }
循环遍历内部神经层的所有迭代成功完成之后,我们将操作的逻辑结果返回给调用者。
实现前馈通验方法之后,是反向传播算法的构造。这就是我们执行模型参数优化之处,以便在训练数据集上找到最大似然函数。如您所知,反向传播算法分为 2 个阶段。首先,我们将误差梯度传播给模型的所有元素,同时参考它们对整体结果的影响。该功能已在 calcInputGradients 方法中实现。在第二阶段(方法 updateInputWeights),我们朝着反梯度方向直接优化参数。
我们将开启实现反向传播算法的工作,配以误差梯度传播方法 calcInputGradients。在参数中,该方法接收指向前一个神经层对象的指针。在前馈通验期间,它扮演输入数据的角色。在该阶段,我们会将方法作的结果写入所获对象的误差梯度缓冲区。
bool CNeuronMLMHAttentionMLKV::calcInputGradients(CNeuronBaseOCL *prevLayer) { if(CheckPointer(prevLayer) == POINTER_INVALID) return false;
在方法主体中,我们检查所接收指针的相关性。之后,我们创建 2 个局部变量,存储指向内层之间传递数据的缓冲区指针。
CBufferFloat *out_grad = Gradient;
CBufferFloat *kv_g = KV_Tensors.At(KV_Tensors.Total() - 1);
在做了一些准备工作之后,我们在内部神经层上创建了一个逆循环。
for(int i = int(iLayers - 1); (i >= 0 && !IsStopped()); i--) { if(i == int(iLayers - 1) || (i + 1) % iLayersToOneKV == 0) kv_g = KV_Tensors.At((i / iLayersToOneKV) * 2 + 1);
在该循环中,我们首先判定是否需要更改 键-值 实体的误差梯度缓冲区。
正如我们所见,MLKV 方法意味着一个 键-值 实体张量将用于多个自关注模块。在组织前馈通验时,我们实现了相应的机制。现在我们必须组织误差梯度传播,与 键-值 级别对应。当然,我们将针对不同级别的误差梯度求和。
该算法的进一步构造非常接近交叉关注度对象中的误差梯度传播。首先,我们经由 FeedForward 模块将得自后续层的误差梯度传播。
//--- Passing gradient through feed forward layers if(IsStopped() || !ConvolutionInputGradients(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 2), out_grad, FF_Tensors.At(i * 6 + 1), FF_Tensors.At(i * 6 + 4), 4 * iWindow, iWindow, None)) return false; CBufferFloat *temp = FF_Tensors.At(i * 6 + 3); if(IsStopped() || !ConvolutionInputGradients(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 1), FF_Tensors.At(i * 6 + 4), FF_Tensors.At(i * 6), temp, iWindow, 4 * iWindow, LReLU)) return false;
我们在前馈通验中对来自 2 个线程的数据求和。故此,现在我们在反向传播通验中,针对涵盖相同数据线程的误差梯度求和。
//--- Sum and normilize gradients if(IsStopped() || !SumAndNormilize(out_grad, temp, temp, iWindow, false)) return false; out_grad = temp;
在下一步中,我们将得到的误差梯度拆分至关注头。
//--- Split gradient to multi-heads if(IsStopped() || !ConvolutionInputGradients(FF_Weights.At(i * (optimization == SGD ? 6 : 9)), out_grad, AO_Tensors.At(i * 2), AO_Tensors.At(i * 2 + 1), iWindowKey * iHeads, iWindow, None)) return false;
接下来,我们将误差梯度传播到 Query、Key 和 Value 实体。于此,我们将规划一条算法的小分支。因为我们需要对来自若干个内层的 键-值 张量的误差梯度求和。在执行误差梯度分派方法时,我们每次都会删除之前收集的数据,并用新数据覆盖它。因此,我们只在第一次调用期间,直接将误差梯度写入 键-值 张量缓冲区。
//--- Passing gradient to query, key and value if(i == int(iLayers - 1) || (i + 1) % iLayersToOneKV == 0) { if(IsStopped() || !AttentionInsideGradients(QKV_Tensors.At(i * 2), QKV_Tensors.At(i * 2 + 1), KV_Tensors.At((i / iLayersToOneKV) * 2), kv_g, S_Tensors.At(i * 2), AO_Tensors.At(i * 2 + 1))) return false; }
在其它情况下,我们首先将误差梯度写入辅助缓存区。然后我们将得到的数值加到这些早前的集合当中。
else { if(IsStopped() || !AttentionInsideGradients(QKV_Tensors.At(i * 2), QKV_Tensors.At(i * 2 + 1), KV_Tensors.At((i / iLayersToOneKV) * 2), GetPointer(Temp), S_Tensors.At(i * 2), AO_Tensors.At(i * 2 + 1))) return false; if(IsStopped() || !SumAndNormilize(kv_g, GetPointer(Temp), kv_g, iWindowKey, false, 0, 0, 0, 1)) return false; }
接下来,我们需要将误差梯度传递到前一层的级别。这里的 “前一层” 主要是指内部的前一层。不过,在处理最低级别时,我们会将误差梯度传递给方法参数中指定对象的缓冲区。
首先,我们定义一个指向接收误差梯度对象的指针。
CBufferFloat *inp = NULL; if(i == 0) { inp = prevLayer.getOutput(); temp = prevLayer.getGradient(); } else { temp = FF_Tensors.At(i * 6 - 1); inp = FF_Tensors.At(i * 6 - 4); }
之后,我们把来自 Query 实体的误差梯度降序。
if(IsStopped() || !ConvolutionInputGradients(QKV_Weights.At(i * (optimization == SGD ? 2 : 3)), QKV_Tensors.At(i * 2 + 1), inp, temp, iWindow, iWindowKey * iHeads, None)) return false;
我们汇总覆盖 2 个数据线程(Query + “through”)的误差梯度。
//--- Sum and normilize gradients if(IsStopped() || !SumAndNormilize(out_grad, temp, temp, iWindow, false, 0, 0, 0, 1)) return false;
上述算法中唯一缺少的是来自 Key 和 Value 实体的误差梯度。如您所知,这些实体不是自每个内层形成的。相应地,我们只将误差梯度传送到其形成时用到的数据。但有一点。之前我们已经将 Query 实体、及经由线程中的误差写入了输入数据的梯度缓冲区。因此,我们首先将误差梯度写入辅助缓冲区,然后将其添加到先前收集的数据之中。
//--- if((i % iLayersToOneKV) == 0) { if(IsStopped() || !ConvolutionInputGradients(KV_Weights.At(i / iLayersToOneKV * (optimization == SGD ? 2 : 3)), kv_g, inp, GetPointer(Temp), iWindow, 2 * iWindowKey * iHeadsKV, None)) return false; if(IsStopped() || !SumAndNormilize(GetPointer(Temp), temp, temp, iWindow, false, 0, 0, 0, 1)) return false; }
在循环迭代结束时,我们传递一个指向误差梯度缓冲区的指针,以便执行下一次循环迭代的操作。
if(i > 0) out_grad = temp; } //--- return true; }
在每步中,我们都会检查操作的结果。所有迭代成功完成循环之后,我们将方法操作的逻辑结果传递给调用者程序。
我们已经将误差梯度传播到所有内部对象和前一层。下一步是调整模型参数。该功能在 updateInputWeights 方法中实现。正如上面讨论的两种方法,在参数中,我们接收指向前一层对象的指针。
bool CNeuronMLMHAttentionMLKV::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { if(CheckPointer(NeuronOCL) == POINTER_INVALID) return false; CBufferFloat *inputs = NeuronOCL.getOutput();
在方法主体中,我们检查所接收指针的相关性,并立即把指向接收对象的结果缓冲区指针保存到局部变量之中。
接下来,我们创建一个循环,遍历所有内层,并更新模型参数。
for(uint l = 0; l < iLayers; l++) { if(IsStopped() || !ConvolutuionUpdateWeights(QKV_Weights.At(l * (optimization == SGD ? 2 : 3)), QKV_Tensors.At(l * 2 + 1), inputs, (optimization == SGD ? QKV_Weights.At(l * 2 + 1) : QKV_Weights.At(l * 3 + 1)), (optimization == SGD ? NULL : QKV_Weights.At(l * 3 + 2)), iWindow, iWindowKey * iHeads)) return false;
类似于前馈通验方法,我们首先调整 Query 张量生成参数。
然后,我们更新 键-值 张量生成参数。再次提请注意,这些参数不会在循环的每次迭代中进行调整。不过,在常规循环中调整 键-值 张量参数,可与正确的输入缓冲区同步,并令代码更清晰。
if(l % iLayersToOneKV == 0) { uint l_kv = l / iLayersToOneKV; if(IsStopped() || !ConvolutuionUpdateWeights(KV_Weights.At(l_kv * (optimization == SGD ? 2 : 3)), KV_Tensors.At(l_kv * 2 + 1), inputs, (optimization == SGD ? KV_Weights.At(l_kv*2 + 1) : KV_Weights.At(l_kv*3 + 1)), (optimization == SGD ? NULL : KV_Weights.At(l_kv * 3 + 2)), iWindow, 2 * iWindowKey * iHeadsKV)) return false; }
自关注模块不包含可训练参数。不过,参数会出现在我们压缩多头关注度结果,将其缩减至输入数据大小的层中。在下一步中,我们调整这些参数。
if(IsStopped() || !ConvolutuionUpdateWeights(FF_Weights.At(l * (optimization == SGD ? 6 : 9)), FF_Tensors.At(l * 6 + 3), AO_Tensors.At(l * 2), (optimization == SGD ? FF_Weights.At(l * 6 + 3) : FF_Weights.At(l * 9 + 3)), (optimization == SGD ? NULL : FF_Weights.At(l * 9 + 6)), iWindowKey * iHeads, iWindow)) return false;
之后,我们只需调整 FeedForward 模块参数。
if(IsStopped() || !ConvolutuionUpdateWeights(FF_Weights.At(l * (optimization == SGD ? 6 : 9) + 1), FF_Tensors.At(l * 6 + 4), FF_Tensors.At(l * 6), (optimization == SGD ? FF_Weights.At(l * 6 + 4) : FF_Weights.At(l * 9 + 4)), (optimization == SGD ? NULL : FF_Weights.At(l * 9 + 7)), iWindow, 4 * iWindow)) return false; //--- if(IsStopped() || !ConvolutuionUpdateWeights(FF_Weights.At(l * (optimization == SGD ? 6 : 9) + 2), FF_Tensors.At(l * 6 + 5), FF_Tensors.At(l * 6 + 1), (optimization == SGD ? FF_Weights.At(l * 6 + 5) : FF_Weights.At(l * 9 + 5)), (optimization == SGD ? NULL : FF_Weights.At(l * 9 + 8)), 4 * iWindow, iWindow)) return false;
我们将传递指向输入缓冲区的指针,以便后续内部神经循环,然后转到循环的下一次迭代。
inputs = FF_Tensors.At(l * 6 + 2); } //--- return true; }
所有迭代成功完成循环之后,我们将所执行操作的逻辑结果返回给调用方。
我们就新的关注度模块类的方法讲述到此结束,其中包括 MLKV 方法作者提议的方法。附件中提供了该类、及其所有方法的完整代码。
如早前所述,上述 MQA 和 GQA 方法是 MLKV 的特例。它们可用所创建类轻松实现,在类初始化方法中指定参数 “layers_to_one_kv=1”。如果 heads_kv 参数的值等于 Query 实体的关注头数量,我们将得到 vanilla 变换器。如果更少,那么我们得到 GQA。如果 heads_kv 等于 “1”,则我们得到 MQA 实现。
在准备本文时,我还用了 MLKV-CNeuronMLCrossAttentionMLKV 的方式创建了一个交叉关注度类。其结构如下所示。
class CNeuronMLCrossAttentionMLKV : public CNeuronMLMHAttentionMLKV { protected: uint iWindowKV; uint iUnitsKV; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *Context); virtual bool AttentionOut(CBufferFloat *q, CBufferFloat *kv, CBufferFloat *scores, CBufferFloat *out); virtual bool AttentionInsideGradients(CBufferFloat *q, CBufferFloat *q_g, CBufferFloat *kv, CBufferFloat *kv_g, CBufferFloat *scores, CBufferFloat *gradient); //--- virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *Context); public: CNeuronMLCrossAttentionMLKV(void) {}; ~CNeuronMLCrossAttentionMLKV(void) {}; virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key,uint heads, uint window_kw, uint heads_kv, uint units_count, uint units_count_kv, uint layers, uint layers_to_one_kv, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) const { return defNeuronMLCrossAttentionMLKV; } //--- virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); };
该类是作为上述 CNeuronMLMHAttentionMLKV 类的后继类而构建。我仅对其方法进行了细微的更正,您可以在附件中找到。
2.3模型架构
我们已利用 MQL5 实现了 MLKV 方法作者提议的方式。现在我们可以转到可学习模型的架构讲解。应当注意的是,不像近期的许多文献,今天我们不会调整环境状态编码器的架构。我们将往扮演者和评论者模型的架构里添加新对象。这些模型的架构是在 CreateDescriptions 方法中指定。
bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic) { //--- CLayerDescription *descr; //--- if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) return false; }
在参数中,该方法接收指向 2 个动态数组的指针,用于记录模型架构的顺序。在方法主体中,我们检查收到的指针,并在必要时创建新的对象实例。
首先,我们描述扮演者架构。我们向模型提供账户状态和持仓的描述。
//--- Actor actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = AccountDescr; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
接收到的数据由全连接层进行预处理。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = EmbeddingSize; descr.activation = SIGMOID; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
然后我们使用 MLKV 方式添加一个新的多级交叉关注度层。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLCrossAttentionMLKV; { int temp[] = {1, BarDescr}; ArrayCopy(descr.units, temp); } { int temp[] = {EmbeddingSize, NForecast}; ArrayCopy(descr.windows, temp); }
该层将当前账户状态、与从环境状态编码器获取的即将到来的价格走势预测进行比较。
此处,我们针对 Query 使用了 8 个关注头,而针对 键-值 张量仅用了 2 个。
{
int temp[] = {8, 2};
ArrayCopy(descr.heads, temp);
}
我们总共在模块中创建了 9 个嵌套层。每 3 层生成一个新的 键-值 张量。
descr.layers = 9; descr.step = 3;
为了优化模型参数,我们调用 Adam 方法。
descr.window_out = 32; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
在关注度模块之后,数据经由 2 个全连接层处理。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
在模型的输出中,我们创建一个扮演者的随机政策,其能够在一定最优值范围内动作。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 2 * NActions; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronVAEOCL; descr.count = NActions; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
此外,我们使用 FreDF 方法的方式在频域中协调动作。
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFreDFOCL; descr.window = NActions; descr.count = 1; descr.step = int(false); descr.probability = 0.8f; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
类似地,我们构建了一个评论者模型。于此,替代账户状态,我们向模型投喂由扮演者政策生成的动作向量。
//--- Critic critic.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = NActions; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; }
该数据也由全连接层进行预处理。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = EmbeddingSize; descr.activation = SIGMOID; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; }
它后随一个交叉关注度模块。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLCrossAttentionMLKV; { int temp[] = {1, BarDescr}; ArrayCopy(descr.units, temp); } { int temp[] = {EmbeddingSize, NForecast}; ArrayCopy(descr.windows, temp); } { int temp[] = {8, 2}; ArrayCopy(descr.heads, temp); } descr.window_out = 32; descr.step = 3; descr.layers = 9; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; }
交叉关注度模块中的数据处理结果经由 3 个全连接层通验。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; }
在模型的输出中,形成预期奖励的向量。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = NRewards; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; }
我们还添加了一个 FreDF 层,从而令频域奖励一致。
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFreDFOCL; descr.window = NRewards; descr.count = 1; descr.step = int(false); descr.probability = 0.8f; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; } //--- return true; }
收集数据和训练模型的智能系统没有变化。您可在附件中查看其完整代码。附件还包含本文中用到的所有程序的完整代码。
3. 测试
我们已实现了所提议方法。现在,我们转到工作的最后阶段:依据真实数据测试所提议方式。
如常,为了训练模型,我们采用 EURUSD 金融工具 2023 全年的真实历史数据,及 H1 时间帧。我们通过在 MetaTrader 5 策略测试器中运行环境交互 EA,收集训练数据集的数据。
在首次启动期间,我们的模型以随机参数进行初始化。结果就是,我们得到的完全随机的策略远非最优。为了往训练数据集里添加可盈利的运行,我建议在收集源数据时使用 Real-ORL 方法的方式。
收集初始训练数据集之后,我们首先在 MetaTrader 5 终端的图表上运行 “.../MLKV/StudyEncoder.mq5” 来实时训练环境状态编码器。该 EA 仅配以训练数据集工作,分析价格走势历史数据中的依赖关系。事实上,即使是一次通验也足以训练它,与交易结果无关。因此,我们训练状态编码器直至预测误差停止降低,不再更新训练数据集。
此处应当注意的是,扮演者和评论者模型的随后训练采用间接获得的预测。为了达成最大的结果,我们需要提取环境状态的当前趋势、及其在编码器隐藏状态下的强度,然后由扮演者和评论者模型访问。
在训练环境状态编码器的过程中获得所需的结果之后,我们转到训练扮演者政策和评论者动作评估的准确性。模型训练的第二阶段是迭代的。关键是,所分析的金融市场环境的可变性非常高。我们无法收集个体与环境之间交互的所有可能变体。因此,在扮演者和评论者模型进行多次训练迭代之后,我们执行一次收集训练数据的迭代。该过程应当取扮演者的当前政策,在某个区域中与环境交互的数据,补充先前收集的训练数据集,这将令其得以改进和优化。
故此,在干次迭代中,训练扮演者和评论者模型、与更新训练数据集的操作交替进行。该过程将重复若干次,直至获得所需的扮演者政策。
为了测试经过训练的模型,我们采用 2023 年 1 月的历史数据,这些数据未包含在训练数据集当中。其它参数按原样采用来自训练数据集迭代时的参数。
我必须承认,在为本文训练模型的过程中,我未能设法得到能够在测试数据集上产生盈利的政策。显然,这是模型退化过程的影响,这在作者的原始论文中已经指出了。
测试结果如下所示。

基于测试结果,我们看到新数据的盈利能力波动接近 “0”。总体而言,我们的最大和平均盈利高于类似的亏损指标。然而,44.4% 的交易胜率未能在测试期间获得任何盈利。
结束语
在本文中,我们领略了一种新方法 MLKV(多层键-值),这是一种创新方式,令变换器中的内存使用更高效。主要思路是将 KV 缓存扩展到多个层,这可以显著降低内存占用。
在本文的实践部分,我们利用 MQL5 实现了所提议方法。我们在真实数据上训练和测试了模型。我们的测试表明,所提议方式可以显著降低训练和模型操作的成本。然而,这是以牺牲模型的性能为代价的。总而言之,我们应当采取一种权衡方式,在成本和模型性能之间寻找一个折衷方案。
参考
文中所用程序
| # | 名称 | 类型 | 说明 |
|---|---|---|---|
| 1 | Research.mq5 | EA | 样本收集 EA |
| 2 | ResearchRealORL.mq5 | EA | 运用 Real-ORL 方法收集示例的 EA |
| 3 | Study.mq5 | EA | 模型训练 EA |
| 4 | StudyEncoder.mq5 | EA | 编码器训练 EA |
| 5 | Test.mq5 | EA | 模型测试 EA |
| 6 | Trajectory.mqh | 类库 | 系统状态定义结构 |
| 7 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
| 8 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/15117
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。
将您自己的 LLM 集成到 EA 中(第 5 部分):使用 LLMs 开发和测试交易策略(一)- 微调
人工蜂巢算法(ABHA):测试与结果
交易中的神经网络:用于时间序列预测的轻量级模型
你又是如何意识到网络已经学会了一些东西,而不是产生了随机信号呢?
行动者的随机政策假定行动具有一定的随机性。不过,在学习过程中,随机值的分散范围会大大缩小。问题的关键在于,在制定随机政策时,每个行动都要训练两个参数:平均值和数值散布的方差。在训练策略时,平均值趋于最优,方差趋于 0。
为了了解代理行动的随机性,我对同一策略进行了多次测试运行。如果代理产生的行为是随机的,那么所有测试的结果都会大相径庭。对于训练有素的策略来说,结果的差异将是微不足道的。
行动者的随机政策假定行动具有一定的随机性。不过,在训练过程中,随机值的散布范围会大大缩小。问题在于,在制定随机策略时,每个行动都需要训练两个参数:平均值和数值散布的方差。在训练策略时,平均值趋于最优,方差趋于 0。
为了了解 Agent 行动的随机性,我对同一策略进行了多次测试运行。如果代理产生的行为是随机的,那么所有测试的结果都会大相径庭。对于训练有素的策略来说,结果的差异将是微不足道的。
明白了,谢谢。