English Русский Español Deutsch 日本語 Português
preview
神经网络变得轻松(第二十三部分):构建迁移学习工具

神经网络变得轻松(第二十三部分):构建迁移学习工具

MetaTrader 5交易系统 | 21 十一月 2022, 09:18
819 0
Dmitriy Gizlyk
Dmitriy Gizlyk

内容


概述

我们继续沉浸在人工智能世界之中。 今天,我邀请您来掌握迁移学习技术。 我们曾在各种文章中提到过这项技术,但从未用到过。 同时,这是一个强力的工具,可以提高开发神经网络的效率,并降低训练它们的成本。


1. 迁移学习的目的

什么是迁移学习,为什么我们需要它? 迁移学习是一种机器学习方法,其用来为解决一个问题而采用训练的模型知识,可被重用作解决新问题的基础。 当然,为了解决新问题,模型需在新数据上进行了初步的额外训练。 在一般情况下,采用正确选择的供体模型,与从头开始训练类似的模型相比,额外的训练运行得更快,结果更好。

可以采用完整的供体模型或其中的一部分。

与这项技术类似的情况是,我们曾用聚类和数据压缩结果来预处理神经网络的源数据。 在这种情况下,我们用到了整个预训练模型。 但在建立解决新问题的模型时,我们没有对供体模型进行额外的培训。 我们仅用它来预处理“未加工”源数据,并基于此数据训练一个新模型。

当我们开始研究自动编码器时,我们还讨论过在模型训练后再使用迁移学习的可能性。 但在这种情况下,我们不能完全使用自动编码器作为完整的供体模型,因为我们训练它的目的是压缩原始数据,然后再从压缩态中恢复它。 因此,完全使用自动编码器作为供体模型毫无意义。 对于数据预处理,仅把它当作编码器来用会更有效。 在这种情况下,整体模型将更小,并且更深层数的效率将更高,因为处理相同的信息量所需的可训练权重更少。

但迁移学习的运用不只限于无监督学习的结果。 回想一下,当您需要添加或删除一个神经层时,您曾多少次重新开始训练您的模型。 在这种情况下,有部分神经层其实可以重用。

这项技术还有另一个应用领域。 由于梯度衰落问题,近乎不可能完全训练深度模型。 运用迁移学习则允许模块化训练神经层,并逐渐增加模型的规模。

当然,这项技术还有许多其它可能的用途,您可自行探索。 现在,我们继续研究一款允许它所用它的工具。


2. 创建一款工具

我们先要决定我们将创建工具的用途。 首先,我们回到如何保存经过训练的模型。 所有这些都保存在一个二进制文件当中。 每个模型对象都有自己的严格数据记录结构。 因此,很难简单地从编辑器中的文件里删除部分数据。 故此,我们要先从文件中加载整个训练模型,执行必要的复原操作,并将新模型保存到新文件中,或覆盖以前的旧模型。 新文件更胜一筹,因为供体模型能够更深入地解决训练目标针对的问题。

此外,我们的神经网络只能与训练它们的数据一起配套操作。 对于全新的数据,结果则是不可预测的。 这也适用于单神经元层。 因此,对于迁移学习,我们只能采用连续神经层,从输入数据层开始。 您不能从模型的中间或末端抽取模块。 也就是说,我们可以使用整个供体模型或其最开头的若干层。 然后我们向其添加若干个不同的神经层,并保存新模型。

同时,我们需要确保新模型在训练模式和操作中的全部功能。 当然,必须首先训练模型。

以下几点需要注意。 来自供体模型的神经层会保留其权重。 它们还保留了在模型预训练阶段获得的所有知识。 新的神经层将接收随机权重,就像模型初始化阶段一样。 如果我们如同往常一样开始训练一个新模型,那么随着训练新神经层,我们先前训练过的神经层就会失衡。 因此,我们必须首先阻止供体模型神经层的训练。 通过这种方式,我们确保只需训练新神经层。


2.1 设计

我们不仅需要用到源供体模型的程序。 我们还需要以某种方式进行处理,并将其重新保存到新文件当中。 复制层的数量,以及模型体系结构始终是独立的。 因此,我们需要一个工具,允许用户快速便捷地单独配置每个模型。 即,我们的工具需要一个便捷的用户界面。 那么,我们就从 UI 设计开始吧。

如以,我看到了三个清晰的模块。 在第一个模块中,我们将操控供体模型。 在此,我们要能够选择含有已训练模型的文件。 从文件加载模型后,该工具必须提供已加载模型的体系结构的说明。 这是因为用户应当了解加载了哪个模型,以及将要复制哪些神经层。 我们还要通知工具有关复制图层的数量。 如上所述,我们将从源数据层开始按顺序复制神经层。

在第二个模块当中,将添加神经层。 在此,我们将创建字段,用于输入有关正在创建的神经层的信息。 与程序代码一样,我们将按顺序逐个定义每个神经层,并将其添加到新模型的架构之中。

第三个模块将显示所创建模型的整体架构,并能够指定一个文件来保存它。 下面呈现该工具的设计示例。

工具设计

该工具的设计及其实现,两者均仅用于演示目的。 您始终能修改它们来更好地满足您的需求。


2.2 实现用户界面 

现在我们就可以继续实现设计了。 为此,我们创建一个新类 CNetCreatorPanel,它应继承自 CAppDialog 对话框应用程序基类。

面板中的每个控件都将作为单独的对象创建。 因此,我们要在新的类中声明相当多的对象。 为方便起见,我们将它们划分为多个模块。

第一个模块包含的对象与预训练模型的可视化相关:

  • m_edPTModel — 指定预训练模型文件名的元素
  • m_edPTModelLayers — 显示预训练模型中神经层的总数
  • m_spPTModelLayers — 将复制到新模型的神经层数量
  • m_lstPTMode — display of the architecture of the pre-trained model
class CNetCreatorPanel : protected CAppDialog
  {
protected:
   //--- pre-trained model
   CEdit             m_edPTModel;
   CEdit             m_edPTModelLayers;
   CSpinEdit         m_spPTModelLayers;
   CListView         m_lstPTModel;
   CNetModify        m_Model;   
   CArrayObj*        m_arPTModelDescription;

此外,我们将在这里声明要与预训练模型配套使用的对象:

  • m_Model — 预训练模型的对象
  • m_arPTModelDescription — 一个动态数组,描述预训练模型的架构

注意以下两个时刻。 所有对象都声明为静态,但模型体系结构描述的动态数组除外。 使用静态对象可以将内存操作传输到系统。 这是因为静态对象是与包含它们的对象一起创建和删除的,不需要程序员任何额外的操作。 但这种方式,只能在我们类的结构中创建对象。 架构的描述将从预训练的模型中获取。 因此,此对象是通过动态指针声明的。

而第二个时刻。 是为了声明预训练的模型对象,我们采用的是 CNetModify 类。 但之前我们为神经网络模型创建了 CNet 类。 这是因为我们需要来自神经网络的附加功能。 为了实现它,我们将创建一个派生自 CNet 类的新类 CNetModify。 但是在描述工具功能时,我们将回到这一部分。

下一个模块包含的对象,用于描述正在创建的新神经层。 这些对象与描述神经层体系结构的 CLayerDescription 类的元素并排。 这就是为什么我们不会详细审查每个元素的原因。 但我想提一下创建两个按钮,它们是添加新的神经层和删除已创建的神经层。 只能删除已加入的神经层。 为了控制需复制的神经层数量,我们要用到前一个模块的元素。

   //--- add layers
   CComboBox         m_cbNewNeuronType;
   CEdit             m_edCount;
   CEdit             m_edWindow;
   CEdit             m_edWindowOut;
   CEdit             m_edStep;
   CEdit             m_edLayers;
   CEdit             m_edBatch;
   CEdit             m_edProbability;
   CComboBox         m_cbActivation;
   CComboBox         m_cbOptimization;
   CButton           m_btAddLayer;
   CButton           m_btDeleteLayer;

新模型的最后一个对象模块仅包含 3 个元素。 这些是用于显示模型一般体系结构的对象,用于保存新模型的按钮,以及描述我们正在添加的神经层体系结构的动态数组。 在这种情况下,我们创建了一个动态数组的静态对象,描述被加入 m_arAddLayers 的神经层架构。 神经层的架构将在工具内部创建。 该对象也可创建为静态对象。

   //--- new model
   CListView         m_lstNewModel;
   CButton           m_btSave;
   CArrayObj         m_arAddLayers;

我们将使用一个类的公开方法的基础清单。 其中包括类构造函数和析构函数、面板创建方法和事件处理程序。

父类的三个方法已被重写。 这本来可以通过公开继承来避免。

public:
                     CNetCreatorPanel();
                    ~CNetCreatorPanel();
   //--- main application dialog creation and destroy
   virtual bool      Create(const long chart, const string name, const int subwin, const int x1, const int y1);
   //--- chart event handler
   virtual bool      OnEvent(const int id, const long &lparam, const double &dparam, const string &sparam);
 
   virtual void      Destroy(const int reason = REASON_PROGRAM) override { CAppDialog::Destroy(reason); }
   bool              Run(void) { return CAppDialog::Run();}
   void              ChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
     {               CAppDialog::ChartEvent(id, lparam, dparam, sparam); }
  };

因为我们使用了静态对象,故我们类的构造函数和析构函数实际上是空的。

与界面元素的创建和排列相关操作的主要部分在对话框窗口创建方法 Create 中实现。 但在我们继续方法描述之前,我们还要做些准备工作。

首先,我们需要定义常量的数量,来帮助我们正确规划界面的内部空间。 附件中提供了完整清单。

还应该注意的是,除了输入元素之外,我们的界面还包含了一定数量的文本标签。 但是我们还没有为它们声明对象。 这样做是为了简化的我们类结构。 我们需要它们仅仅是为了可视化,因此在我们的工具里,它们并不参与创建的功能。 但是,我们需要创建这些对象。 创建这类对象的过程将会重复,除某些数据之外。 这也许包括对象文本及其位置。 为了构造我们的代码,我们将创建一个单独的 CreateLabel 方法来创建这类标签。

在方法参数中,我们将传递在面板上的对象标识符、标签文本及其坐标。

在方法主体中,我们首先创建一个新的标签对象,并检查操作结果。 然后我们在图表上创建一个对象,向其传递必要的内容,并将创建的对象指针添加到含有界面对象集合的动态数组之中。

我们在私密变量中创建了一个含有指针的新对象。 在方法执行操作期间,检查每个操作的结果,如果出现错误,则删除已创建的对象。 但是退出该方法后,我们不会保留指向在类中所创建对象的指针,以便将来当程序关闭时能删除它。 这是因为我们将指向所创建对象的指针传递给对话框对象集合,完整功能已在父类中实现。 此功能包括在程序关闭时删除集合内所有对象。 那么,现在我们就可以将指针传递给集合,并忘记它。

bool 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 false;
   if(!tmp_label.Create(m_chart_id, StringFormat("%s%d", LABEL_NAME, id), m_subwin, x1, y1, x2, y2))
     {
      delete tmp_label;
      return false;
     }
   if(!tmp_label.Text(text))
     {
      delete tmp_label;
      return false;
     }
   if(!Add(tmp_label))
     {
      delete tmp_label;
      return false;
     }
//---
   return true;
  }

类似地,我们将创建一个用于创建输入对象的方法。 但是,我们使用以前在类中创建的对象,替代创建新对象。 相关指针则在方法参数中传递。

bool CNetCreatorPanel::CreateEdit(const int id,
                                  CEdit& object,
                                  const int x1,
                                  const int y1,
                                  const int x2,
                                  const int y2,
                                  bool read_only)
  {
   if(!object.Create(m_chart_id, StringFormat("%s%d", EDIT_NAME, id), m_subwin, x1, y1, x2, y2))
      return false;
   if(!object.TextAlign(ALIGN_RIGHT))
      return false;
   if(!object.ReadOnly(read_only))
      return false;
   if(!Add(object))
      return false;
//---
   return true;
  }

此外,我们使用枚举和常量来描述所创建的神经层架构。 为了避免用户在这类元素中输入不正确的值,我们要创建特殊的控件。 用户只能从预定的列表中选择一个元素。 我们需要若干个这类元素。 我们首先创建一个元素来指示神经层的类型。 此功能将在 CreateComboBoxType 方法中实现。 由于此方法旨在创建特殊元素,因此我们不需要在参数中传递指向对象的指针。 在此,我们只需要指定正在创建的元素坐标。

在方法主体中,我们在图表上以指定坐标创建一个元素,并检查结果。

接下来,我们需要为元素填充文本描述和数字 ID 。 我们可用神经层的类型标识符作为 ID。 但是我们没有文字描述。 因此,要将数字标识符转换为文本描述,我们将创建一个单独的 LayerTypeToString 方法来完成这件事。 它的算法非常简单。 您可以在附件中查看它。 在此,我们仅针对每个神经层的类型调用此方法。

在方法末尾处,我们将对象指针添加到界面对象的集合当中。

请注意,我们将动态和静态对象两者都添加到集合之中。 这是因为集合功能更广泛,比程序完成后再删除对象的控制强得多。 与此同时,集合元素参与判定图表上对象的坐标,并处理事件。 指定集合的常规目的是将所有对象作为一个完整有机体运作。

bool CNetCreatorPanel::CreateComboBoxType(const int x1, const int y1, const int x2, const int y2)
  {
   if(!m_cbNewNeuronType.Create(m_chart_id, "cbNewNeuronType", m_subwin, x1, y1, x2, y2))
      return false;
   if(!m_cbNewNeuronType.ItemAdd(LayerTypeToString(defNeuronBaseOCL), defNeuronBaseOCL))
      return false;
   if(!m_cbNewNeuronType.ItemAdd(LayerTypeToString(defNeuronConvOCL), defNeuronConvOCL))
      return false;
   if(!m_cbNewNeuronType.ItemAdd(LayerTypeToString(defNeuronProofOCL), defNeuronProofOCL))
      return false;
   if(!m_cbNewNeuronType.ItemAdd(LayerTypeToString(defNeuronLSTMOCL), defNeuronLSTMOCL))
      return false;
   if(!m_cbNewNeuronType.ItemAdd(LayerTypeToString(defNeuronAttentionOCL), defNeuronAttentionOCL))
      return false;
   if(!m_cbNewNeuronType.ItemAdd(LayerTypeToString(defNeuronMHAttentionOCL), defNeuronMHAttentionOCL))
      return false;
   if(!m_cbNewNeuronType.ItemAdd(LayerTypeToString(defNeuronMLMHAttentionOCL), defNeuronMLMHAttentionOCL))
      return false;
   if(!m_cbNewNeuronType.ItemAdd(LayerTypeToString(defNeuronDropoutOCL), defNeuronDropoutOCL))
      return false;
   if(!m_cbNewNeuronType.ItemAdd(LayerTypeToString(defNeuronBatchNormOCL), defNeuronBatchNormOCL))
      return false;
   if(!m_cbNewNeuronType.ItemAdd(LayerTypeToString(defNeuronVAEOCL), defNeuronVAEOCL))
      return false;
   if(!Add(m_cbNewNeuronType))
      return false;
//---
   return true;
  }

类似地,为激活函数和参数优化方法的枚举创建对象。 为了将枚举转换为文本形式,我们将调用标准的 EnumToString 函数。 因此,我们就能在循环里将元素添加到列表之中。 附件中提供了这些方法的完整代码。

至此准备工作完毕,我们可以继续创建用户界面了。 此功能是在 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;

接下来,将界面对象添加到所创建面板之中。 对象将从左上角开始按顺序添加。 每个新对象的坐标将与前一个对象的坐标相接。 这种方式令我们能够把对象构建成均匀的结构。

根据上述逻辑,我们开始创建预训练模型工作组对象。 第一个就是组标签。 若要创建它,确定标签的坐标,并调用之前创建的 CreateLabel 方法。 标签文本和坐标一并传递给该方法。 不要忘记添加独有的标签 ID。

   int lx1 = INDENT_LEFT;
   int ly1 = INDENT_TOP;
   int lx2 = lx1 + LIST_WIDTH;
   int ly2 = ly1 + EDIT_HEIGHT;
   if(!CreateLabel(0, "PreTrained model", lx1, ly1, lx2, ly2))
      return false;

接着,我们创建一个输入字段,用来选择含有预训练模型的文件名称。 为此,垂直平移所创建对象的坐标,并保持水平坐标不变。 因此,2 个对象将严格位于彼此上下方。

用户将无法手动指定文件名。 代之,我们会提示用户从现有文件中选择一个。 稍后我们将回到此动作的功能。 至于现在,我们把文件名称字段设置为只读。 调用之前创建的 CreateEdit 方法创建对象。 创建字段后,向其添加信息消息。

   ly1 = ly2 + CONTROLS_GAP_Y;
   ly2 = ly1 + EDIT_HEIGHT;
   if(!CreateEdit(0, m_edPTModel, lx1, ly1, lx2, ly2, true))
      return false;
   if(!m_edPTModel.Text("Select file"))
      return false;

以下,我们将指定已训练模型的神经字段数量。 为此,需为神经层数创建一个文本标签和一个输入字段(在本例中为输出)。 此字段也是只读的。

   ly1 = ly2 + CONTROLS_GAP_Y;
   ly2 = ly1 + EDIT_HEIGHT;
   if(!CreateLabel(1, "Layers Total", lx1, ly1, lx1 + EDIT_WIDTH, ly2))
      return false;
//---
   if(!CreateEdit(1, m_edPTModelLayers, lx2 - EDIT_WIDTH, ly1, lx2, ly2, true))
      return false;
   if(!m_edPTModelLayers.Text("0"))
      return false;

类似地,为了输入要复制的神经层数,创建一个标签和字段。 我们需要在这里实现一种机制,限制用户选择神经层数。 它不得小于 0,或大于模型中的神经层总数。 这可利用 CSpinEdit 类对象的实例轻松完成。 该类允许我们指定有效值的范围。 其余的均已在类中实现。

   ly1 = ly2 + CONTROLS_GAP_Y;
   ly2 = ly1 + EDIT_HEIGHT;
   if(!CreateLabel(2, "Transfer Layers", lx1, ly1, lx1 + EDIT_WIDTH, ly2))
      return false;
//---
   if(!m_spPTModelLayers.Create(m_chart_id, "spPTMCopyLayers", m_subwin, lx2 - 100, ly1, lx2, ly2))
      return false;
   m_spPTModelLayers.MinValue(0);
   m_spPTModelLayers.MaxValue(0);
   m_spPTModelLayers.Value(0);
   if(!Add(m_spPTModelLayers))
      return false;

接下来,我们应该只显示一个包含预训练模型架构描述的窗口。 请注意,在此之前,我们总是将创建对象的坐标向下移动一级。 在这种情况下,我们只是从前一个对象的上边框移动到底部。 对象的下边框设置为从窗口高度开始的缩进。 因此,我们将对象拉伸到窗口的大小,并在创建的界面底部获得平滑的边缘。

   lx1 = INDENT_LEFT;
   lx2 = lx1 + LIST_WIDTH;
   ly1 = ly2 + CONTROLS_GAP_Y;
   ly2 = ClientAreaHeight() - INDENT_BOTTOM;
   if(!m_lstPTModel.Create(m_chart_id, "lstPTModel", m_subwin, lx1, ly1, lx2, ly2))
      return false;
   if(!m_lstPTModel.VScrolled(true))
      return false;
   if(!Add(m_lstPTModel))
      return false;

预训练模型模块的操作完成,并进入第二个描述所加神经层架构的对象模块。 模块对象也是从上至下创建的。 定义新对象的坐标时,我们平移水平坐标,并在距窗口顶部边缘缩进级别处定义上边框。

   lx1 = lx2 + CONTROLS_GAP_X;
   lx2 = lx1 + ADDS_WIDTH;
   ly1 = INDENT_TOP;
   ly2 = ly1 + EDIT_HEIGHT;
   if(!CreateLabel(3, "Add layer", lx1, ly1, lx2, ly2))
      return false;

以下,在缩进距离处,创建一个组合框,来选择要创建的神经层类型。 这是由以前创建的方法来完成的。 此对象的宽度将等于整个模块的宽度。

   ly1 = ly2 + CONTROLS_GAP_Y;
   ly2 = ly1 + EDIT_HEIGHT;
   if(!CreateComboBoxType(lx1, ly1, lx2, ly2))
      return false;

接下来是描述所创建神经层架构的元素。 对于 CLayerDescription 神经层架构描述类中的每个元素,我们将创建 2 个对象:一个带有元素名称的文本标签,和一个数值输入字段。 为了按严格的顺序在界面面板上定位元素,我们将文本标签左对齐,输入字段对齐模块的右边。 所有输入字段的大小将相同。 此方式将创建一种表格。

现在我不会为所有 9 个元素提供雷同的代码。 以下是在我们的表格里创建 2 行的代码示例。 完整代码可在附件中找到。

   ly1 = ly2 + CONTROLS_GAP_Y;
   ly2 = ly1 + EDIT_HEIGHT;
   if(!CreateLabel(4, "Neurons", lx1, ly1, lx1 + EDIT_WIDTH, ly2))
      return false;
//---
   if(!CreateEdit(2, m_edCount, lx2 - EDIT_WIDTH, ly1, lx2, ly2, false))
      return false;
   if(!m_edCount.Text((string)DEFAULT_NEURONS))
      return false;

   ly1 = ly2 + CONTROLS_GAP_Y;
   ly2 = ly1 + EDIT_HEIGHT;
   if(!CreateLabel(5, "Activation", lx1, ly1, lx1 + EDIT_WIDTH, ly2))
      return false;
//---
   if(!CreateComboBoxActivation(lx2 - EDIT_WIDTH, ly1, lx2, ly2))
      return false;

为所添加的神经层的架构创建描述元素后,我们还要加上 2 个按钮:用于添加和删除神经层。 将按钮排列成一行,把模块划分为两部分,它们的宽度各占一半。

   ly1 = ly2 + CONTROLS_GAP_Y;
   ly2 = ly1 + BUTTON_HEIGHT;
   if(!m_btAddLayer.Create(m_chart_id, "btAddLayer", m_subwin, lx1, ly1, lx1 + ADDS_WIDTH / 2, ly2))
      return false;
   if(!m_btAddLayer.Text("ADD LAYER"))
      return false;
   m_btAddLayer.Locking(false);
   if(!Add(m_btAddLayer))
      return false;
//---
   if(!m_btDeleteLayer.Create(m_chart_id, "btDeleteLayer", m_subwin, lx2 - ADDS_WIDTH / 2, ly1, lx2, ly2))
      return false;
   if(!m_btDeleteLayer.Text("DELETE"))
      return false;
   m_btDeleteLayer.Locking(false);
   if(!Add(m_btDeleteLayer))
      return false;

我们迈入第三个也是最后一个模块,描述正在创建的模型的完整体系结构。 在此,您可以找到上面所用的所有方法。

创建所有元素后,我们以 “true” 退出该方法。 下面的附件中提供了所有方法和类的完整代码。

我们的界面元素的排列至此完毕。 现在可将其添加到智能系统。 但在这种形式下,它将只是品种图表上的一幅美丽的图片。 接下来,我们需要在窗体中实现必要的功能。


2.3 实现工具功能

我们将继续致力于创建我们的工具,下一步是为界面提供必要的功能。 在继续之前,我们回到工具所期待的算法。

  1. 首先,我们需要打开包含保存已训练模型的文件。 为此,用户单击对象来选择文件。 这将打开一个对话框,用户在其中选择含有给定扩展名的现有文件。
  2. 用户选择文件后,该工具应从指定文件加载模型,并显示有关加载模型的信息(神经元层的类型和数量,每层中的神经元数量)。
  3. 连同有关默认加载模型的信息输出,其所有神经层都设置为复制到新模型。 有关它们的信息也会复制到所创建模型的描述模块当中。
  4. 用户应该能够手工更改需复制的神经层数量。 若需复制神经层数发生变化,必须同时对所创建模型的架构进行更改。 这将反映在描述所创建模型的体系结构的模块之中。
  5. 选择需复制神经层数后,用户可以手工指定新神经层的类型和架构,并通过按“添加层”按钮将其加到已创建的模型之中。
  6. 如果某个神经层被错误地添加到模型当中,用户可在描述模型架构的模块中选择这样的神经层,并通过按“删除”按钮将其删除。 请注意,只能删除后添加的神经层。 若要删除供体模型的神经层,您应使用该工具更改需复制的神经层数。
  7. 所创建的神经网络的架构创建完毕后,用户按下“保存模型”按钮。 这会打开一个对话框,用户应在其中选择现有文件,或指定新文件的名称。

在我看来,这些就是操控该工具的逻辑场景。 不过,还需一些努力来实现它。 首先,我们需要获取有关所保存模型信息的功能。 以前,我们不向用户提供有关所加载模型的信息。 为了实现该功能,我们需要针对神经网络类进行修改。 但由于此功能不会影响模型本身的操作,因此我们会将其添加到新的 CNetModify 类中,其是之前创建的 CNet 神经网络模型类的直接继承者。

我们不会在新类中创建任何新对象。 因此,类构造函数和析构函数将保持为空。 LayersTotal 方法返回模型中的神经层数。 它的算法并不复杂,因为它只是简单地返回数组的大小。 其完整代码可在附件中找到。

class CNetModify :  public CNet
  {
public:
                     CNetModify(void) {};
                    ~CNetModify(void) {};
   //---
   uint              LayersTotal(void);
   CArrayObj*        GetLayersDiscriptions(void);
  };

我们稍微介绍一下 GetLayersDiscriptions 方法,它能获取有关所用神经网络的信息。 那么执行此方法的结果,我们应收到一个含有神经网络架构描述的动态数组,类似于在模型构造函数方法的参数中传递的模型描述。 规划这个过程的复杂性,与我们之前没有创建获取神经层超参数的方法这一事实有关。 因此,我们需要在神经层类中添加相应的方法。 开始阶段,我们将 GetLayerInfo 方法添加到 CNeuronBaseOCL 神经层基类之中。

新方法不包含参数,且在执行后返回 CLayerDescription 神经层描述对象。 在方法主体中,我们将首先创建神经层描述对象的实例。 然后用当前神经层的超参数填充它。 之后,退出该方法,并将所创建对象的指针返回到调用程序。

CLayerDescription* CNeuronBaseOCL::GetLayerInfo(void)
  {
   CLayerDescription* result = new CLayerDescription();
   if(!result)
      return result;
//---
   result.type = Type();
   result.count = Output.Total();
   result.optimization = optimization;
   result.activation = activation;
   result.batch = (int)(optimization == LS ? iBatch : 1);
   result.layers = 1;
//---
   return result;
  }

通过向神经层基类添加一个方法,其所有衍生后代都拥有了该方法。 所以,所有的神经层都有这种方法。 现在我们可以从任何神经层获得类似的信息。 如果这些数据对您来说足够了,那么您就可以完成神经层的操作,并转入模型信息收集方法。

但是,如果您需要每个神经层的特定信息,则需要在所有神经层中覆盖此方法。 下面是在子采样层中覆盖方法的示例,它允许获取有关所分析窗口的大小,及其移动步长的数据。 在方法主体中,首先调用父类方法来获取基准超参数。 然后用特别参数补充得到的神经层描述对象。 之后退出该方法,并把指向神经层描述对象的指针返回给调用程序。

CLayerDescription* CNeuronProofOCL::GetLayerInfo(void)
  {
   CLayerDescription *result = CNeuronBaseOCL::GetLayerInfo();
   if(!result)
      return result;
   result.window = (int)iWindow;
   result.step = (int)iStep;
//---
   return result;
  }

前面讨论的所有神经层类型的类似方法都可在文后所附文件中找到。

现在我们可以获得有关每个神经层的超参数信息。 该信息可以组合成一个公用结构。 我们回到我们的 CNetModify::GetLayersDiscriptions 方法,并在其中创建一个动态数组,存储指向神经层描述对象的指针。

接着,我们将创建一个遍历所有神经层的循环。 在循环体中,我们调用上面创建的方法,从每个神经层请求一个架构描述对象。 获得的对象将被添加到动态数组之中。

在执行循环的所有迭代之后,我们将拥有一个动态数组,其中包含对全部已加载模型架构的描述。 方法完成后会将其返回给调用程序。

CArrayObj* CNetModify::GetLayersDiscriptions(void)
  {
   CArrayObj* result = new CArrayObj();
   for(uint i = 0; i < LayersTotal(); i++)
     {
      CLayer* layer = layers.At(i);
      if(!layer)
         break;
      CNeuronBaseOCL* neuron = layer.At(0);
      if(!neuron)
         break;
      if(!result.Add(neuron.GetLayerInfo()))
         break;
     }
//---
   return result;
  }

在此阶段,我们已经实现了获取之前已创建模型架构描述的可能性。 现在,我们可以转进到实现从用户指定文件加载已预训练模型的方法。 为了实现此功能,我们创建 CNetCreatorPanel::LoadModel 方法。 该方法将在参数中接收要加载模型的文件名称。

在方法主体中,我们首先从指定的文件加载模型。 注意,在调用模型的 Load 方法之前,我们不会检查参数的值。 这是因为所有控制都是在 load 方法中实现的。 我们只检查操作结果。 如果出现模型加载错误,则输出模型描述模块加载错误信息。

bool CNetCreatorPanel::LoadModel(string file_name)
  {
   float error, undefine, forecast;
   datetime time;
   ResetLastError();
   if(!m_Model.Load(file_name, error, undefine, forecast, time, false))
     {
      m_lstPTModel.ItemsClear();
      m_lstPTModel.ItemAdd("Error of load model", 0);
      m_lstPTModel.ItemAdd(file_name, 1);
      int err = GetLastError();
      if(err == 0)
         m_lstPTModel.ItemAdd("The file is damaged");
      else
         m_lstPTModel.ItemAdd(StringFormat("error id: %d", GetLastError()), 2);
      m_edPTModel.Text("Select file");
      return false;
     }

加载模型成功后,在界面的相应元素中显示加载文件名称和神经层数。

删除先前加载的模型描述(如果有)。 然后调用该方法收集有关已加载模型架构的信息。

   m_edPTModel.Text(file_name);
   m_edPTModelLayers.Text((string)m_Model.LayersTotal());
   if(!!m_arPTModelDescription)
      delete m_arPTModelDescription;
   m_arPTModelDescription = m_Model.GetLayersDiscriptions();

接收到有关加载模型的信息后,创建一个循环,在其主体中将接收到的信息输出到界面的相应模块之中。

   m_lstPTModel.ItemsClear();
   int total = m_arPTModelDescription.Total();
   for(int i = 0; i < total; i++)
     {
      CLayerDescription* temp = m_arPTModelDescription.At(i);
      if(!temp)
         return false;
      //---
      string item = StringFormat("%s (units %d)", LayerTypeToString(temp.type), temp.count);
      if(!m_lstPTModel.AddItem(item, i))
         return false;
     }

在方法末尾,把允许复制的神经层数范围更改为所加载模型的总大小。 指示工具复制整个加载的模型。 然后退出该方法。

   m_spPTModelLayers.MaxValue(total);
   m_spPTModelLayers.Value(total);
//---
   return true;
  }

如您所见,上述方法依据参数接收的文件名称,调用程序加载数据。 我们需要令用户能够选择模型文件。

我们创建另一个 OpenPreTrainedModel 方法。 在该方法的主体中,我们只调用标准的 FileSelectDialog 函数,其内已实现了文件对话框界面。 在调用函数时,指定所需的文件扩展名,和 FSD_FILE_MUST_EXIST 标志,其表示只能指定已存在文件。

对于某些标志,此功能允许选择多个文件。 因此,作为执行结果,FileSelectDialog 返回所选文件的数量。 而所选文件名称包含在数组当中,然后函数在参数中接收数组的指针。

故此,当用户选择了一个文件,其名称在参数中传递给上述方法。 否则,将生成一条消息,提示用户应选择加载数据的文件。

bool CNetCreatorPanel::OpenPreTrainedModel(void)
  {
   string filenames[];
   if(FileSelectDialog("Select a file to load data", NULL,
                       "Neuron Net (*.nnw)|*.nnw|All files (*.*)|*.*",
                       FSD_FILE_MUST_EXIST, filenames, NULL) > 0)
     {
      if(!LoadModel(filenames[0]))
         return false;
     }
   else
      m_edPTModel.Text("Files not selected");
//---
   return true;
  }

我们逐渐向前迈进,并且已创建了可视化的界面。 我们还创建了一个方法链条,用于选择文件和加载预训练的模型。 但到目前为止,这两个程序模块尚未结合成一个有机程序。 数据加载方法会在面板上显示有关所加载数据模型的信息。 但就目前而言,它还只是一条单行道。 我们需要指定返回的方式,即程序将接收有关用户操作,和用户对信息的反应信息。

为此,需用到事件处理程序。 在 CAppDialog 子类中,该机制以宏替换来实现。 为此目的,在程序代码中创建了一个宏替换模块,该宏替换模块从 EVENT_MAP_BEGIN 开始,至 EVENT_MAP_END 结束。 它们之间是众多与各种事件相对应的宏替换。 在我们的例子中,我们将采用 ON_EVENT 宏,这意味着可依据数字标识符处理事件。 为了处理鼠标在文件名对象上单击的事件,我们在宏替换主体中指定 ON_CLICK 事件、m_edPTModel 对象指针,以及发生 OpenPreTrainedModel 事件时要调用的方法名称。 因此,当按下的鼠标按钮对应于文件名输入框的 m_edPTModel 对象时,程序将调用 OpenPreTrainedModel 方法,从而启动预训练模型加载方法链。

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)
EVENT_MAP_END(CAppDialog)

我们能以类似方式描述它们调用的其它事件和方法:

  • OnClickAddButton — “加层”按钮单击事件
  • OnClickDeleteButton  — 删除按钮单击事件的方法
  • OnClickSaveButton  — “保存模型”按钮单击的方法
  • ChangeNumberOfLayers — 更改要复制的神经层数的事件方法
  • OnChangeListPTModel — 鼠标单击模型架构描述链接中的神经层的方法。

附件中提供了所有这些方法的完整代码。 我们来研究求解新模型的方法,因为它的实现相当复杂,需要在 CNetModify 神经网络模型类中创建额外方法。

该方法的算法可以有条件地分为 3 个模块:

  • 从预训练模型中复制神经层
  • 向模型添加新的神经层
  • 将模型保存到文件

当下,我们的神经网络类中只实现了最后一条。 我们没有从另一个模型复制神经层,或向现有模型添加新神经层的方法。

我们要一点点地进行。 首先,我们要创建一个复制神经层的机制。 我们知道,根据神经层的结构,它可以包含不同数量的对象。 然而,我们需要一种通用算法,允许依据不同的参数优化方法,来复制所有类型的神经层。 复制已训练模型不仅涉及迁移架构,还涉及所有权重。 现在的问题是:为什么我们必须复制每个神经层的所有元素? 为什么我们不能只复制指针所指的神经层对象? 依据指针,我们可从程序代码的不同部分访问相同的对象。 故此此,我们将使用此属性。 我们来创建两个方法。 其一,按模型结构中的编号,返回指向该神经层对象的指针。 其二,向模型架构中添加一个指针所指的神经层对象。

CLayer* CNetModify::GetLayer(uint layer)
  {
   if(!layers || LayersTotal() <= layer)
      return NULL;
//---
   return layers.At(layer);
  }

bool CNetModify::AddLayer(CLayer *new_layer)
  {
   if(!new_layer)
      return false;
   if(!layers)
     {
      layers = new CArrayLayer();
      if(!layers)
         return false;
     }
//---
   return layers.Add(new_layer);
  }

鉴于我们复制了连续的神经层模块,之后迁移指针所指的新模型,并保留顺序,如此我们保存了这些神经层之间的所有关系。

这只是第一点。 我们继续。 我们的模型构造函数可以根据架构描述创建一个新模型。 在向模型添加神经层时,我们创建了类似的神经层描述。 看似我们可以简单地添加新层,其模型已知如何去做。 但困难在于复制的神经层和新创建的神经层之间缺乏桥梁。

取决于我们神经层的结构,一个神经层的权重与其它神经层的元素直接相关。 因此,为了维持模型在前馈和后馈模式下均能运行,我们需要建立此连接。 如果您查看 CNeuronBaseOCL 神经层基类的初始化方法,您可在其参数中注意到后续神经层中的神经元数量。 此参数判定正在创建的权重矩阵的大小,以及参数优化中用到的关联缓冲区。

首先,我们在类中加入 CNeuronBaseOCL 方法,其会根据后续层 CNeuronBaseOCL::numOutputs 中指定的神经元数量调整权重矩阵

在方法的参数中,我们将传递后续层中的神经元数量和参数优化方法。

在方法主体中,我们检查参数中接收的后续神经层中的元素数量,并在必要时创建相应大小的权重矩阵。 以随机权重填充它,因为它对应的只是新添加的神经层。 对于已填充的矩阵,在 OpenCL 关联环境中创建一个缓冲区,并将矩阵内容传递到其中。

有必要将数据传递到 OpenCL 关联环境,因为我们的类方法尝试把数据保存到文件之前,需从关联环境加载数据。 若出错的情况下,它将中止保存模型,并显示负面结果。 当然,我们可以修改神经层类的方法。 但我认为这样的劳动力成本超过了将信息迁移到 OpenCL 关联环境并返回的成本。

bool CNeuronBaseOCL::numOutputs(const uint outputs, ENUM_OPTIMIZATION optimization_type)
  {
   if(outputs > 0)
     {
      if(CheckPointer(Weights) == POINTER_INVALID)
        {
         Weights = new CBufferFloat();
         if(CheckPointer(Weights) == POINTER_INVALID)
            return false;
        }
      Weights.BufferFree();
      Weights.Clear();
      int count = (int)((Output.Total() + 1) * outputs);
      if(!Weights.Reserve(count))
         return false;
      float k = (float)(1 / sqrt(Output.Total() + 1));
      for(int i = 0; i < count; i++)
        {
         if(!Weights.Add((2 * GenerateWeight()*k - k)*WeightsMultiplier))
            return false;
        }
      if(!Weights.BufferCreate(OpenCL))
         return false;

创建权重矩阵后,我们再来创建权重优化过程中要用到的数据缓冲区。

创建权重矩阵后,让我们创建权重优化过程中使用的数据缓冲区。 然后退出该方法。

该方法的完整代码可在文后的附件中找到。

现在,我们回到 CNetModify 类,根据给定的 AddLayers 描述创建一个添加神经层的方法。 在方法参数中,传递指向动态数组的指针,其中包含欲添加的神经层体系结构的描述。 在该方法主体中要立即检查接收到的数据。 所接收指针必须有效,且必须包含至少一个神经层的描述。

bool CNetModify::AddLayers(CArrayObj *new_layers)
  {
   if(!new_layers || new_layers.Total() <= 0)
      return false;
//---
   if(!layers || LayersTotal() <= 0)
     {
      Create(new_layers);
      return true;
     }

接下来,检查模型中存在的神经层数。 如果是空的,只需调用父类的构造函数。 它会依据给定体系结构创建一个新模型。

如果我们要在已有模型中添加神经层 ,那么我们首先声明局部变量。

   CLayerDescription *desc = NULL, *next = NULL;
   CLayer *temp;
   int outputs;

然后做一点准备工作,调用上面创建的方法连接两个神经层。

   int shift = (int)LayersTotal() - 1;
   CLayer* last_layer = layers.At(shift);
   if(!last_layer)
      return false;
//---
   CNeuronBaseOCL* neuron = last_layer.At(0);
   if(!neuron)
      return false;
//---
   desc = neuron.GetLayerInfo();
   next = new_layers.At(0);
   outputs = (next == NULL || (next.type != defNeuron && next.type != defNeuronBaseOCL) ? 0 : next.count);
   if(!neuron.numOutputs(outputs, next.optimization))
      return false;
   delete desc;

更进一步,与父类的构造函数相似,循环遍历模型体系结构描述的动态数组,并按顺序添加所有神经层。 此模块代码完全重复父类构造函数的代码。 因此,我不会在本文中重复。 下面的附件中提供了所有方法和类的完整代码。

我们回到该工具的 CNetCreatorPanel 类,并创建一个按下“模型保存”按钮的事件处理方法,其中会将上述创建新模型的方法合并到单个序列中。

在 OnClickSaveButton 方法的开头,我们将提示用户指定一个文件来保存模型。 为此,我们将调用已熟悉的 FileSelectDialog 函数。 这次我们将更改标志,来指示正在创建的文件是为了写入。 此外,指定默认文件名。

bool CNetCreatorPanel::OnClickSaveButton(void)
  {
   string filenames[];
   if(FileSelectDialog("Select files to save", NULL,
                       "Neuron Net (*.nnw)|*.nnw|All files (*.*)|*.*",
                       FSD_WRITE_FILE, filenames, "NewModel.nnw") <= 0)
     {
      Print("File not selected");
      return false;
     }

接着,创建神经网络类的新实例,并检查操作的结果。

   string file_name = filenames[0];
   if(StringLen(file_name) - StringLen(EXTENSION) > StringFind(file_name, EXTENSION))
      file_name += EXTENSION;
   CNetModify* new_model = new CNetModify();
   if(!new_model)
      return false;

成功创建新模型后,实现循环来复制所需数量的神经层。 针对所有已复制神经层,学习标志应切换为 false。 如此,我们在后续训练过程中禁用了为这些层更新权重的过程。 稍后,我们以编程方式挨个调用单个方法,为模型所有神经层更改此标志。

   int total = m_spPTModelLayers.Value();
   bool result = true;
   for(int i = 0; i < total && result; i++)
     {
      CLayer* temp = m_Model.GetLayer((uint)i);
      if(!temp)
        {
         result = false;
         break;
        }
      CNeuronBaseOCL* neuron = temp.At(0);
      neuron.TrainMode(false);
      if(!new_model.AddLayer(temp))
         result = false;
     }

完成复制神经层的迭代后,再调用上述方法添加神经层,即可完成新模型的创建。

   new_model.SetOpenCL(m_Model.GetOpenCL());
   if(result && m_arAddLayers.Total() > 0)
      if(!new_model.AddLayers(GetPointer(m_arAddLayers)))
         result = false;

之后,我们只需要保存所创建的模型。

   if(result && !new_model.Save(file_name, 1.0e37f, 100, 0, 0, false))
      result = false;
//---
   if(!!new_model)
      delete new_model;
   LoadModel(m_edPTModel.Text());
//---
   return result;
  }

保存模型后,我们可以将其删除,因为训练将在另一个程序中进行。

请注意,删除模型时,已复制的神经层也将被删除。 这是因为我们并未将数据复制到新模型当中,而只是传递其指针。 故此,如果您打算基于已用过的模型来创建另一个模型,则需要重新加载它。 为了避免不必要的例程,我们调用重新加载模型的方法。 并且只在那之后才退出该方法。

类的操控代码到此完毕。 进入下一个测试。


3. 测试

为了测试创建的工具,我们创建 NetCreator.mq5 智能系统。 EA 代码十分简单,仅包含上面创建的 CNetCreatorPanel 类的连接。 实际上,在 EA 中集成的类主要在 3 个点执行。 在 OnInit 函数中初始化和启动模型。 在 OnDeinit 函数中注销该类。 在 OnChartEvent 方法中将事件传递给该类。 下面给出了所有集成点的代码。

#include "NetCreatorPanel.mqh"
CNetCreatorPanel Panel;
//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   if(!Panel.Create(0, "NetCreator", 0, 50, 50))
      return INIT_FAILED;
   if(!Panel.Run())
      return INIT_FAILED;
//---
   return(INIT_SUCCEEDED);
  }

void OnDeinit(const int reason)
  {
//---
   Panel.Destroy(reason);
  }

void OnChartEvent(const int id,
                  const long &lparam,
                  const double &dparam,
                  const string &sparam)
  {
//---
   if(id == CHARTEVENT_OBJECT_CLICK)
      Sleep(0);
   Panel.ChartEvent(id, lparam, dparam, sparam);
  }

实际测试证实了我们的期望,神经层如愿从一个模型迁移到另一个模型,并可以添加新层。 此外,该工具允许您创建一个全新的模型。 因此,您可以在程序代码中迁移所创建模型的描述。  


结束语

在本文中,我们创建了一个工具,可以将部分神经层从一个模型迁移到另一个模型。 它还允许添加任意架构任意数量的新层。 我诚邀每个人都验证一下他们以前训练的模型,看看改变架构如何影响模型的生产力。

您可以尝试在一个模型中组合不同的架构,并执行一定数量的更改模型架构的实验。 与此同时,如果您保留结果层和源数据层的架构,那么您可以尝试将全新的模型架构“推置”到已有的智能系统当中。 然后训练模型,并比较架构的影响和模型的误差。


参考文献列表

  1. 神经网络变得轻松(第二十部分):自动编码器
  2. 神经网络变得轻松(第二十一部分):变分自动编码器(VAE)
  3. 神经网络变得轻松(第二十二部分):递归模型的无监督学习

本文中用到的程序

# 名称 类型 说明
1 NetCreator.mq5 EA   模型构建工具
2 NetCreatotPanel.mqh 类库 创建工具的类库
3 NeuroNet.mqh 类库 用于创建神经网络的类库
4 NeuroNet.cl 代码库 OpenCL 程序代码库


本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/11273

附加的文件 |
MQL5.zip (71.47 KB)
DoEasy. 控件 (第 14 部分): 命名图形元素的新算法。 继续操控 TabControl WinForms 对象 DoEasy. 控件 (第 14 部分): 命名图形元素的新算法。 继续操控 TabControl WinForms 对象
在本文中,我将创建一个新算法来为构建自定义图形的所有图形元素命名,并继续开发 TabControl WinForms 对象。
数据科学与机器学习 — 神经网络(第 01 部分):前馈神经网络解密 数据科学与机器学习 — 神经网络(第 01 部分):前馈神经网络解密
许多人喜欢它们,但却只有少数人理解神经网络背后的整个操作。 在本文中,我尝试用淳朴的语言来解释前馈多层感知,解密其封闭大门背后的一切。
群体优化算法 群体优化算法
这是一篇关于优化算法(OA)分类的介绍性文章。 本文尝试创建一个测试基台(一组函数),用于比较 OA,并可识别所有广为人知的算法中最通用的算法。
从头开始开发智能交易系统(第 26 部分):面向未来(I) 从头开始开发智能交易系统(第 26 部分):面向未来(I)
今天,我们将把我们的订单系统提升到一个新的层次。 但在此之前,我们需要解决少量问题。 我们现有的一些问题,是与在交易日里我们想要如何工作,以及我们做什么事情相关。