Сравнительное тестирование рекуррентных моделей

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

Как и при тестировании предыдущих моделей, начнем мы с проверки корректности распределения градиента через наш рекуррентный слой, построенный средствами MQL5. Для этого мы построим скрипт check_gradient_lstm.mq5. Скрипт мы будем строить на базе ранее построенных аналогичных скриптов для проверки корректности работы предыдущих моделей. В принципе мы сделаем копию скрипта check_gradient_conv.mq5 из раздела тестирования сверточных моделей и внесем в него изменения, соответствующие новой модели.

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

//--- рекуррентный слой
   if(!(descr = new CLayerDescription()))
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      delete layers;
      return false;
     }
   descr.type = defNeuronLSTM;
   descr.count = BarsToLine;
   descr.window_out = 2;
   descr.activation = AF_NONE;
   descr.optimization = Adam;
   descr.activation_params[0] = 1;
   if(!layers.Add(descr))
     {
      PrintFormat("Error adding layer: %d"GetLastError());
      delete layers;
      delete descr;
      return false;
     }

В остальном блок построения нейронной сети остается без изменения.

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

Второй особенностью рекуррентного слоя является использование своих результатов в качестве исходных данных для новой итерации. Я согласен, что именно для этого все и затевалось. Мы хотели, чтобы нейронная сеть учитывала не только текущее состояние внешней среды, но и ее предыдущие состояния, которые мы передаем в качестве скрытого состояния на новую итерацию. Это дает положительный эффект для работы нейронной сети, но искажает данные для тестирования корректности распределения градиента ошибки. Дело в том, что весь наш алгоритм проверки корректности распределения градиента ошибки построен на принципе изменения только одного проверяемого параметра при прочих постоянных значениях состояния внешней среды. Но в случае с рекуррентным слоем даже при постоянстве всех параметров исходных данных мы можем получить другой результат за счет изменения скрытого состояния. Чтобы исключить это влияние, нам временно нужно добавить очистку буферов памяти и скрытого состояния в метод прямого прохода нашего класса рекуррентного LSTM-блока CNeuronLSTM::FeedForward. Я специально выделил эти строки в коде заливкой.

bool CNeuronLSTM::FeedForward(CNeuronBase *prevLayer)
  {
//--- Проверяем актуальность всех объектов
    ....
//--- Подготавливаем заготовки для новых буферов памяти и скрытого состояния
   CBufferDouble *memory = CreateBuffer(m_cMemorys);
   if(!memory)
      return false;
   CBufferDouble *hidden = CreateBuffer(m_cHiddenStates);
   if(!hidden)
     {
      delete memory;
      return false;
     }
//--- Только для проверки градиента
   memory.BufferInit(m_cOutputs.Total(), 0);
   hidden.BufferInit(m_cOutputs.Total(), 0);
//--- Далее следует код метода без изменений

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

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

Тест корректности распределения градиента ошибки через LSTM блок

Тест корректности распределения градиента ошибки через LSTM блок

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

 

Скрипт для тестирования рекуррентных моделей

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

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

//+------------------------------------------------------------------+
//| Внешние параметры для работы скрипта                             |
//+------------------------------------------------------------------+
// Имя файла с обучающей выборкой
input string   StudyFileName = "study_data.csv";
// Имя файла для записи динамики ошибки
input string   OutputFileName = "loss_study_lstm.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;

В функции описания архитектуры модели CreateLayersDesc между слоем исходных данных и блоком скрытых слоев вставим один LSTM-блок. Размер буфера результатов данного рекуррентного блока будет равен количеству анализируемых нейронных слоев. Глубину анализируемой истории установим на пять итераций. Архитектурой LSTM-блока уже определены функции активации всех его составляющих, и сам блок не имеет верхнеуровневой функции активации. Соответственно, в описании архитектуры блока мы укажем отсутствие функции активации. Метод оптимизации параметров будем использовать Adam.

//--- рекуррентный слой
   if(!(descr = new CLayerDescription()))
     {
      PrintFormat("Error creating CLayerDescription: %d"GetLastError());
      return false;
     }
   descr.type = defNeuronLSTM;
   descr.count = BarsToLine;
   descr.window_out = 5;
   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;
     }

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

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

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

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 - 10));
      k = fmax(k0);

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

      for(int i = 0; (i < (BatchSize + 10) && (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 < 10)
            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;
  }

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

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

 

Первое тестирование LSTM-модели

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

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

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

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

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

Тестирование модели рекуррентной нейронной сети

Тестирование модели рекуррентной нейронной сети

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

Тестирование модели рекуррентной нейронной сети

Тестирование модели рекуррентной нейронной сети

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

 

Второе тестирование LSTM-модели

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

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

Тестирование модели рекуррентной нейронной сети

Тестирование модели рекуррентной нейронной сети

Тестирование модели рекуррентной нейронной сети

Тестирование модели рекуррентной нейронной сети

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

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

 

Результаты тестирования рекуррентных моделей в Python

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

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

Результаты тестового обучения моделей Python

Результаты тестового обучения моделей Python

Результаты тестового обучения моделей Python

Результаты тестового обучения моделей Python

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

График динамики обучения по метрике Accuracy полностью подтверждают наши выводы, сделанные по графику ошибки.

На тестовой выборке все обученные модели показали довольно близкие результаты. Отклонение как по среднеквадратичной ошибке, так и по метрике accuracy минимально.

Тестирование обученных моделей Python на тестовой выборке

Тестирование обученных моделей Python на тестовой выборке

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

Тестирование обученных моделей Python на тестовой выборке

Тестирование обученных моделей Python на тестовой выборке

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

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