Создание модели для тестирования

В предыдущем разделе мы создали шаблон советника, чтобы протестировать возможность использования наших моделей нейронных сетей для совершения торговых операций на финансовых рынках. Это универсальный шаблон, который может работать с любыми моделями. В нем лишь ограничены параметры описания одной свечи и конфигурации слоя результатов. В результате работы модель должна вернуть тензор значений, который блок принятия решений в шаблоне может однозначно трактовать.

Для тестирования я решил собрать новую модель, в которой будет задействовано несколько типов нейронных слоев. Создавать и обучать модель будем с помощью скрипта. Формат таких скриптов нам уже знаком из многочисленных тестов, которые мы рассмотрели в рамках этой книги. Новый скрипт создадим в файле gpt_not_norm.mq5. Файл нового скрипта сохраним в подкаталоге gpt нашей книги в соответствии со структурой файлов.

В глобальной части скрипта объявим две константы:

  • BarsInHistory — количество баров в обучающей выборке;
  • ModelName — имя файла для сохранения обученной модели.

Обозначим внешние параметры скрипта. Прежде всего, это, конечно, название файла с обучающей выборкой StudyFileName. Хочу заметить, что здесь мы используем выборку без предварительной нормализации данных. В предыдущем разделе в нашем шаблоне советника мы не настраивали предварительную обработку данных, поэтому весь расчет на использование слоя пакетной нормализации. Тесты, которые мы провели ранее, подтверждают возможность такой замены.

Следующий внешний параметр OutputFileName содержит имя файла для записи динамики изменения ошибки модели в процессе обучения.

Мы планируем использовать блок с архитектурой GPT. Для такой архитектуры характерно использование параметра для указания длины накопительной внутренней последовательности паттерна. Для запроса данного параметра у пользователя создадим внешний параметр BarsToLine.

Далее идет ставший уже стандартным для подобных скриптов набор параметров:

  • NeuronsToBar — количество нейронов входного слоя на один бар;
  • UseOpenCL — флаг использования технологии OpenCL;
  • BatchSize — размер пакета между обновлениями матрицы весов;
  • LearningRate — коэффициент обучения;
  • HiddenLayers — количество скрытых слоев;
  • HiddenLayer — количество нейронов в скрытом слое;
  • Epochs — количество итераций обновления матрицы весов до прекращения процесса обучения.

#define HistoryBars           40
#define ModelName             "gpt_not_norm.net"
//+------------------------------------------------------------------+
//| Внешние параметры для работы скрипта                             |
//+------------------------------------------------------------------+
// Имя файла с обучающей выборкой
input string   StudyFileName  = "study_data_not_norm.csv";
// Имя файла для записи динамики ошибки
input string   OutputFileName = "loss_study_gpt_not_norm.csv";
// Глубина анализируемой истории

input int     BarsToLine     = 60;
// Количество нейронов входного слоя на 1 бар
input int      NeuronsToBar   = 4;
// Использовать OpenCL
input bool     UseOpenCL      = false;
// Размер пакета для обновления матрицы весов
input int      BatchSize      = 10000;
// Коэффициент обучения
input double   LearningRate   = 0.0003;
// Количество скрытых слоев
input int      HiddenLayers   = 2;
// Количество нейронов в одном скрытом слое
input int      HiddenLayer    = 60;
// Количество итераций обновления матрицы весов
input int      Epochs         = 5000;

После объявления внешних параметров добавляем в скрипт нашу библиотеку работы с моделями нейронных сетей.

//+------------------------------------------------------------------+
//| Подключаем библиотеку нейронной сети                             |
//+------------------------------------------------------------------+
#include "..\..\..\Include\NeuroNetworksBook\realization\neuronnet.mqh"

На этом работа в глобальной области заканчивается. Продолжим написание кода скрипта уже в теле функции OnStart. В теле функции мы используем структурированный подход вызова отдельных функций, каждая из которых выполняет определенные действия.

void OnStart(void)
  {
   VECTOR loss_history;
//--- подготовим вектор для хранения истории ошибок сети
   if(!loss_history.Resize(0Epochs))
     {
      Print("Not enough memory for loss history");
      return;
     }
   CNet net;
//--- 1. инициализация сети
   if(!NetworkInitialize(net))
      return;
//--- 2. загрузка данных обучающей выборки
   CArrayObj data;
   CArrayObj result;
   if(!LoadTrainingData(StudyFileNamedataresult))
      return;
//--- 3. обучение сети
   if(!NetworkFit(netdataresultloss_history))
      return;
//--- 4. сохранение истории ошибок сети
   SaveLossHistory(OutputFileNameloss_history);
   Print("Done");
  }

Первой в нашем скрипте идет функция инициализации модели NetworkInitialize. В параметрах данная функция получает указатель на объект модели, которую предстоит инициализировать.

В теле функции предусмотрено два варианта инициализации модели. Сначала мы пытаемся загрузить предварительно обученную модель из файла, указанного во внешних параметрах скрипта, и проверяем результат выполнения операции. Если модель успешно загружена, пропускаем блок создания новой модели и продолжаем работу с загруженной моделью. Благодаря этой возможности мы можем при необходимости остановить и возобновить процесс обучения.

bool NetworkInitialize(CNet &net)
  {
   if(net.Load(ModelName))
     {
      printf("Loaded pre-trained model %s"ModelName);
      net.SetLearningRates((TYPE)LearningRate,(TYPE)0.9, (TYPE)0.999);
      net.UseOpenCL(UseOpenCL);
      net.LossSmoothFactor(BatchSize);
      return true;
     }

Если же не удалось загрузить предварительно обученную модель, создаем новую нейронную сеть. Сначала мы создаем динамический массив для записи указателей на объекты описания нейронных слоев и тут же вызываем функцию создания описания архитектуры нашей модели CreateLayersDesc.

   CArrayObj layers;
//--- создаем описание слоев сети
   if(!CreateLayersDesc(layers))
      return false;

Когда в нашем динамическом массиве объектов собрано полное описание создаваемой модели, вызываем метод генерации модели с указанием в параметрах указателя на динамический массив описания модели, функции потерь и параметров оптимизации модели.

//--- инициализируем сеть
   if(!net.Create(&layers, (TYPE)LearningRate, (TYPE)0.9, (TYPE)0.999LOSS_MSE,
                                                                    0, (TYPE)0))
     {
      PrintFormat("Error of init Net: %d"GetLastError());
      return false;
     }

Обязательно проверяем результат выполнения операции.

После создания модели устанавливаем заданный пользователем флаг использования технологии OpenCL и диапазон сглаживания ошибки.

   net.UseOpenCL(UseOpenCL);
   net.LossSmoothFactor(BatchSize);
   return true;
  }

На этом мы заканчиваем функцию инициализации модели. Предлагаю сразу познакомиться с алгоритмом функции создания описания архитектуры модели CreateLayersDesc. В параметрах функция получает указатель на объект динамического массива описания архитектуры модели. В теле функции сразу очищаем полученный массив.

bool CreateLayersDesc(CArrayObj &layers)
  {
   layers.Clear();

Первым мы создаем слой исходных данных. Алгоритм для создания всех нейронных слоев будет один, поэтому сначала мы создаем новый экземпляр объекта описания нейронного слоя. Как всегда, проверяем результат операции по созданию нового объекта.

   CLayerDescription *descr;
//--- создаем слой исходных данных
   if(!(descr = new CLayerDescription()))
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      return false;
     }

После того, как объект описания нейронного слоя создан, заполняем его данными, достаточными для однозначного понимания архитектуры создаваемого нейронного слоя.

   descr.type         = defNeuronBase;
   int prev_count = descr.count = NeuronsToBar * GPT_InputBars;
   descr.window       = 0;
   descr.activation   = AF_NONE;
   descr.optimization = None;

На вход создаваемой модели мы будем подавать информацию всего о трех последних свечах. На самом деле этого действительно мало как с токи зрения объема информации для принятия решения нейронной сетью, так и с практической точки зрения трейдинга. Но не будем забывать, что мы будем использовать в своей модели блоки с архитектурой GPT. Данная архитектура предполагает накопление исторических данных внутри блока. Это компенсирует недостаток информации. В то же время использование малого объема исходных данных позволит значительно сократить объем вычислительных операций на каждой итерации. Таким образом, размер слоя исходных данных определяется как произведение количества элементов для описания одной свечи на количество анализируемых свечей. В нашем случае количество элементов описания одной свечи задается во внешнем параметре NeuronsToBar, а количество анализируемых свечей задается константой GPT_InputBars.

Слой исходных данных не использует ни функцию активации, ни оптимизацию параметров. Напомню, что исходные данные мы записываем напрямую в буфер результатов.

   if(!layers.Add(descr))
     {
      PrintFormat("Error adding layer: %d"GetLastError());
      delete descr;
      return false;
     }

После того, как мы заполнили объект описания архитектуры нейронного слоя необходимым набором достаточных данных, добавляем его в наш динамический массив указателей на объекты.

Напомню, что мы предварительно не обрабатывали исходные данные. Поэтому в архитектуре нейронной сети мы предусмотрели создание слоя пакетной нормализации данных сразу за слоем исходных данных. Согласно приведенному выше алгоритму, мы создаем новый экземпляр объекта описания нейронного слоя. Не забываем проверить результат проведения операции создания объекта, так как на следующем этапе мы будем заполнять элементы данного объекта необходимым описанием архитектуры создаваемого нейронного слоя, а обращение к элементам объекта по недействительному указателю вызовет критическую ошибку.

//--- создаем слой нормализации данных
   if(!(descr = new CLayerDescription()))
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      return false;
     }

В описании создаваемого нейронного слоя мы укажем тип нейронного слоя defNeuronBatchNorm, что соответствует слою пакетной нормализации. Размер нейронного слоя и окна исходных данных укажем равным размеру предыдущего нейронного слоя исходных данных.

Размер пакета укажем на уровне размера пакета между обновлениями матрицы весов, которые пользователь указал во внешнем параметре BatchSize.

Как и предыдущий слой, слой пакетной нормализации не использует функцию активации. Но уже появляется метод оптимизации обучаемых параметров Adam.

   descr.type         = defNeuronBatchNorm;
   descr.count        = prev_count;
   descr.window       = prev_count;
   descr.activation   = AF_NONE;
   descr.optimization = Adam;
   descr.batch        = BatchSize;

После указания всех необходимых параметров описания создаваемого нейронного слоя мы добавляем указатель на объект в динамический массив указателей на описание архитектуры нашей модели.

   if(!layers.Add(descr))
     {
      PrintFormat("Error adding layer: %d"GetLastError());
      delete descr;
      return false;
     }

Мы уже говорили, что четырех параметров в описании одной свечи довольно мало. Следовательно, было бы не плохо добавить еще несколько параметров. Для использования методов машинного обучения в условиях дефицита параметров разработан ряд подходов, которые были объединены в целое направление Feature Engineer. Одним из таких способов является использование сверточных слоев, в которых количество фильтров превышает размер исходного окна. Логика такого подхода заключается в том, что вектор описания одного элемента рассматривается как координаты некой точки текущего состояния в N-мерном пространстве, где N — длина вектора описания одного элемента. Осуществляя свертку, мы проецируем эту точку на вектор свертки. Именно этим свойством мы пользуемся при сжатии данных и понижении размерности данных. Именно этим свойством мы и воспользуемся для увеличения размерности данных. Как можно заметить, здесь нет противоречия с ранее изученным подходом к использованию сверточного слоя. Только теперь мы берем количество фильтров превышающее вектор описания одного элемента и тем самым увеличиваем размерность пространства. Воспользуемся описанным методом и сделаем следующий сверточный слой с числом фильтров в два раза больше числа элементов описания одной свечи. Надо сказать, что в данном случае мы делаем сверточный слой в рамках описания одной свечи, поэтому размер окна исходных данных и размер его шага будут равны размеру вектора описания одной свечи.

Алгоритм создания описания нейронного слоя остается прежним. Сначала мы создаем новый экземпляр объекта описания нейронного слоя и проверяем результат выполнения операции.

//--- Сверточный слой
   if(!(descr = new CLayerDescription()))
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      return false;
     }

Затем заполняем необходимые данные.

   descr.type = defNeuronConv;
   prev_count = descr.count = prev_count / NeuronsToBar;
   descr.window = NeuronsToBar;
   int prev_window = descr.window_out = 2 * NeuronsToBar;
   descr.step = NeuronsToBar;
   descr.activation = AF_SWISH;
   descr.optimization = Adam;
   descr.activation_params[0] = 1;

Предаем указатель на заполненный экземпляр объекта в динамический массив описания архитектуры создаваемой модели.

   if(!layers.Add(descr))
     {
      PrintFormat("Error adding layer: %d"GetLastError());
      delete descr;
      return false;
     }

После того, как поток информации пройдет через сверточный слой, мы ожидаем получить тензор с восемью элементами описания состояния одной свечи. Но мы помним, что полносвязные модели не оценивают зависимость между элементами. А при анализе временных рядов такая зависимость обычно довольно сильная.

Поэтому на следующем этапе мы попробуем проанализировать такие зависимости. Как вы помните, именно о таком анализе мы говорили при знакомстве со сверточными сетями. Каким бы странным это ни казалось, но для решения двух, на первый взгляд, разных задач мы используем один тип нейронных слоев. На самом деле, мы выполняем схожую задачу, но с разными данными. В предыдущем сверточном слое мы раскладывали вектор описания одной свечи на большее количество элементов. Можно посмотреть на эту задачу и с другой стороны. Как мы говорили при изучении сверточного слоя, процесс свертки — это определение схожести двух функций. То есть в каждом фильтре мы выявляем схожесть исходных данных с некой эталонной функцией. Каждый фильтр использует свою эталонную функцию. Осуществляя операции свертки в размере одного бара, мы искали схожесть каждого бара с неким эталонным.

Теперь же мы хотим проанализировать динамику изменения параметров свечей. Для этого нам надо провести свертку между одинаковыми элементами векторов описания разных свечей. После проведения свертки предыдущий слой нам вернул последовательно по три значения (количество анализируемых свечей) от каждого фильтра. Значит, следующим шагом мы создаем сверточный слой с окном исходных данных и его шагом равным количеству анализируемых свечей. В данном сверточном слое мы тоже будем использовать восемь фильтров.

Создадим описание сверточного нейронного слоя согласно приведенному ранее алгоритму.

//--- Сверточный слой 2
   if(!(descr = new CLayerDescription()))
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      return false;
     }

   descr.type = defNeuronConv;
   descr.window = prev_count;
   descr.step = prev_count;
   prev_count = descr.count = prev_window;
   prev_window = descr.window_out = 8;
   descr.activation = AF_SWISH;
   descr.optimization = Adam;
   descr.activation_params[0] = 1;

   if(!layers.Add(descr))
     {
      PrintFormat("Error adding layer: %d"GetLastError());
      delete descr;
      return false;
     }

Таким образом, после предварительной обработки данных в одном слое пакетной нормализации и двух последовательных сверточных слоях мы получили тензор из 64 элементов (8 * 8). Напомню, что на вход нейронной сети мы подали тензор из 12 элементов: 3 свечи по 4 элемента на каждую свечу.

Дальше сигнал будем обрабатывать в блоке с архитектурой GPT. В нем мы создадим четыре последовательных нейронных слоя с восемью головами внимания в каждом. Размер глубины анализируемых данных мы вывели во внешние параметры скрипта, что позволит провести обучение с различной глубиной и выбрать оптимальный параметр по соотношению затрат на обучение к качеству работы модели. Алгоритм создания описания нейронного слоя остается прежним.

//--- GPT-слой
   if(!(descr = new CLayerDescription()))
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      return false;
     }

   descr.type = defNeuronGPT;
   descr.count = BarsToLine;
   descr.window = prev_count * prev_window;
   descr.window_out = prev_window;
   descr.step = 8;
   descr.layers = 4;
   descr.activation = AF_NONE;
   descr.optimization = Adam;
   descr.activation_params[0] = 1;

   if(!layers.Add(descr))
     {
      PrintFormat("Error adding layer: %d"GetLastError());
      delete descr;
      return false;
     }

За блоком GPT создадим блок полносвязных нейронных слоев. Все слои в блоке будут идентичными. Количество слоев и нейронов в каждом мы вынесли во внешние параметры скрипта. Согласно предложенному выше алгоритму, мы создаем новый экземпляр объекта описания нейронного слоя и проверяем результат выполнения операции.

//--- Скрытые полносвязные слои
   if(!(descr = new CLayerDescription()))
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      return false;
     }

После успешного создания нового экземпляра объекта заполним его необходимыми данными. Как уже было сказано выше, количество нейронов в слое мы возьмем из внешнего параметра HiddenLayer. Функцию активации я выбрал Swish. Конечно, для большей гибкости скрипта в процессе обучения больше параметров можно вынести во внешние и провести больше циклов обучения модели с различными параметрами. Такой подход потребует больше времени и затрат на обучение модели, но позволит найти наиболее оптимальные значения для параметров модели.

   descr.type = defNeuronBase;
   descr.count = HiddenLayer;
   descr.activation = AF_SWISH;
   descr.optimization = Adam;
   descr.activation_params[0] = 1;

Так как у нас планируется создание идентичных нейронных слоев, далее мы создаем цикл с количеством итераций равным количеству создаваемых нейронных слоев. В теле цикла будем добавлять созданное описание нейронного слоя в динамический массив описания архитектуры создаваемой модели. И, конечно, проверяем результат выполнения операций на каждой итерации цикла.

   for(int i = 0i < HiddenLayersi++)
     {
      if(!layers.Add(descr))
        {
         PrintFormat("Error adding layer: %d"GetLastError());
         delete descr;
         return false;
        }
     }

В завершение модели создадим слой результатов. Это полносвязный слой, который содержит два нейрона с функцией активации tanh. Выбор в пользу данной функции активации был сделан по совокупности оценки целевых значений обучаемой модели:

  • Первый элемент целевого значения принимает 1 для целей на покупку и −1 для целей на продажу, что наилучшим образом настраивается функцией гиперболического тангенса tanh;
  • Обучение моделей мы осуществляли на паре EURUSD, следовательно, значение ожидаемого движения до ближайшего экстремума должно быть в диапазоне от −0.05 до 0.05. В данном диапазоне значений график функции гиперболического тангенса tanh близок к линейному.

Если же вы планируете использование модели на инструментах с абсолютным значением ожидаемого движения до ближайшего экстремума более 1, можно применить масштабирование целевого результата. Затем использовать обратное масштабирование при трактовке сигнала модели. Также можно подумать об использовании другой функции активации.

Для создания описания нейронного слоя в архитектуре создаваемой модели мы используем тот же алгоритм. Сначала создаем новый экземпляр объекта описания нейронного слоя и проверяем результат выполнения операции.

//---  Слой результатов
   if(!(descr = new CLayerDescription()))
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      return false;
     }

Затем наполняем созданный объект необходимой информацией: тип нейронного слоя, количество нейронов, функция активации и метод оптимизации параметров модели.

   descr.type         = defNeuronBase;
   descr.count        = 2;
   descr.activation   = AF_TANH;
   descr.optimization = Adam;

Указатель на заполненный объект добавляем в динамический массив описания архитектуры создаваемой модели. При этом не забываем проверить результат выполнения операции.

   if(!layers.Add(descr))
     {
      PrintFormat("Error adding layer: %d"GetLastError());
      delete descr;
      return false;
     }
   return true;
  }

Завершаем работу функции.

Следующей в алгоритме скрипта идет функция загрузки обучающей выборки LoadTrainingData. В параметрах функция получает строковую переменную с названием файла для загрузки и указатели на два объекта динамических массивов: паттернов data и целевых значений result.

bool LoadTrainingData(string pathCArrayObj &dataCArrayObj &result)
  {

Напомню, что загружать мы будем обучающую выборку без предварительной нормализации исходных данных из файла study_data_not_norm.csv, так как мы планируем использовать модель в режиме реального времени, а для подготовки исходных данных будем использовать слой пакетной нормализации.

Алгоритм загрузки исходных данных будет полностью повторять тот, что мы рассмотрели для выполнения этой же задачи ранее, в скрипте тестирования архитектуры GPT. Но повторение — мать учения. Поэтому кратко освежим его в памяти. Для загрузки обучающей выборки мы объявляем две новые переменные для хранения указателей на объекты буферов данных, в которые мы и будем непосредственно считывать из файла по одному паттерну и его целевому значению (pattern и target, соответственно). Сами же экземпляры объектов мы будем создавать позже. Это связано с тем, что нам потребуются новые экземпляры объектов для загрузки каждого паттерна. Поэтому создавать объекты будем уже в теле цикла перед непосредственным процессом загрузки данных из файла.

   CBufferType *pattern;
   CBufferType *target;

После завершения подготовительной работы открываем файл с обучающей выборкой для чтения данных. При открытии файла среди прочих флагов указываем FILE_SHARE_READ — этот флаг открывает общий доступ к файлу для чтения данных. То есть, добавляя данный флаг, мы не блокируем доступ к файлу из других приложений для чтения файла. Это позволит запустить параллельно несколько скриптов с разными параметрами, и они не будут блокировать доступ к файлу друг другу. Конечно, запускать параллельно несколько скриптов можно только в том случае, если это позволяют мощности оборудования.

//--- открываем файл с обучающей выборкой
   int handle = FileOpen(pathFILE_READ | FILE_CSV | FILE_ANSI | FILE_SHARE_READ,
                                                                    ","CP_UTF8);
   if(handle == INVALID_HANDLE)
     {
      PrintFormat("Error opening study data file: %d"GetLastError());
      return false;
     }

Конечно, проверяем результат выполнения операции. В случае ошибки открытия файла информируем об ошибке пользователя, удаляем все ранее созданные объекты и выходим из программы.

После успешного открытия файла создаем цикл непосредственного чтения данных из файла. Операции в теле цикла будут повторяться до наступления одного из следующих событий:

  • достижение конца файла;
  • прерывание выполнения программы пользователем.

//--- выводим прогресс загрузки данных обучения в комментарий чарта
   uint next_comment_time = 0;
   uint OutputTimeout     = 250; // не чаще 1 раза в 250 миллисекунд
//--- организовываем цикл загрузки обучающей выборки
   while(!FileIsEnding(handle) && !IsStopped())
     {
      if(!(pattern = new CBufferType()))
        {
         PrintFormat("Error creating Pattern data array: %d"GetLastError());
         return false;
        }
      if(!pattern.BufferInit(1NeuronsToBar * GPT_InputBars))
         return false;

В теле цикла первым делом создаем новые экземпляры объектов для записи текущего паттерна и его целевых значений. И, конечно, сразу проверяем результат выполнения операции. В случае возникновения ошибки информируем пользователя об ошибке, удаляем ранее созданные объекты, закрываем файл и выходим из программы. Очень важно удалить все объекты и закрыть файл перед выходом из программы.

      if(!(target = new CBufferType()))
        {
         PrintFormat("Error creating Pattern Target array: %d"GetLastError());
         return false;
        }
      if(!target.BufferInit(12))
         return false;

После этого организуем вложенный цикл с количеством итераций равным полному паттерну данных. Мы создавали обучающие выборки из 40 свечей на паттерн и, соответственно, должны последовательно считать все данные. В то же время для обучения нашей модели не требуется столь большое описание паттерна. Поэтому организуем пропуск лишних данных — в буфер будем записывать только последние необходимые данные.

      int skip = (HistoryBars - GPT_InputBars) * NeuronsToBar;
      for(int i = 0i < NeuronsToBar * HistoryBarsi++)
        {
         TYPE temp = (TYPE)FileReadNumber(handle);
         if(i < skip)
            continue;
         pattern.m_mMatrix[0i - skip] = temp;
        }

После полной загрузки данных текущего паттерна организуем аналогичный цикл для загрузки целевых значений. На этот раз число итераций цикла будет равно числу целевых значений в обучающей выборке, то есть в нашем случае двум. Перед запуском цикла проверяем состояние флага сохранения паттерна. В цикл заходим только в том случае, если описание паттерна было загружено в полном объеме.

      for(int i = 0i < 2i++)
         target.m_mMatrix[0i] = (TYPE)FileReadNumber(handle);

После отработки циклов загрузки данных переходим к блоку добавления информации о паттерне в наши динамические массивы. Добавим указатели на объекты в динамический массив описания паттернов и целевых результатов. Не забываем проверять результат выполнения операций.

      if(!data.Add(pattern))
        {
         PrintFormat("Error adding study data to array: %d"GetLastError());
         return false;
        }
      if(!result.Add(target))
        {
         PrintFormat("Error adding study data to array: %d"GetLastError());
         return false;
        }

После успешного добавления в динамические массивы проинформируем пользователя о количестве загруженных паттернов и перейдем к загрузке следующего паттерна.

      //--- выводим прогресс загрузки в комментарий чарта (не чаще 1 раза в 250 миллисекунд)
      if(next_comment_time < GetTickCount())
        {
         Comment(StringFormat("Patterns loaded: %d"data.Total()));
         next_comment_time = GetTickCount() + OutputTimeout;
        }
     }
   FileClose(handle);
   Comment(StringFormat("Patterns loaded: %d"data.Total()));
   return(true);
  }

После успешной загрузки всех данных обучающей выборки закрываем файл и завершаем функцию загрузки данных.

Далее переходим к процедуре обучения нашей модели в функции NetworkFit. В параметрах функция получает указатели на три объекта:

  • обучаемая модель,
  • динамический массив описаний состояния системы,
  • динамический массив целевых результатов.

bool NetworkFit(CNet &netconst CArrayObj &dataconst CArrayObj &resultVECTOR &loss_history)
  {

В теле метода вначале выполним небольшую подготовительную работу. Подготовим локальные переменные.

   int patterns = data.Total();
   int count = -1;
   TYPE min_loss = FLT_MAX;

После завершения подготовительной работы организуем вложенные циклы обучения нашей модели. Внешний цикл будет отсчитывать количество обновлений матриц весовых коэффициентов, а во вложенном мы будем перебирать паттерны нашей обучающей выборки.

Напомню, в архитектуре GPT исторические данные накапливаются в стеке. Поэтому для корректной работы модели очень важна историческая последовательность подачи данных на вход модели, аналогично рекуррентным моделям. По этой причине мы не можем перемешивать обучающую выборку в рамках одного пакета обучения и будем подавать на вход модели паттерны в хронологическом порядке. В то же время для обучения модели модно использовать случайные пакеты из общей совокупности обучающей выборки. Стоит отметить, что при определении пакета для обучения нужно увеличить его размер на величину внутренней накопительной последовательности блока GPT, так как для корректного определения зависимостей между элементами необходимо соблюдение их хронологической последовательности.

Таким образом, перед запуском вложенного цикла мы определяем границы текущего пакета для обучения.  

В теле вложенного цикла перед дальнейшем продолжением операций проверяем флаг принудительного завершения работы программы и при необходимости прерываем работу функции.

//--- цикл по эпохам
   for(int epoch = 0epoch < Epochsepoch++)
     {
      ulong ticks = GetTickCount64();
      //--- обучаем батчами
      //--- выбор случайного паттерна
      int k = (int)((double)(MathRand() * MathRand()) / MathPow(32767.02) * (patterns - BarsToLine - 1));
      k = fmax(k0);
      for(int i = 0; (i < (BatchSize + BarsToLine) && (k + i) < patterns); i++)
        {
         //--- проверим на остановку обучения
         if(IsStopped())
           {
            Print("Network fitting stopped by user");
            return true;
           }

Сначала осуществляем прямой проход модели, вызвав метод net.FeedForward. В параметрах метода прямого прохода мы передаем указатель на объект описания текущего состояния паттерна и проверяем результат выполнения операции. В случае возникновения ошибки выполнения метода информируем пользователя об ошибке, удаляем созданные объекты и выходим из программы.

         if(!net.FeedForward(data.At(k + i)))
           {
            PrintFormat("Error in FeedForward: %d"GetLastError());
            return false;
           }

После успешного выполнения метода прямого прохода проверяем заполненность буфера нашего блока GPT. Если буфер еще не заполнен, переходим к следующей итерации цикла.

         if(i < BarsToLine)
            continue;

Метод обратного прохода net.Backpropagation вызываем только после заполнения накопительной последовательности блока GPT. На этот раз в параметрах метода мы передаем указатель на объект целевых значений. Обязательно проверяем результат операции — если возникла ошибка, выполняем операции как при ошибке в прямом методе.

         if(!net.Backpropagation(result.At(k + i)))
           {
            PrintFormat("Error in Backpropagation: %d"GetLastError());
            return false;
           }
        }

Методами прямого и обратного проходов мы выполнили соответствующие алгоритмы процесса обучения нашей модели. На данном этапе градиент ошибки уже распространен до каждого обучаемого параметра. Осталось лишь обновить матрицы весовых коэффициентов. Но данную операцию мы выполняем не на каждой итерации обучения, а только после набора пакета. В нашем случае обновлять матрицы весовых коэффициентов будем после завершения итераций вложенного цикла.

      //--- перенастраиваем веса сети
      net.UpdateWeights(BatchSize);
      printf("Use OpenCL %s, epoch %d, time %.5f sec", (string)UseOpenCLepoch, (GetTickCount64() - ticks) / 1000.0);

Как вы видели на графиках тестирования моделей, динамика ошибки модели практически никогда не идет плавной линией. Сохранение модели с минимальной ошибкой позволит нам сохранить наиболее подходящие параметры для нашей модели. Поэтому мы сначала проверяем текущую ошибку модели и сравниваем ее с минимальной достигнутой в процессе обучения. Если ошибка снизилась, сохраняем текущую модель и обновляем переменную минимальной ошибки.

      //--- сообщим о прошедшей эпохе
      TYPE loss = net.GetRecentAverageLoss();
      Comment(StringFormat("Epoch %d, error %.5f"epochloss));
      //--- запомним ошибку эпохи для сохранения в файл
      loss_history[epoch] = loss;
      if(loss < min_loss)
         //--- сохраняем модель с минимальной ошибкой
         if(net.Save(ModelName))
           {
            min_loss = loss;
            count = -1;
           }

Дополнительно мы ввели счетчик count. В нем мы будем отсчитывать количество итераций обновления от последнего минимального значения ошибки. И если его значение превысит указанный порог (в примере указано 10 итераций), то мы прерываем процесс обучения.

      if(count >= 10)
         break;
      count++;
     }
   return true;
  }

После завершения полного цикла обучения нам предстоит сохранить в файл накопленную динамику изменения ошибки модели в процессе обучения. Для этого мы создали функцию SaveLossHistory. В параметрах функция получает строковую переменную с именем файла для записи данных и вектор ошибок в процессе обучения модели.

В теле функции мы открываем файл для записи. При этом используем имя файла, которое пользователь указал в параметрах. Сразу проверяем результат. Если произошла ошибка при открытии файла, информируем пользователя и выходим из функции.

void SaveLossHistory(string pathconst VECTOR &loss_history)
  {
   int handle = FileOpen(OutputFileNameFILE_WRITE | FILE_CSV | FILE_ANSI,
                                                             ","CP_UTF8);
   if(handle == INVALID_HANDLE)
     {
      PrintFormat("Error creating loss file: %d"GetLastError());
      return;
     }
   for(ulong i = 0i < loss_history.Size(); i++)
      FileWrite(handleloss_history[i]);
   FileClose(handle);
   PrintFormat("The dynamics of the error change is saved to a file %s\\MQL5\\Files\\%s",
                                 TerminalInfoString(TERMINAL_DATA_PATH), OutputFileName);
  }

Если файл открылся успешно, организуем цикл, в котором поочередно запишем все значения вектора накопления ошибки модели при обучении. После завершения полного цикла записи данных закрываем файл и информируем пользователя о месте нахождения файла.

На этом наш скрипт создания и обучения модели завершен, и мы можем начать обучение модели на ранее созданной выборке не нормированных обучающих данных из файла study_data_not_norm.csv.

Следующим шагом будет запуск процесса обучения модели. Здесь надо запастись терпением, ведь процесс обучения довольно длительный. Его длительность зависит от используемого оборудования. К примеру, я запустил обучение модели с параметрами, приведенными на скриншоте ниже.

На моем ноутбуке с Intel Core i7-1165G7 вычисление одного пакета между обновлениями матриц весовых коэффициентов составляет 35–36 секунд. То есть полное обучение модели с 5000 итераций обновления матриц коэффициентов займет около 2 суток непрерывной работы. В то же время, если вы видите что обучение остановилось и минимальная ошибка долгое время не изменяется, вы можете в ручном режиме прервать обучение модели. Если достигнутые показатели не удовлетворяют требования, можно продолжить обучение модели с другими значениями коэффициента обучения и размера пакета для обновления матрицы весов. Общий подход в подборе параметров следующий:

  • Коэффициент обучения — обучение начинается с большего коэффициента, и в процессе обучения мы постепенно снижаем коэффициент обучения;
  • Размера пакета обновления матрицы весов — обучение начинается с малого пакета и постепенно увеличивается.
Параметры обучения модели

Параметры обучения модели

Указанные приемы позволяют на начальном этапе проводить максимально быстрое грубое обучение модели с последующей более тонкой настройкой. При этом, если в процессе обучения модели ошибка постоянно растет, то это свидетельствует о завышенном коэффициенте обучения. Использование большого пакета обновления матрицы весов помогает изменять матрицы весов в наиболее приоритетном направлении, но при этом требует больше времени на проведение операций между обновлениями весовых коэффициентов. В то же время малый пакет ведет к более быстрому и более хаотичному обновлению параметров модели, при этом общая тенденция сохраняется. Но при использовании малых размеров пакета рекомендуется уменьшать и коэффициент обучения, чтобы снизить переобучение модели под конкретные участки обучающей выборки.