Сравнительное тестирование реализаций

Мы завершили работу еще над одним классом нейронного слоя с использованием механизмов внимания CNeuronGPT. В данном классе мы попытались воссоздать модель GPT (Generative Pre-trained Transformer), предложенную командой OpenAI в 2018 году. Данная модель была разработана для решения языковых задач, но позже продемонстрировала довольно высокие результаты и для решения других задач. Третье поколение данной модели (GPT-3) на момент написания книги является самой продвинутой языковой моделью.

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

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

Скрипт для данного тестирования мы запишем в файл gpt_test.mq5. В качестве шаблона возьмем один из скриптов тестирования предыдущих моделей внимания — attention_test.mq5. В начале скрипта создадим константу указания размера паттерна в файле обучающей выборки и внешние параметры настройки скрипта.

#define GPT_InputBars         5
#define HistoryBars           40
//+------------------------------------------------------------------+
//| Внешние параметры для работы скрипта                             |
//+------------------------------------------------------------------+
// Имя файла с обучающей выборкой
input string   StudyFileName = "study_data.csv";
// Имя файла для записи динамики ошибки
input string   OutputFileName = "loss_study_gpt.csv";
// Количество исторических баров в одном паттерне
input int      BarsToLine     = 40;             
// Количество нейронов входного слоя на 1 бар
input int      NeuronsToBar   = 4;              
// Использовать OpenCL
input bool     UseOpenCL      = false;          
// Размер пакета для обновления матрицы весов
input int      BatchSize      = 10000;          
// Коэффициент обучения
input double   LearningRate   = 0.00003;         
// Количество скрытых слоев
input int      HiddenLayers   =  3;             
// Количество нейронов в одном скрытом слое
input int      HiddenLayer    =  40;            
// Количество циклов обновления матрицы весов
input int      Epochs         =  1000;          

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

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

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

На этом мы завершаем создание глобальных переменных и переходим к работе непосредственно над скриптом.

В теле скрипта нам необходимо внести изменения в две функции. Первые изменения мы внесем в функцию описания архитектуры модели CreateLayersDesc. Как уже было сказано выше, на вход модели мы будем подавать информацию только о пяти последних свечах. Значит, мы уменьшаем размер слоя исходных данных до 20 нейронов. Но мы сделаем гибкую архитектуру скрипта и укажем размер слоя исходных данных как произведение внешнего параметра количества нейронов на описание одной свечи NeuronsToBar и константы количества свечей для загрузки GPT_InputBars.

bool CreateLayersDesc(CArrayObj &layers)
  {
   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;
   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());
      delete descr;
      return false;
     }

Вторым слоем мы создадим блок GPT. Об этом модели подскажет константа defNeuronGPT в поле типа создаваемого нейронного слоя type.

В поле count мы укажем размера стека для хранения информации о паттерне. Его значение определит размер буферов для тензоров Key и Value, а также повлияет на размер вектора коэффициентов зависимости Score.

Размер окна исходных данных мы установим на уровне количества элементов в предыдущем слое, которое мы предусмотрительно сохранили в локальную переменную.

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

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

   descr.type = defNeuronGPT;
   descr.count = BarsToLine;
   descr.window = prev_count;
   descr.window_out = NeuronsToBar// Размер вектора Key
   descr.step = 8;                  // Голов внимания
   descr.layers = 4;
   descr.activation = AF_NONE;
   descr.optimization = Adam;

Кроме того, при тестировании архитектуры Multi-Head Self-Attention мы создавали четыре одинаковых нейронных слоя. Сейчас же для создания подобной архитектуры нам достаточно создать одно описания нейронного слоя и указать в параметре layers количество идентичных нейронных слоев.

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

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

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

Следующий блок, в который внесем изменения, — функция загрузка обучающей выборки LoadTrainingData.

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

bool LoadTrainingData(string pathCArrayObj &dataCArrayObj &result)
  {
   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;
     }

Проверяем полученный в результате операции хендл файла.

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

//--- выводим прогресс загрузки данных обучения в комментарий чарта
   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 свечей паттерна и за ними 2 элемента целевых значений. Чтение данных будем осуществлять в той же последовательности. Сначала организуем цикл по чтению вектора описания паттерна. Считывать из файла будем по одному значению в локальную переменную, при этом будем проверять позицию загруженного элемента. Сохранять в буфер данных будем только те элементы, которые попадают в размер нашего окна анализа.

      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);

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

      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);
   return(true);
  }

После завершения всех итераций цикла два динамических массивах (data и result) будут содержать всю информацию об обучающей выборке. Можем закрыть файл, а вместе с тем и завершить блок загрузки данных. Завершаем работу функции.

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

bool NetworkFit(CNet &netconst CArrayObj &dataconst CArrayObj &target,
                                                     VECTOR &loss_history)
  {
//--- обучение
   int patterns = data.Total();
//--- цикл по эпохам
   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;
           }
         if(!net.FeedForward(data.At(k + i)))
           {
            PrintFormat("Error in FeedForward: %d"GetLastError());
            return false;
           }
         if(i < BarsToLine)
            continue;
         if(!net.Backpropagation(target.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;
     }
   return true;
  }

Не забываем контролировать процесс выполнения операций.

Дальнейший код скрипта перенесен без изменений.

Теперь запустим скрипт и сравним результаты с полученными ранее при тестировании предыдущих моделей.

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

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

Тестирование модели GPT

Тестирование модели GPT

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

Здесь надо добавить, что при тестировании мы обучали нашу модель «с чистого листа». Авторы архитектуры предлагают проводить предварительное обучение блока GPT без учителя на большом объеме данных и только предварительно обученную модель подстраивать под решение конкретных задач в процессе обучения с учителем.

Тестирование модели GPT

Тестирование модели GPT

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

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

Тестирование модели GPT 4 слоя

Тестирование модели GPT 4 слоя

Тестирование модели GPT 4 слоя

Тестирование модели GPT 4 слоя

Добавим, что из практики использования моделей внимания наиболее четко проявляются их преимущества при использовании длительных последовательностей. И GPT не исключение. Скорее даже наоборот. Так как модель пересчитывает только текущее состояние и использует архивные копии предыдущих состояний, это позволяет значительно сократить количество операций при анализе больших последовательностей. А со снижением количества итераций растет и скорость работы всей модели.

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

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

Тестирование модели GPT с увеличенным стеком

Тестирование модели GPT с увеличенным стеком

Тестирование модели GPT с увеличенным стеком

Тестирование модели GPT с увеличенным стеком

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