![神经网络变得轻松(第二十四部分):改进迁移学习工具](https://c.mql5.com/2/49/Neural_Networks_Easy_016_600x314.jpg)
神经网络变得轻松(第二十四部分):改进迁移学习工具
内容
概述
在本系列的上一篇文章中,我们创建了一款工具来利用迁移学习技术。 作为完工后的结果,我们得到了一款工具,能够编辑已训练模型。 利用此工具,我们可以从预训练模型中提取任意数量的神经层。 当然,也有限制条件。 我们只从初始数据层开始提取连续的层。 这种方式的原因在于神经网络的本质。 它们仅对初始数据与训练模型时所采用的数据拟合良好。
甚至,创建的工具不仅允许编辑已训练的模型。 它还允许创建全新的。 这就能够避免在程序代码中描述模型体系结构。 我们只需利用该工具描述模型。 然后,我们从文件加载已创建神经网络来跟踪和使用模型。 这样就可以在不更改程序代码的情况下试验不同的体系结构。 甚至不需要重新编译程序。 您只需更改模型文件。
这种有用的工具也应尽可能地做到用户友好。 因此,在本文中,我们将尝试提高其可用性。
1. 显示有关神经层的完整信息
我们来开始提高工具的可用性,方法是增加有关每个神经层的信息量。 正您记得的,在上一篇文章中,我们收集了有关已训练模型的每个神经层架构的所有可能信息。 但该工具仅向用户显示神经层类型,和输出神经元的数量。 当我们操控一个模型,并记忆其架构时,这是可以的。 但是当您试验大量的模型时,这些信息量显然是不够的。
另一方面,更多信息需要在信息板上有更多的空间。 大概,在模型信息窗口加入水平滚动并无好处。 因此,我决定把有关每个神经层的信息显示在若干行中。 输出信息必须易于阅读。 它看起来不应该像一个难以理解得巨大文本块。 为了将文本划分为模块,我们在两个连续神经层的描述之间插入可视的分隔符。
将文本分成几行的决定似乎是一个简单的解决方案,但其实施过程也需要非标准方式。 关键点在于我们用 CListView 列表类来显示有关模型体系结构的信息。 其中的每一行都表示列表的一个单独元素。 此外,不可能在若干行中显示一个元素,并将多个元素组合到一个实例中。 添加此类功能将需要更改算法和类的架构。 实际上,其结果将导致创建一个新的控件对象类。 在这种情况下,可以从 CListView 类继承或创建一个全新的元素。 但这需要太多的工作量,我尚无计划。
因此,我决定借用一个已经存在的类,但进行一些调整,无需更改类代码。 如上所提,我们将利用分隔符将文本直观地为独立神经层划分出模块。 分隔符会将带有模型架构描述的整个文本拆分为单独的神经层模块。 我们还将直观地针对每个神经层的信息进行分组。
但除了视觉上分组之外,我们还需要在程序级别理解列表元素属于哪个神经层。 在上一篇文章中,我们实现了鼠标选择已训练模型的单独神经层,改变复制的神经层数,并从已加到新模型的神经层列表中删除所选中的层。 在两种情况下,我们都需要清楚地理解所选中元素与特定神经层之间的对应关系。
将每个元素添加到列表时,我们为其指定了文本和数值。 通常,数值用于快速识别所选元素。 以前,我们为每个元素指定了一个独立的数值。 但也可以若干个元素共用一个值。 当然,这种方式会令识别列表中的每个元素变得困难。 但是,我们现在不需这样做。 我们只需要识别一组元素。 因此,使用此功能,我们识别的不是单一元素,而是整组元素。
bool AddItem( const string item, // text const long value // value )
实际上,此解决方案提供了另一个优势。 CListView 类拥有 SelectByValue 方法。 此方法的主要目的是依据其数值选择元素。 其算法是在列表的所有元素中查找匹配指定数值的第一个元素,并选择它。 规划列表的选择更改事件的处理,我们就能读取用户选择的元素值,并要求类从列表中选出匹配该数值的第一个元素。 这将令群组的开头更直观。 我认为这是一个非常方便的功能。
bool SelectByValue( const long value // value )
现在,我们来看看所描述方法的实现。 首先,我们需要实现神经层架构描述的文字性表示,以便将其显示在面板上。 为此,我们创建 LayerDescriptionToString 方法。 该方法在参数中接收指向神经层体系结构描述对象的指针,以及指向保存神经层文本描述字符串的动态数组指针。 数组的每个元素在模型架构描述列表当中都占单独一行。 按照上述说法,每个元素都是列表中分开描述一个神经层的一组元素。 通过使用动态数组,我们可以规划不同大小的元素群组,具体取决于描述特定神经层的需要。
该方法将接收数组中的元素数。
int CNetCreatorPanel::LayerDescriptionToString(const CLayerDescription *layer, string& result[]) { if(!layer) return -1;
在方法的主体中,我们首先检查收到的指向神经层架构描述指针的有效性。
接着,我们将准备一个局部变量,并清除生成的动态数组。
string temp; ArrayFree(result);
接下来,我们将根据神经层的类型创建其文本描述。 我们不会立即操控字符串的动态数组。 取而代之,整个描述将写在一个字符串之中。 但是我们将在字符串应被拆分的位置插入一个分隔符。 在此示例中,我用的是反斜杠 “\”。 我调用 StringFormat 函数来正确撰写带有该标记的文本。 该函数以最小的工作量生成格式化文本。
创建神经层架构的格式化字符串描述后,我们将调用 StringSplit 函数并将文本拆分为数行。 该函数根据在上一步中精心添加到文本中的分隔符元素,将文本分成数行。 该函数的便利性还在于它能将动态数组的大小增加到所需的大小。 如此,我们就不需要控制这部分。
switch(layer.type) { case defNeuronBaseOCL: temp = StringFormat("Dense (outputs %d, \activation %s, \optimization %s)", layer.count, EnumToString(layer.activation), EnumToString(layer.optimization)); if(StringSplit(temp, '\\', result) < 0) return -1; break; case defNeuronConvOCL: temp = StringFormat("Convolution (outputs %d, \window %d, step %d, window out %d, \activation %s, \optimization %s)", layer.count * layer.window_out, layer.window, layer.step, layer.window_out, EnumToString(layer.activation), EnumToString(layer.optimization)); if(StringSplit(temp, '\\', result) < 0) return -1; break; case defNeuronProofOCL: temp = StringFormat("Proof (outputs %d, \window %d, step %d, \optimization %s)", layer.count, layer.window, layer.step, EnumToString(layer.activation), EnumToString(layer.optimization)); if(StringSplit(temp, '\\', result) < 0) return -1; break; case defNeuronAttentionOCL: temp = StringFormat("Self Attention (outputs %d, \units %s, window %d, \optimization %s)", layer.count * layer.window, layer.count, layer.window, EnumToString(layer.optimization)); if(StringSplit(temp, '\\', result) < 0) return -1; break; case defNeuronMHAttentionOCL: temp = StringFormat("Multi-Head Attention (outputs %d, \units %s, window %d, heads %s, \optimization %s)", layer.count * layer.window, layer.count, layer.window, layer.step, EnumToString(layer.optimization)); if(StringSplit(temp, '\\', result) < 0) return -1; break; case defNeuronMLMHAttentionOCL: temp = StringFormat("Multi-Layer MH Attention (outputs %d, \units %s, window %d, key size %d, \heads %s, layers %d, \optimization %s)", layer.count * layer.window, layer.count, layer.window, layer.window_out, layer.step, layer.layers, EnumToString(layer.optimization)); if(StringSplit(temp, '\\', result) < 0) return -1; break; case defNeuronDropoutOCL: temp = StringFormat("Dropout (outputs %d, \probability %d, \optimization %s)", layer.count, layer.probability, EnumToString(layer.optimization)); if(StringSplit(temp, '\\', result) < 0) return -1; break; case defNeuronBatchNormOCL: temp = StringFormat("Batchnorm (outputs %d, \batch size %d, \optimization %s)", layer.count, layer.batch, EnumToString(layer.optimization)); if(StringSplit(temp, '\\', result) < 0) return -1; break; case defNeuronVAEOCL: temp = StringFormat("VAE (outputs %d)", layer.count); if(StringSplit(temp, '\\', result) < 0) return -1; break; case defNeuronLSTMOCL: temp = StringFormat("LSTM (outputs %d, \optimization %s)", layer.count, EnumToString(layer.optimization)); if(StringSplit(temp, '\\', result) < 0) return -1; break; default: temp = StringFormat("Unknown type %#x (outputs %d, \activation %s, \optimization %s)", layer.type, layer.count, EnumToString(layer.activation), EnumToString(layer.optimization)); if(StringSplit(temp, '\\', result) < 0) return -1; break; }
创建所有已知神经层的描述之后,不要忘记为未知类型添加一个标准描述。 因此,我们能够通知用户有关未知神经层的检测,并防止无意中破坏模型完整性。
在方法的末尾,将结果数组的大小返回给调用者。
//--- return ArraySize(result); }
接着,我们继续讨论 LoadModel 方法,我们已经在上一篇文章中讨论过该方法。 我们不会修改整个方法,但会修改将元素添加到列表的循环主体。 和以前一样,在循环体中,我们首先从动态数组中获取指向下一层描述对象的指针。 立即检查所接收指针的有效性。
for(int i = 0; i < total; i++) { CLayerDescription* temp = m_arPTModelDescription.At(i); if(!temp) return false;
然后我们要准备一个存储字符串的动态数组,并调用上面描述的 LayerDescriptionToString 方法生成神经层的文本描述。 该方法完成后,我们得到一个字符串描述数组,及内含的元素数量。 如果发生错误,该方法将返回一个空数组和 -1,替代数组大小。 通知用户有关错误,并完成方法。
string items[]; int total_items = LayerDescriptionToString(temp, items); if(total_items < 0) { printf("%s %d Error at layer %d: %d", __FUNCSIG__, __LINE__, i, GetLastError()); return false; }
如果成功生成描述文本,则先要加入模块分隔符元素。 然后,在嵌套循环中,输出描述神经层的文本数组的全部内容。
if(!m_lstPTModel.AddItem(StringFormat("____ Layer %d ____", i + 1), i + 1)) return false; for(int it = 0; it < total_items; it++) if(!m_lstPTModel.AddItem(items[it], i + 1)) return false; }
请注意,在指定群组 id 时,我们取模型描述的动态数组中神经层的序号,再加 1。 这是所需的,因为数组中的索引以 0 开头。 如果我们将 0 指定为数字标识符,CListView 类将自动将其替换为列表中的元素总数。 我们不想收到随机值来替代群组 ID。
LoadModel 方法代码的其余部分未更改。 附件中提供了其完整代码。 此外,附件还包含程序中用到的所有方法和类的代码。 特别是,您可以看到类似附加的显示新 ChangeNumberOfLayers 模型描述的方法。
请注意,在 ChangeNumberOfLayers 方法中,有关模型的信息是从包含模型架构描述的两个动态数组中收集的。 第一个描述供体模型的架构。 我们从中获取所复制神经层的描述。 第二个数组包含我们正在添加的神经网络的描述。
输出模型架构描述后,迈入处理已创建列表状态变化事件的方法。
ON_EVENT(ON_CHANGE, m_lstPTModel, OnChangeListPTModel) ON_EVENT(ON_CHANGE, m_lstNewModel, OnChangeListNewModel)
如上所述,当用户选择列表中的任意行时,我们会将所选内容移动到指定模块的第一行。 为此,我们只需获取用户选中元素的群组 ID,并指示程序选择匹配给定 ID 的第一个元素。 此操作由 SelectByValue 方法实现。
bool CNetCreatorPanel::OnChangeListNewModel(void) { long value = m_lstNewModel.Value(); //--- return m_lstNewModel.SelectByValue(value); }
这将扩展有关模型架构的显示信息。 信息量最低限度足够,并且特定于神经层类型。 因此,用户只能看到有关特定神经层的相关信息。 甚而,不会有额外的信息弄乱窗口。
2. 已用激活/未用停用的输入字段
下一处修改涉及数据输入字段。 也许看起来很奇怪,但它们为想象提供了广阔领域。 可能首先抓住您眼球的是输入信息的数量。 该面板提供了描述神经层架构的 CLayerDescription 类的所有元素的输入字段。 我不是说这很糟糕。 用户可以查看所有指定的数据,并在添加图层之前按照任何顺序随时进行更改。 但我们知道,并非所有这些字段都与所有神经层相关。
例如,对于一个完全连接神经层,只需指定三个参数就足够了:神经元的数量、激活函数、和参数优化方法。 其余参数与它无关。 当应对卷积神经层时,需要指定输入数据窗口的大小,及其步长。 输出元素的数量将取决于源数据缓冲区大小,和两个指定的参数。
在递归 LSTM 模块中,激活函数已由模块架构定义,故无需指定它们。
好吧,用户可能知道所有这些功能。 但一个设计良好的工具能够警示用户避免可能的“机械”错误。 有两种可能的预防选项。 我们可以从面板中删除不相关的元素,或者简单地令其不可编辑。
每个选项都有其优点和缺点。 第一个选项的优点包括减少面板上的输入字段数量。 故此,面板可以更紧凑。 缺点是实现起来更加复杂。 鉴于我们每次都需要重新排列面板上的元素。 与此同时,对象的持续重新排列会令用户感到困惑,并导致出错。
我的观点是,当您需要输入大量数据时,运用此方法是合理的。 然后删除不必要的对象,令面板更加紧凑和整洁。
如果我们只有少量元素,那么第二种选择是可接受的。 我们可以轻松地一次性排列面板上的所有元素。 甚至,我们不必围绕面板移动它们,从而令用户困惑。 用户只需在视觉上记住它们的位置,从而提高整体性能。
我们已将所有输入字段置于界面的面板上。 因此,我考虑到第二个实现选项是可接受的。
我们已经有一个架构解决方案。 但我们要走得更远一点。 该面板所含有的字段是下拉列表和直接输入字段。 下拉字段仅允许从提供的选项里选择一个。 但在输入数值的字段中,用户也可实际输入任何文本。
然而,我们期望从其得到一个整数值。 从逻辑上讲,我们应该在把输入信息传递到所创建神经层架构的对象描述之前,加入针对输入信息的检查。 为了与用户共享信息的正确性,输入的信息将在用户输入文本后立即进行验证。 验证之后,我们将用工具接受的信息替换用户在字段中输入的信息。 因此,用户可以看到输入和读取信息之间的差别。 如有必要,用户可以进一步纠正数据。
再等一下。 在 CLayerDescription 类中描述神经层架构时,我们有双重用途的元素。 例如,step 是为卷积层和子样本层指定源数据窗口的步长。 但在描述关注神经层时,采用相同的参数指定关注者的数量。
window_out 参数指定卷积层中的过滤器数量,和关注模块中内部关键层的大小。
为了令界面更加用户友好,最好在选择相应的神经层类型时更改文本标签。
用户不会受到界面窗口中的重新排列问题困扰。 字段本身不会更改。 只有它旁边的信息会发生变化。 如果用户不关注新数据,并自动将信息输入相应的字段,则不会在模型规划中导致任何错误。 在任何情况下,数据都将发送到层架构描述所需的元素之中。
为了实现上述方案,我们需要退后一步,做一些准备工作。
首先,在界面面板上创建文本标签时,我们没有保存指向对应对象的指针。 现在,当我们需要更改其中一些文本时,我们不得不在普通对象数组中查找它们。 为了避免这种情况,我们回到 CreateLabel 文本标签创建方法。 方法操作完毕后,我们返回一个指向所创建对象的指针,替代原本的逻辑结果。
CLabel* CNetCreatorPanel::CreateLabel(const int id, const string text, const int x1, const int y1, const int x2, const int y2 ) { CLabel *tmp_label = new CLabel(); if(!tmp_label) return NULL; if(!tmp_label.Create(m_chart_id, StringFormat("%s%d", LABEL_NAME, id), m_subwin, x1, y1, x2, y2)) { delete tmp_label; return NULL; } if(!tmp_label.Text(text)) { delete tmp_label; return NULL; } if(!Add(tmp_label)) { delete tmp_label; return NULL; } //--- return tmp_label; }
当然,我们不会保存指向所有标签的指针。 我们只会保存两个对象。 为此,我们要声明两个额外的变量。 尽管我们使用指向对象的动态指针,但我们不会将它们添加到工具类的析构函数之中。 这些对象仍将在所有工具对象的数组中删除。 但与此同时,我们可以直接访问我们需要的对象。
CLabel* m_lbWindowOut; CLabel* m_lbStepHeads;
我们在类的 Create 方法中编写指向新变量的指针。 该方法需要小的修改,如下所示。 方法代码的其余部分则保持不变。 附件中提供了该方法的完整代码。
bool CNetCreatorPanel::Create(const long chart, const string name, const int subwin, const int x1, const int y1) { if(!CAppDialog::Create(chart, name, subwin, x1, y1, x1 + PANEL_WIDTH, y1 + PANEL_HEIGHT)) return false; //--- ............... ............... //--- ly1 = ly2 + CONTROLS_GAP_Y; ly2 = ly1 + EDIT_HEIGHT; m_lbStepHeads = CreateLabel(8, "Step", lx1, ly1, lx1 + EDIT_WIDTH, ly2); if(!m_lbStepHeads) return false; //--- ............... ............... //--- ly1 = ly2 + CONTROLS_GAP_Y; ly2 = ly1 + EDIT_HEIGHT; m_lbWindowOut = CreateLabel(9, "Window Out", lx1, ly1, lx1 + EDIT_WIDTH, ly2); if(!m_lbWindowOut) return false; //--- ............... ............... //--- return true; }
我们的下一步准备工作是创建一个更改输入字段状态的方法。 标准 CEdit 类已经拥有 ReadOnly 结构,可更改对象状态。 但该方法不提供状态的可视化。 它只锁定输入数据的可能性。 不过,我们需要对于输入对象进行可用和不可用于视觉分离。 我们不会发明任何新东西。 我们用背景色突出显示对象。 可编辑字段将具有白色背景,不可编辑字段的背景颜色将与面板颜色匹配。
此功能将在 EditReedOnly 方法中实现。 在方法参数中,传递指向对象的指针,和新的状态标志。 在方法主体中,将接收到的标志传递给输入对象的 ReadOnly 方法,并根据指定的标志设置对象的背景。
bool CNetCreatorPanel::EditReedOnly(CEdit& object, const bool flag) { if(!object.ReadOnly(flag)) return false; if(!object.ColorBackground(flag ? CONTROLS_DIALOG_COLOR_CLIENT_BG : CONTROLS_EDIT_COLOR_BG)) return false; //--- return true; }
现在关注激活函数。 或者更确切地说,是可用激活函数的下拉列表。 并非所有神经层类型都需要下拉列表。 某些架构提供预定义的激活函数类型,无法通过列表进行更改。 这方面的一个例子是 LSTM 模块、子样本层、关注模块。 然而,CComboBox 类未提供一个方法,以任何方式阻止类功能。 因此,我们将采用一种变通办法,并根据具体情况更改可用激活函数的列表。 我们将创建单独的方法来填充可用激活函数的列表。
事实上,这样的方法只有两种。 其中之一是通用的,指示激活函数 — ActivationListMain。 其二则为空 — ActivationListEmpty,它只有一个选项 “None”。
为了理解方法构造算法,我们来研究激活列表主方法的代码。 在方法伊始,清除可用激活函数列表当中的现有元素。 然后在循环中调用 ItemAdd 方法和 EnumToString 函数填充列表。
请注意,激活函数枚举中元素的编码从 -1 开头,表示 “None”。 下一个函数 — 双曲正切 TANH — 它索引为 0。 当填写描述列表时,对于上述指出的原因,这其实很不妙。 因为下拉列表是 CListView 类。 因此,为了排除列表标识符的 null 值,我们简单地向枚举标识符里加入一个小常量。
填充可用激活函数列表后,设置默认值,并退出方法。
bool CNetCreatorPanel::ActivationListMain(void) { if(!m_cbActivation.ItemsClear()) return false; for(int i = -1; i < 3; i++) if(!m_cbActivation.ItemAdd(EnumToString((ENUM_ACTIVATION)i), i + 2)) return false; if(!m_cbActivation.SelectByValue((int)DEFAULT_ACTIVATION + 2)) return false; //--- return true; }
我们需要的另一种方法能帮助我们稍微把用户的工作自动化。 如上所述,在卷积模型或关注模块的情况下,模型输出处的元素数量取决于所分析的初始数据的窗口大小,及其走势步长。 为了消除可能的错误,并减少用户的手工劳动,我决定关闭模块数量的输入字段,并调用单独的 SetCounts 方法填充它。
在该方法的参数中,我们传递所创建神经层的类型。 该方法将返回操作的布尔值结果。
bool CNetCreatorPanel::SetCounts(const uint position, const uint type) { const uint position = m_arAddLayers.Total();
且在方法主体中,我们首先判定前一层输出中的元素数量。 请注意,前一层可以处于两个动态数组之一当中:供体模型架构的描述,或加入的新神经层的架构描述。 我们可以很容易地判定从哪里获取最后一个神经层。 神经层将始终添加到列表的末尾。 因此,只有当新神经层数组为空时,我们才会从供体模型中提取一层。 遵循这个逻辑,我们检查新神经层的动态数组的大小。 根据其大小,从相应的数组请求指向前一个神经层的指针。
CLayerDescription *prev; if(position <= 0) { if(!m_arPTModelDescription || m_spPTModelLayers.Value() <= 0) return false; prev = m_arPTModelDescription.At(m_spPTModelLayers.Value() - 1); if(!prev) return false; } else { if(m_arAddLayers.Total() < (int)position) return false; prev = m_arAddLayers.At(position - 1); } if(!prev) return false;
接下来,根据其类型,计算前一层的结果缓冲区中的元素数量。 如果缓冲区大小不大于 0,则以 false 退出该方法。
int outputs = prev.count; switch(prev.type) { case defNeuronAttentionOCL: case defNeuronMHAttentionOCL: case defNeuronMLMHAttentionOCL: outputs *= prev.window; break; case defNeuronConvOCL: outputs *= prev.window_out; break; } //--- if(outputs <= 0) return false;
然后从界面读取所分析的初始数据窗口大小值,及其步长。 并准备一个变量来记录计算结果。
int counts = 0; int window = (int)StringToInteger(m_edWindow.Text()); int step = (int)StringToInteger(m_edStep.Text());
元素的数量将根据所创建神经层的类型进行计算。 为了计算卷积层和子样本层的元素数量,我们需要所分析输入数据窗口的大小,及其步长。
switch(type) { case defNeuronConvOCL: case defNeuronProofOCL: if(step <= 0) break; counts = (outputs - window - 1 + 2 * step) / step; break;
但使用关注模块时,步长等于窗口大小。 使用数学规则,减少公式。
case defNeuronAttentionOCL: case defNeuronMHAttentionOCL: case defNeuronMLMHAttentionOCL: if(window <= 0) break; counts = (outputs + window - 1) / window; break;
当使用变分自动编码器的潜伏层时,层大小将比前一个小两倍。
case defNeuronVAEOCL: counts = outputs / 2; break;
针对所有其它情况,我们将神经层的大小设置为等于前一层的大小。 这可以在声明常规化,或 Dropout 层时使用。
default: counts = outputs; break; } //--- return m_edCount.Text((string)counts); }
将接收到的值转移至相应的界面元素。
现在我们有足够的手段来规划界面更改,具体取决于所要创建的神经层的类型。 那么,我们来看看我们应如何做到这一点。 此功能在 OnChangeNeuronType 方法中实现。 之所以这样称呼这个名字,是因为每次用户更改神经层的类型时,我们都会调用它。
指定的方法不包含参数,并返回操作的逻辑结果。 在方法主体中,我们首先定义用户选择的神经层类型。
bool CNetCreatorPanel::OnChangeNeuronType(void) { long type = m_cbNewNeuronType.Value();
进而,该算法的分支取决于所选的神经层类型。 每个神经层的算法都是相似的。 但几乎每个神经层都有自己的细微差别。 针对完全连接的神经层,我们只为神经元的数量留下一个激活的输入字段,并加载可能的激活函数的完整列表。
switch((int)type) { case defNeuronBaseOCL: if(!EditReedOnly(m_edCount, false) || !EditReedOnly(m_edBatch, true) || !EditReedOnly(m_edLayers, true) || !EditReedOnly(m_edProbability, true) || !EditReedOnly(m_edStep, true) || !EditReedOnly(m_edWindow, true) || !EditReedOnly(m_edWindowOut, true)) return false; if(!ActivationListMain()) return false; break;
对于卷积层,另外三个输入字段将处于激活状态。 其中包括所分析的源数据窗口的大小及其步长,以及结果窗口的大小(筛选器的数量)。 我们还更新了两个文本标签的值,并根据源数据窗口的大小和步长,重新计算神经层中的元素数量。 请注意,我们计算的是一个过滤器的元素数量。 因此,结果不取决于所使用的过滤器数量。
case defNeuronConvOCL: if(!EditReedOnly(m_edCount, true) || !EditReedOnly(m_edBatch, true) || !EditReedOnly(m_edLayers, true) || !EditReedOnly(m_edProbability, true) || !EditReedOnly(m_edStep, false) || !EditReedOnly(m_edWindow, false) || !EditReedOnly(m_edWindowOut, false)) return false; if(!m_lbStepHeads.Text("Step")) return false; if(!m_lbWindowOut.Text("Window Out")) return false; if(!ActivationListMain()) return false; if(!SetCounts(defNeuronConvOCL)) return false; break;
对于子采样层,我们不必指定过滤器的数量,和激活函数。 在我们的实现中,我们始终采用最大值作为子样本层的激活函数。 因此,清除可用激活函数的列表。 但与卷积层一样,我们开始计算所创建层的元素数量。
case defNeuronProofOCL: if(!EditReedOnly(m_edCount, true) || !EditReedOnly(m_edBatch, true) || !EditReedOnly(m_edLayers, true) || !EditReedOnly(m_edProbability, true) || !EditReedOnly(m_edStep, false) || !EditReedOnly(m_edWindow, false) || !EditReedOnly(m_edWindowOut, true)) return false; if(!m_lbStepHeads.Text("Step")) return false; if(!SetCounts(defNeuronProofOCL)) return false; if(!ActivationListEmpty()) return false; break;
声明 LSTM 模块时,激活函数列表也不会用到,故清除它。 仅有一个输入字段可用 — 神经层中的元素数量。
case defNeuronLSTMOCL: if(!EditReedOnly(m_edCount, false) || !EditReedOnly(m_edBatch, true) || !EditReedOnly(m_edLayers, true) || !EditReedOnly(m_edProbability, true) || !EditReedOnly(m_edStep, true) || !EditReedOnly(m_edWindow, true) || !EditReedOnly(m_edWindowOut, true)) return false; if(!ActivationListEmpty()) return false; break;
为了初始化 Dropout 层,我们只需要指定神经元 dropout 的概率值。 不使用激活函数。 元素的数量等于前一个神经层的大小。
case defNeuronDropoutOCL: if(!EditReedOnly(m_edCount, true) || !EditReedOnly(m_edBatch, true) || !EditReedOnly(m_edLayers, true) || !EditReedOnly(m_edProbability, false) || !EditReedOnly(m_edStep, true) || !EditReedOnly(m_edWindow, true) || !EditReedOnly(m_edWindowOut, true)) return false; if(!SetCounts(defNeuronDropoutOCL)) return false; if(!ActivationListEmpty()) return false; break;
类似的方式适用于批量常规化层。 不过,在此我们指定批量大小。
case defNeuronBatchNormOCL: if(!EditReedOnly(m_edCount, true) || !EditReedOnly(m_edBatch, false) || !EditReedOnly(m_edLayers, true) || !EditReedOnly(m_edProbability, true) || !EditReedOnly(m_edStep, true) || !EditReedOnly(m_edWindow, true) || !EditReedOnly(m_edWindowOut, true)) return false; if(!SetCounts(defNeuronBatchNormOCL)) return false; if(!ActivationListEmpty()) return false; break;
取决于关注方法,我们激活了注意者和神经层数量的输入字段。 相应输入字段的文本标签已更改。
case defNeuronAttentionOCL: if(!EditReedOnly(m_edCount, true) || !EditReedOnly(m_edBatch, true) || !EditReedOnly(m_edLayers, true) || !EditReedOnly(m_edProbability, true) || !EditReedOnly(m_edStep, true) || !EditReedOnly(m_edWindow, false) || !EditReedOnly(m_edWindowOut, true)) return false; if(!SetCounts(defNeuronAttentionOCL)) return false; if(!ActivationListEmpty()) return false; break; case defNeuronMHAttentionOCL: if(!EditReedOnly(m_edCount, true) || !EditReedOnly(m_edBatch, true) || !EditReedOnly(m_edLayers, true) || !EditReedOnly(m_edProbability, true) || !EditReedOnly(m_edStep, false) || !EditReedOnly(m_edWindow, false) || !EditReedOnly(m_edWindowOut, true)) return false; if(!m_lbStepHeads.Text("Heads")) return false; if(!SetCounts(defNeuronMHAttentionOCL)) return false; if(!ActivationListEmpty()) return false; break; case defNeuronMLMHAttentionOCL: if(!EditReedOnly(m_edCount, true) || !EditReedOnly(m_edBatch, true) || !EditReedOnly(m_edLayers, false) || !EditReedOnly(m_edProbability, true) || !EditReedOnly(m_edStep, false) || !EditReedOnly(m_edWindow, false) || !EditReedOnly(m_edWindowOut, false)) return false; if(!m_lbStepHeads.Text("Heads")) return false; if(!m_lbWindowOut.Text("Keys size")) return false; if(!SetCounts(defNeuronMLMHAttentionOCL)) return false; if(!ActivationListEmpty()) return false; break;
对于变分自动编码器的潜伏层,无需输入任何数据。 仅选择图层类型,并将其添加到模型之中。
case defNeuronVAEOCL: if(!EditReedOnly(m_edCount, true) || !EditReedOnly(m_edBatch, true) || !EditReedOnly(m_edLayers, true) || !EditReedOnly(m_edProbability, true) || !EditReedOnly(m_edStep, true) || !EditReedOnly(m_edWindow, true) || !EditReedOnly(m_edWindowOut, true)) return false; if(!ActivationListEmpty()) return false; if(!SetCounts(defNeuronVAEOCL)) return false; break;
如果未找到参数中指定的神经层类型,则使用 “false” 完成该方法。
default: return false; break; } //--- return true; }
如果该方法的所有操作都成功完成,则退出并显示正面结果。
现在我们需要在正确的时间开始规划所描述方法。 我们将采用与图层类型选择元素值更改相关的事件,并将添加相应的事件处理程序。
EVENT_MAP_BEGIN(CNetCreatorPanel) ON_EVENT(ON_CLICK, m_edPTModel, OpenPreTrainedModel) ON_EVENT(ON_CLICK, m_btAddLayer, OnClickAddButton) ON_EVENT(ON_CLICK, m_btDeleteLayer, OnClickDeleteButton) ON_EVENT(ON_CLICK, m_btSave, OnClickSaveButton) ON_EVENT(ON_CHANGE, m_spPTModelLayers, ChangeNumberOfLayers) ON_EVENT(ON_CHANGE, m_lstPTModel, OnChangeListPTModel) ON_EVENT(ON_CHANGE, m_lstNewModel, OnChangeListNewModel) ON_EVENT(ON_CHANGE, m_cbNewNeuronType, OnChangeNeuronType) EVENT_MAP_END(CAppDialog)
通过实现上述方法,我们根据所选神经层的类型,来规划输入字段的激活和停用。 但我们也讨论了数据整体控制。
在所有输入字段中,我们期望整数值大于零。 唯一的例外是 Dropout 层中元素舍弃的概率值。 这可以是介于 0 和 1 之间的实际值。 因此,我们需要两种方法来验证输入的数据。 一个针对概率,一个针对所有其它元素。
这两种方法的算法都非常简单。 首先,我们读取用户输入的文本值,将其转换为数字值,并检查它是否在有效值范围内。 将接收到的值输入回界面的相应窗口。 用户只需要检查数据是否已被正确解释。
bool CNetCreatorPanel::OnEndEditProbability(void) { double value = StringToDouble(m_edProbability.Text()); return m_edProbability.Text(DoubleToString(fmax(0, fmin(1, value)), 2)); } bool CNetCreatorPanel::OnEndEdit(CEdit& object) { long value = StringToInteger(object.Text()); return object.Text((string)fmax(1, value)); }
请注意,在检查概率值的正确性时,我们会清楚地识别输入字段。 但为了在第二个方法中识别对象,我们将在方法参数中传递相关的对象指针。 这是另一个挑战。 建议的事件处理宏替换尚无合适的宏替换来传递调用者对象的指针至事件处理方法。 因此,我们需要添加这样一个宏替换。
#define ON_EVENT_CONTROL(event,control,handler) if(id==(event+CHARTEVENT_CUSTOM) && lparam==control.Id()) \ { handler(control); return(true); }
在输入字段中,可以是所分析源数据窗口的大小,及其步长。 这些参数会影响神经层中元素的数量。 因此,在更改它们的数值时,我们需要重新计算所创建神经层的大小。 但是我们所用的事件处理模型只允许每个事件有一个处理程序。 与此同时,我们可用一个处理程序来为不同的事件服务。 因此,我们来创建另一个方法,该方法将首先检查输入字段中的值,它们是窗口大小和步长。 然后我们调用重新计算神经层大小的方法,同时参考所选的神经层类型。
bool CNetCreatorPanel::OnChangeWindowStep(void) { if(!OnEndEdit(m_edWindow) || !OnEndEdit(m_edStep)) return false; return SetCounts((uint)m_cbNewNeuronType.Value()); }
现在我们只需要完成我们的事件处理程序映射。 这将允许您在正确的时间运行正确的事件处理程序。
EVENT_MAP_BEGIN(CNetCreatorPanel) ON_EVENT(ON_CLICK, m_edPTModel, OpenPreTrainedModel) ON_EVENT(ON_CLICK, m_btAddLayer, OnClickAddButton) ON_EVENT(ON_CLICK, m_btDeleteLayer, OnClickDeleteButton) ON_EVENT(ON_CLICK, m_btSave, OnClickSaveButton) ON_EVENT(ON_CHANGE, m_spPTModelLayers, ChangeNumberOfLayers) ON_EVENT(ON_CHANGE, m_lstPTModel, OnChangeListPTModel) ON_EVENT(ON_CHANGE, m_lstNewModel, OnChangeListNewModel) ON_EVENT(ON_CHANGE, m_cbNewNeuronType, OnChangeNeuronType) ON_EVENT(ON_END_EDIT, m_edWindow, OnChangeWindowStep) ON_EVENT(ON_END_EDIT, m_edStep, OnChangeWindowStep) ON_EVENT(ON_END_EDIT, m_edProbability, OnEndEditProbability) ON_EVENT_CONTROL(ON_END_EDIT, m_edCount, OnEndEdit) ON_EVENT_CONTROL(ON_END_EDIT, m_edWindowOut, OnEndEdit) ON_EVENT_CONTROL(ON_END_EDIT, m_edLayers, OnEndEdit) ON_EVENT_CONTROL(ON_END_EDIT, m_edBatch, OnEndEdit) EVENT_MAP_END(CAppDialog)
3. 添加键盘事件处理
为了令我们的迁移学习工具更加便利和用户友好,我们已经做得很好了。 但所有这些改进都集中在界面上,令其更容易与鼠标或触摸板一起使用。 但我们还没有实现该工具与键盘交互的任何可能性。 例如,利用向上和向下箭头更改要复制的神经层数量会很方便。 按下 Delete 键则可调用一个方法,从正在创建的模型中删除选定的神经层。
我现在还不会深入探讨这个话题。 我只会向您展示如何在几行代码中利用现有事件处理程序添加键值处理。
上面提出的所有三个功能都已在我们的工具代码中实现了。 当发生特定事件时,它们就会执行。 若要删除选定的神经层,面板上有一个单独的按钮。 需复制的神经层数量则利用 CSpinEdit 对象的按钮进行更改。
从技术上讲,按下键盘按钮与按下鼠标按钮,或移动鼠标按钮是相同的事件。 它也由 OnChartEvent 函数处理。 因此,调用了该类的 ChartEvent 方法。
发生击键事件时,我们将收到 CHARTEVENT_KEYDOWN 事件的 ID。 lparam 变量里将保存按键的 ID。
利用此属性,我们可以玩转键盘,并判定我们感兴趣的所有按键的标识符。 例如,以下是上面提到的键值的代码。
#define KEY_UP 38 #define KEY_DOWN 40 #define KEY_DELETE 46
现在我们回到我们类的 ChartEvent 方法。 在其内,我们调用了父类中的一个类似方法。 现在,我们需要添加对事件 ID 和工具可见性的检查。 仅在工具界面可见时,事件处理程序才可运行。 用户应该能够看到面板上发生的事情,并直观地控制过程。
如果通过第一阶段验证,则检查按键的代码。 如果列表中有相应的键值,则在界面面板上生成一个对应于类似操作的自定义事件。
例如,当按下 Delete 时,在界面面板上生成按钮单击事件 DELETE。
void CNetCreatorPanel::ChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam) { CAppDialog::ChartEvent(id, lparam, dparam, sparam); if(id == CHARTEVENT_KEYDOWN && m_spPTModelLayers.IsVisible()) { switch((int)lparam) { case KEY_UP: EventChartCustom(CONTROLS_SELF_MESSAGE, ON_CLICK, m_spPTModelLayers.Id() + 2, 0.0, m_spPTModelLayers.Name() + "Inc"); break; case KEY_DOWN: EventChartCustom(CONTROLS_SELF_MESSAGE, ON_CLICK, m_spPTModelLayers.Id() + 3, 0.0, m_spPTModelLayers.Name() + "Dec"); break; case KEY_DELETE: EventChartCustom(CONTROLS_SELF_MESSAGE, ON_CLICK, m_btDeleteLayer.Id(), 0.0, m_btDeleteLayer.Name()); break; } } }
之后我们退出该方法。 接着,我们让程序利用现有的事件处理程序和方法来处理生成的事件。
当然,仅当程序中有相应的处理程序时,这种方式才有可能。 但是,您可以创建新的事件处理程序,并为它们生成唯一的事件。
结束语
在本文中,我们研究了提高用户界面可用性的各种选项。 您可以通过测试文章附带的工具来评估方法的品质。 我希望您觉得这个工具有用。 如果您能在相关论坛帖子中分享您的印象和改进该工具的愿望,我将不胜感激。
参考文献列表
- 神经网络变得轻松(第二十部分):自动编码器
- 神经网络变得轻松(第二十一部分):变分自动编码器(VAE)
- 神经网络变得轻松(第二十二部分):递归模型的无监督学习
- 神经网络变得轻松(第二十三部分):构建迁移学习工具
本文中用到的程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | NetCreator.mq5 | EA | 模型构建工具 |
2 | NetCreatotPanel.mqh | 类库 | 创建工具的类库 |
3 | NeuroNet.mqh | 类库 | 用于创建神经网络的类库 |
4 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/11306
![DoEasy. 控件(第 15 部分):TabControl WinForms 对象 — 多行选项卡标题、选项卡处理方法](https://c.mql5.com/2/48/MQL5-avatar-doeasy-library-2__3.png)
![MQL5 中的矩阵和向量操作](https://c.mql5.com/2/48/matrix_and_vectors_2.png)