preview
Нейросети — это просто (Часть 71): Прогнозирование будущих состояний с учетом поставленных целей (GCPC)

Нейросети — это просто (Часть 71): Прогнозирование будущих состояний с учетом поставленных целей (GCPC)

MetaTrader 5Торговые системы | 6 января 2024, 09:58
1 354 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Введение

Имитационное обучение (Behavior Cloning — BC), направленное на достижение цели, является многообещающим подходом для решения различных задач обучения с подкреплением в офлайн режиме. Вместо оценки ценности состояния и действий, BC напрямую обучает политику поведения Агента, выстраивая зависимости между поставленной целью, анализируемым состоянием окружающей среды и действием Агента. Это достигается с помощью методов обучения с учителем на предварительно собранных офлайн траекториях. Знакомый нам метод Decision Transformer и производные от него алгоритмы продемонстрировали эффективность моделирования последовательностей для обучения с подкреплением в офлайн режиме.

Ранее, при использовании вышеуказанных алгоритмов, мы экспериментировали с различными вариантами постановки целей для стимулирования необходимых нам действий Агента. Изучение моделью уже пройденной траектории всегда оставалось вне нашего внимания. И здесь можно задаться вопросом о целесообразности изучения траектории в целом. Подобным вопросом задались и авторы статьи «Goal-Conditioned Predictive Coding for Offline Reinforcement Learning». В своей работе они исследуют несколько ключевых вопросов:

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

  2. Какие будут наиболее эффективные цели обучения представлению траектории для поддержки обучения политики? Следует ли моделям последовательностей учить кодировать опыт истории, будущую динамику или и то, и другое?

  3. Поскольку одна и та же модель последовательности может быть использована как для обучения представлению траектории, так и для обучения политики, следует ли иметь одни и те же цели обучения или нет?

В статье приведены результаты экспериментов в 3 искусственных средах, которые позволяют авторам сделать следующие выводы:

  • моделирование последовательности, если оно правильно разработано, может эффективно помогать принятию решений, когда полученное представление траектории используется как вход для обучения политики;

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

Эти наблюдения подталкивают авторов статьи к созданию двухэтапной структуры, которая сжимает информацию о траектории в компактные сжатые представления с использованием предварительного обучения моделирования последовательности. Сжатое представление затем используется для обучения политики поведения Агента с использованием модели на основе простого многослойного персептрона (MLP). Предложенный ими метод Goal-Conditioned Predictive Coding (GCPC) является наиболее эффективной целью обучения представлению траектории. Он обеспечивает конкурентоспособную производительность во всех проведенных авторами тестовых задачах. Особенно отмечается его эффективность для решения задач с длительным горизонтом планирования. Сильная эмпирическая производительность GCPC приобретается благодаря латентному представлению пройденных и прогнозируемых состояний. При этом прогнозирование состояний осуществляется с ориентацией на поставленные цели, что обеспечивают решающее руководство для принятия решений.

1. Алгоритм Goal-Conditioned Predictive Coding

Авторы метода GCPC в своей работе используют моделирование последовательностей для обучения с подкреплением в офлайн режиме. Для решения проблемы офлайн обучения с подкреплением используется условное, фильтрованное или взвешенное имитационное обучения. Предполагается, наличие предварительно собранного набора обучающих данных. Но политики, используемые для сбора данных, могут быть неизвестны. Обучающие данные содержат набор траекторий. Каждая траектория представлена как набор состояний и действий (St, At). Траектория может опционально содержать вознаграждение Rt, полученное на временном шаге t.

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

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

  • когда используется только текущее наблюдаемое состояние St и цель G, политика Агента игнорирует историю наблюдений; 
  • когда политика Агента — это модель последовательности, она может использовать всю наблюдаемую траекторию для прогнозирования следующего действия At.

Для оптимизации политики поведения Агента обычно используется целевая функция максимального правдоподобия.

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

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

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

Во втором этапе TrajNet применяется к немаскированной наблюдаемой траектории , чтобы получить сжатое представление траектории B. Затем PolicyNet прогнозирует действие A при заданных пройденной траектории (или текущем состоянии окружающей среды), цели G и сжатом представлении траектории B.

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

Авторская визуализация метода представлена ниже.

2. Реализация средствами MQL5

После рассмотрения теоретических аспектов метода Goal-Conditioned Predictive Coding мы переходим к его реализации средствами MQL5. И здесь в первую очередь следует обратить внимание на различное количество моделей, используемых на разных этапах обучения и эксплуатации модели.

2.1 Архитектура моделей

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

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

bool CreateTrajNetDescriptions(CArrayObj *encoder, CArrayObj *decoder)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }
   if(!decoder)
     {
      decoder = new CArrayObj();
      if(!decoder)
         return false;
     }

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

//--- Encoder
   encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

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

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

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = 1000;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

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

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronDropoutOCL;
   descr.count = prev_count;
   descr.probability = 0.8f;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

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

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

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = HistoryBars;
   descr.window = BarDescr;
   descr.step = BarDescr;
   int prev_wout = descr.window_out = BarDescr / 2;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = prev_count;
   descr.step = prev_wout;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count;
   descr.window = prev_wout;
   descr.step = prev_wout;
   prev_wout = descr.window_out = 8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = prev_count;
   descr.step = prev_wout;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

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

//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = EmbeddingSize;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Помимо исторических данных авторы метода GCPC предлагают подавать на вход Энкодера эмбединг цели и Slot tokens (результаты предыдущих проходов Энкодеров). Наша глобальная цель получение максимально возможного дохода не оказывает влияния на состояния окружающей среды и мы опускаем её. А вот результаты последнего прохода нашего Энкодера мы добавим в модель с помощью слоя конкатенации.

//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = 2 * EmbeddingSize;
   descr.window = prev_count;
   descr.step = EmbeddingSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

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

//--- layer 10
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronEmbeddingOCL;
   prev_count = descr.count = GPTBars;
     {
      int temp[] = {EmbeddingSize, EmbeddingSize};
      ArrayCopy(descr.windows, temp);
     }
   prev_wout = descr.window_out = EmbeddingSize;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

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

//--- layer 11
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMLMHAttentionOCL;
   descr.count = prev_count * 2;
   descr.window = prev_wout;
   descr.step = 4;
   descr.window_out = 16;
   descr.layers = 4;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

На выходе Энкодера мы понижаем размерность данных полносвязным слоем и нормализуем данные функцией SoftMax.

//--- layer 12
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = EmbeddingSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 13
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = prev_count;
   descr.step = 1;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Сжатое представление пройденной траектории мы будем подавать на вход Декодера.

//--- Decoder
   decoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = EmbeddingSize;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

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

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = (HistoryBars + PrecoderBars) * EmbeddingSize;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

После чего обрабатываем в слое внимания.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMLMHAttentionOCL;
   prev_count = descr.count = prev_count / EmbeddingSize;
   prev_wout = descr.window = EmbeddingSize;
   descr.step = 4;
   descr.window_out = 16;
   descr.layers = 2;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

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

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

И сразу нужно вспомнить какую информацию о ценовом движении мы сохраняем в буфере воспроизведения опыта. Это 3 отклонения:

  • тело свечи (close - open)
  • от цены открытия до максимума (high - open)
  • от цены открытия до минимума (low - open).

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

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMultiModels;
   descr.count = 3;
   descr.window = prev_wout;
   descr.step = prev_count;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

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

Сразу надо сказать, что вопреки ожиданиям на втором этапе мы будем обучать не 1 модель политики поведения Актера, а сразу 3 модели.

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

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

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

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

bool CreateDescriptions(CArrayObj *actor, CArrayObj *goal, CArrayObj *encoder)
  {
//---
   CLayerDescription *descr;
//---
   if(!actor)
     {
      actor = new CArrayObj();
      if(!actor)
         return false;
     }
   if(!goal)
     {
      goal = new CArrayObj();
      if(!goal)
         return false;
     }
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }

Как уже было сказано выше, на вход Энкодера мы подаем сжатое представление траектории.

//--- State Encoder
   encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = EmbeddingSize;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Полученные данные объединяются с информацией о состоянии счета в слое конкатенации.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = AccountDescr;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

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

//--- Actor
   actor.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = LatentCount;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Полученные данные объединяем с поставленной целью.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = NRewards;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

И обрабатываем полносвязными слоями.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NActions;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

На выходе Актера добавляем стохастичность политике его поведения.

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

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

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

//--- Goal
   goal.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = EmbeddingSize;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!goal.Add(descr))
     {
      delete descr;
      return false;
     }

Полученные данные анализируются 2 полносвязными слоями.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!goal.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!goal.Add(descr))
     {

      delete descr;
      return false;
     }

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

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFQF;
   descr.count = NRewards;
   descr.window_out = 32;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!goal.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

2.2 Модель взаимодействия со средой

Продолжаем нашу работу по реализации метода Goal-Conditioned Predictive Coding. И после описания архитектуры моделей мы переходим к непосредственной реализации алгоритмов. И первым мы реализуем советник взаимодействия с окружающей средой и сбора данных для обучающей выборки. Авторы метода не акцентировали внимания на методе сбора обучающих данных. На самом деле, обучающая выборка может быть собраны любым доступным способом, включая рассмотренные нами ранее алгоритмы ExORL и Real-ORL. Необходимо лишь соответствие форматов записи и представления данных. Но для оптимизации предварительно обученных моделей нам необходим советник, который бы в процессе взаимодействия со средой использовал выученную нами политику поведения и сохранял результаты взаимодействия в траекторию. Такой функционал мы реализуем в советнике «...\Experts\GCPC\Research.mq5». Базовые принципы построения алгоритма советника заимствованы из предыдущих работ. Однако количество используемых моделей накладывает свой отпечаток. И мы точечно остановимся на некоторых методах советника.

В данном советнике мы будем использовать 4 модели.

CNet                 Encoder;
CNet                 StateEncoder;
CNet                 Actor;
CNet                 Goal;

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

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

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
........
........
//--- load models
   float temp;
   if(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true))
     {
      CArrayObj *encoder = new CArrayObj();
      CArrayObj *decoder = new CArrayObj();
      if(!CreateTrajNetDescriptions(encoder, decoder))
        {
         delete encoder;
         delete decoder;
         return INIT_FAILED;
        }
      if(!Encoder.Create(encoder))
        {
         delete encoder;
         delete decoder;
         return INIT_FAILED;
        }
      delete encoder;
      delete decoder;
      //---
     }

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

   if(!StateEncoder.Load(FileName + "StEnc.nnw", temp, temp, temp, dtStudied, true) ||
      !Goal.Load(FileName + "Goal.nnw", temp, temp, temp, dtStudied, true) ||
      !Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true))
     {
      CArrayObj *actor = new CArrayObj();
      CArrayObj *goal = new CArrayObj();
      CArrayObj *encoder = new CArrayObj();
      if(!CreateDescriptions(actor, goal, encoder))
        {
         delete actor;
         delete goal;
         delete encoder;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor) || !StateEncoder.Create(encoder) || !Goal.Create(goal))
        {
         delete actor;
         delete goal;
         delete encoder;
         return INIT_FAILED;
        }
      delete actor;
      delete goal;
      delete encoder;
      //---
     }

Все модели переносим в единый контекст OpenCL.

   StateEncoder.SetOpenCL(Actor.GetOpenCL());
   Encoder.SetOpenCL(Actor.GetOpenCL());
   Goal.SetOpenCL(Actor.GetOpenCL());

И в обязательном порядке отключаем режим обучения модели Энкодера.

   Encoder.TrainMode(false);

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

И проверим соответствие архитектур загруженных моделей.

   Actor.getResults(Result);
   if(Result.Total() != NActions)
     {
      PrintFormat("The scope of the actor does not match the actions count (%d <> %d)", NActions, Result.Total());
      return INIT_FAILED;
     }
   Encoder.getResults(Result);
   if(Result.Total() != EmbeddingSize)
     {
      PrintFormat("The scope of the Encoder does not match the embedding size (%d <> %d)", EmbeddingSize, 
                                                                                                  Result.Total());
      return INIT_FAILED;
     }
//---
   Encoder.GetLayerOutput(0, Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Input size of Encoder doesn't match state description (%d <> %d)", Result.Total(),
                                                                                        (HistoryBars * BarDescr));
      return INIT_FAILED;
     }
//---
   PrevBalance = AccountInfoDouble(ACCOUNT_BALANCE);
   PrevEquity = AccountInfoDouble(ACCOUNT_EQUITY);
//---
   return(INIT_SUCCEEDED);
  }

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

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
//---
........
........
//---
   if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CNet*)GetPointer(Encoder)) ||

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

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

      !StateEncoder.feedForward((CNet *)GetPointer(Encoder), -1, (CBufferFloat *)GetPointer(bAccount)) ||
      !Goal.feedForward((CNet *)GetPointer(Encoder), -1, (CBufferFloat *)NULL) ||

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

      !Actor.feedForward((CNet *)GetPointer(StateEncoder), -1, (CNet *)GetPointer(Goal)))
      return;

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

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

2.3 Обучение функции траектории

После сбора данных обучающей выборки мы переходим к построению советников обучения моделей. И согласно алгоритму GCPC первым этапом является обучение модели функции траектории TrajNet. Данный функционал мы реализуем в советнике «...\Experts\GCPC\StudyEncoder.mq5».

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

//+------------------------------------------------------------------+
//| Input parameters                                                 |
//+------------------------------------------------------------------+
input int                  Iterations     = 1e4;
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
STrajectory          Buffer[];
CNet                 Encoder;
CNet                 Decoder;

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

CBufferFloat         LastEncoder;

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

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   ResetLastError();
   if(!LoadTotalBase())
     {
      PrintFormat("Error of load study data: %d", GetLastError());
      return INIT_FAILED;
     }

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

//--- load models
   float temp;
   if(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true) ||
      !Decoder.Load(FileName + "Dec.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Init new models");
      CArrayObj *encoder = new CArrayObj();
      CArrayObj *decoder = new CArrayObj();
      if(!CreateTrajNetDescriptions(encoder, decoder))
        {
         delete encoder;
         delete decoder;
         return INIT_FAILED;
        }
      if(!Encoder.Create(encoder) || !Decoder.Create(decoder))
        {
         delete encoder;
         delete decoder;
         return INIT_FAILED;
        }
      delete encoder;
      delete decoder;
      //---
     }

 Обе модели мы располагаем в едином контексте OpenCL.

   OpenCL = Encoder.GetOpenCL();
   Decoder.SetOpenCL(OpenCL);

И проверяем соответствие архитектур моделей.

   Encoder.getResults(Result);
   if(Result.Total() != EmbeddingSize)
     {
      PrintFormat("The scope of the Encoder does not match the embedding size count (%d <> %d)", EmbeddingSize,
                                                                                                 Result.Total());
      return INIT_FAILED;
     }
//---
   Encoder.GetLayerOutput(0, Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Input size of Encoder doesn't match state description (%d <> %d)", Result.Total(),
                                                                                       (HistoryBars * BarDescr));
      return INIT_FAILED;
     }
//---
   Decoder.GetLayerOutput(0, Result);
   if(Result.Total() != EmbeddingSize)
     {
      PrintFormat("Input size of Decoder doesn't match Encoder output (%d <> %d)", Result.Total(), EmbeddingSize);
      return INIT_FAILED;
     }

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

   if(!LastEncoder.BufferInit(EmbeddingSize,0) ||
      !Gradient.BufferInit(EmbeddingSize,0) ||
      !LastEncoder.BufferCreate(OpenCL) ||
      !Gradient.BufferCreate(OpenCL))
     {
      PrintFormat("Error of create buffers: %d", GetLastError());
      return INIT_FAILED;
     }

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

   if(!EventChartCustom(ChartID(), 1, 0, 0, "Init"))
     {
      PrintFormat("Error of create study event: %d", GetLastError());
      return INIT_FAILED;
     }
//---
   return(INIT_SUCCEEDED);
  }

Непосредственно сам процесс обучения осуществляется в методе Train. В нем мы уже традиционно комбинируем алгоритм Goal-Conditioned Predictive Coding с нашими наработками из прошлых статей. В начале метода мы создаем вектор вероятностей использования траекторий для обучения моделей.

//+------------------------------------------------------------------+
//| Train function                                                   |
//+------------------------------------------------------------------+
void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);

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

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

   vector<float> result, target;
   matrix<float> targets;
   STD = vector<float>::Zeros((HistoryBars + PrecoderBars) * 3);
   int std_count = 0;
   uint ticks = GetTickCount();

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

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

   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
      int tr = SampleTrajectory(probability);
      int batch = GPTBars + 50;
      int state = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 3 - PrecoderBars - batch));
      if(state <= 0)
        {
         iter--;
         continue;
        }

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

      Encoder.Clear();
      Decoder.Clear();
      LastEncoder.BufferInit(EmbeddingSize,0);

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

      int end = MathMin(state + batch, Buffer[tr].Total - PrecoderBars);
      for(int i = state; i < end; i++)
        {
         State.AssignArray(Buffer[tr].States[i].state);

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

         if(!LastEncoder.BufferWrite() || !Encoder.feedForward((CBufferFloat*)GetPointer(State), 1, false, 
                                                               (CBufferFloat*)GetPointer(LastEncoder)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

А затем Декодера.

         if(!Decoder.feedForward(GetPointer(Encoder), -1, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

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

         target.Assign(Buffer[tr].States[i].state);
         ulong size = target.Size();
         targets = matrix<float>::Zeros(1, size);
         targets.Row(target, 0);
         if(size > BarDescr)
            targets.Reshape(size / BarDescr, BarDescr);
         ulong shift = targets.Rows();
         targets.Resize(shift + PrecoderBars, 3);
         for(int t = 0; t < PrecoderBars; t++)
           {
            target.Assign(Buffer[tr].States[i + t].state);
            if(size > BarDescr)
              {
               matrix<float> temp(1, size);
               temp.Row(target, 0);
               temp.Reshape(size / BarDescr, BarDescr);
               temp.Resize(size / BarDescr, 3);
               target = temp.Row(temp.Rows() - 1);
              }
            targets.Row(target, shift + t);
           }
         targets.Reshape(1, targets.Rows()*targets.Cols());
         target = targets.Row(0);

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

         Decoder.getResults(result);
         vector<float> error = target - result;
         std_count = MathMin(std_count, 999);
         STD = MathSqrt((MathPow(STD, 2) * std_count + MathPow(error, 2)) / (std_count + 1));
         std_count++;

Здесь надо отметить, что мы контролируем отклонение по каждому параметру отдельно.

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

         vector<float> check = MathAbs(error) - STD * STD_Multiplier;
         if(check.Max() > 0)
           {
            //---
            Result.AssignArray(CAGrad(error) + result);
            if(!Decoder.backProp(Result, (CNet *)NULL) ||
               !Encoder.backPropGradient(GetPointer(LastEncoder), GetPointer(Gradient)))
              {
               PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
               break;
              }
           }

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

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

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

         Encoder.getResults(result);
         LastEncoder.AssignArray(result);
         //---
         if(GetTickCount() - ticks > 500)
           {
            double percent = (double(i - state) / ((end - state)) + iter) * 100.0 / (Iterations);
            string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Decoder", percent, Decoder.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

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

   Comment("");
//---
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Decoder", Decoder.getRecentAverageError());
   ExpertRemove();
//---
  }

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

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//---
   if(!(reason == REASON_INITFAILED || reason == REASON_RECOMPILE))
     {
      Encoder.Save(FileName + "Enc.nnw", 0, 0, 0, TimeCurrent(), true);
      Decoder.Save(FileName + "Dec.nnw", Decoder.getRecentAverageError(), 0, 0, TimeCurrent(), true);
     }
   delete Result;
   delete OpenCL;
  }

2.4 Обучение политики

Следующим этапом мы переходим к обучению политики поведения Агента, которая реализована в советнике «...\Experts\GCPC\Study.mq5». Здесь же мы будем обучать модель энкодера состояния, которая по существу является составной частью модели нашего Агента. И модель постановки целей.

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

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

CNet                 Actor;
CNet                 StateEncoder;
CNet                 Encoder;
CNet                 Goal;

В методе инициализации советника OnInit мы, как и в выше рассмотренном советнике, загружаем обучающую выборку.

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   ResetLastError();
   if(!LoadTotalBase())
     {
      PrintFormat("Error of load study data: %d", GetLastError());
      return INIT_FAILED;
     }

Далее мы загружаем модели. Вначале пробуем открыть предварительно обученный Энкодер. Он должен быть обучен на первом этапе алгоритма Goal-Conditioned Predictive Coding. И отсутствие данной модели не позволяет нам перейти к следующему этапу.

//--- load models
   float temp;
   if(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Cann't load Encoder model");
      return INIT_FAILED;
     }

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

   if(!StateEncoder.Load(FileName + "StEnc.nnw", temp, temp, temp, dtStudied, true) ||
      !Goal.Load(FileName + "Goal.nnw", temp, temp, temp, dtStudied, true) ||
      !Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true))
     {
      CArrayObj *actor = new CArrayObj();
      CArrayObj *goal = new CArrayObj();
      CArrayObj *encoder = new CArrayObj();
      if(!CreateDescriptions(actor, goal, encoder))
        {
         delete actor;
         delete goal;
         delete encoder;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor) || !StateEncoder.Create(encoder) || !Goal.Create(goal))
        {
         delete actor;
         delete goal;
         delete encoder;
         return INIT_FAILED;
        }
      delete actor;
      delete goal;
      delete encoder;
      //---
     }

Затем мы переносим все модели в единый контекст OpenCL. И в обязательном порядке устанавливаем в положение false режим обучения Энкодера, чтобы отключить маскирование исходных данных.

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

   Actor.getResults(Result);
   if(Result.Total() != NActions)
     {
      PrintFormat("The scope of the actor does not match the actions count (%d <> %d)", NActions, Result.Total());
      return INIT_FAILED;
     }
   Encoder.getResults(Result);
   if(Result.Total() != EmbeddingSize)
     {
      PrintFormat("The scope of the Encoder does not match the embedding size (%d <> %d)", EmbeddingSize, Result.Total());
      return INIT_FAILED;
     }
//---
   Encoder.GetLayerOutput(0, Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Input size of Encoder doesn't match state description (%d <> %d)", Result.Total(),
                                                                                               (HistoryBars * BarDescr));
      return INIT_FAILED;
     }
//---
   StateEncoder.GetLayerOutput(0, Result);
   if(Result.Total() != EmbeddingSize)
     {
      PrintFormat("Input size of State Encoder doesn't match Bottleneck (%d <> %d)", Result.Total(), EmbeddingSize);
      return INIT_FAILED;
     }
//---
   StateEncoder.getResults(Result);
   int latent_state = Result.Total();
   Actor.GetLayerOutput(0, Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Actor doesn't match output State Encoder (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }
//---
   Goal.GetLayerOutput(0, Result);
   latent_state = Result.Total();
   Encoder.getResults(Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Goal doesn't match output Encoder (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }
//---
   Goal.getResults(Result);
   if(Result.Total() != NRewards)
     {
      PrintFormat("The scope of Goal doesn't match rewards count (%d <> %d)", Result.Total(), NRewards);
      return INIT_FAILED;
     }

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

   if(!bLastEncoder.BufferInit(EmbeddingSize, 0) ||
      !bGradient.BufferInit(MathMax(EmbeddingSize, AccountDescr), 0) ||
      !bLastEncoder.BufferCreate(OpenCL) ||
      !bGradient.BufferCreate(OpenCL))
     {
      PrintFormat("Error of create buffers: %d", GetLastError());
      return INIT_FAILED;
     }

И генерируем пользовательское событие начала процесса обучения.

   if(!EventChartCustom(ChartID(), 1, 0, 0, "Init"))
     {
      PrintFormat("Error of create study event: %d", GetLastError());
      return INIT_FAILED;
     }
//---
   return(INIT_SUCCEEDED);
  }

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

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//---
   if(!(reason == REASON_INITFAILED || reason == REASON_RECOMPILE))
     {
      Actor.Save(FileName + "Act.nnw", 0, 0, 0, TimeCurrent(), true);
      StateEncoder.Save(FileName + "StEnc.nnw", 0, 0, 0, TimeCurrent(), true);
      Goal.Save(FileName + "Goal.nnw", 0, 0, 0, TimeCurrent(), true);
     }
   delete Result;
   delete OpenCL;
  }

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

//+------------------------------------------------------------------+
//| Train function                                                   |
//+------------------------------------------------------------------+
void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);

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

   vector<float> result, target;
   matrix<float> targets;
   STD_Actor = vector<float>::Zeros(NActions);
   STD_Goal = vector<float>::Zeros(NRewards);
   int std_count = 0;
   bool Stop = false;
//---
   uint ticks = GetTickCount();

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

В теле внешнего цикла мы сэмплируем траекторию и начальное состояние на ней.

   for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter ++)
     {
      int tr = SampleTrajectory(probability);
      int batch = GPTBars + 50;
      int state = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - PrecoderBars - batch));
      if(state <= 0)
        {
         iter--;
         continue;
        }

Очищаем стек Энкодера и буфер последних его результатов.

      Encoder.Clear();
      bLastEncoder.BufferInit(EmbeddingSize, 0);

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

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

      int end = MathMin(state + batch, Buffer[tr].Total - PrecoderBars);
      for(int i = state; i < end; i++)
        {
         bState.AssignArray(Buffer[tr].States[i].state);
         //---
         if(!bLastEncoder.BufferWrite() ||
            !Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)GetPointer(bLastEncoder)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

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

         float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
         float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
         bAccount.Clear();
         bAccount.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance);
         bAccount.Add(Buffer[tr].States[i].account[1] / PrevBalance);
         bAccount.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity);
         bAccount.Add(Buffer[tr].States[i].account[2]);
         bAccount.Add(Buffer[tr].States[i].account[3]);
         bAccount.Add(Buffer[tr].States[i].account[4] / PrevBalance);
         bAccount.Add(Buffer[tr].States[i].account[5] / PrevBalance);
         bAccount.Add(Buffer[tr].States[i].account[6] / PrevBalance);
         double time = (double)Buffer[tr].States[i].account[7];
         double x = time / (double)(D'2024.01.01' - D'2023.01.01');
         bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = time / (double)PeriodSeconds(PERIOD_MN1);
         bAccount.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
         x = time / (double)PeriodSeconds(PERIOD_W1);
         bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = time / (double)PeriodSeconds(PERIOD_D1);
         bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         if(bAccount.GetIndex() >= 0)
            bAccount.BufferWrite();

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

         //--- State embedding
         if(!StateEncoder.feedForward((CNet *)GetPointer(Encoder), -1, (CBufferFloat*)GetPointer(bAccount)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

И теперь, для осуществления прямого прохода Актера нам необходимо указать ему цель. Как и в случае Decision Transformer, на данном этапе мы используем фактические результаты, полученные при взаимодействии с окружающей средой в качестве целеуказания. А фактически совершенные действия Агента в качестве целевых результатов работы политики. Таким образом мы выстраиваем связи между целью и действием в конкретном состоянии окружающей среды. Но здесь есть один нюанс. При обучении Автоэнкодера мы стремились получить прогнозные данные на несколько свечей вперед. Следовательно, сейчас мы ожидаем наличие прогнозной информации о нескольких последующих свечах в сжатом представлении текущего состояния. Логично предположить, что совершаемые на данном этапе действия Агента должны быть рассчитаны на получение результата в прогнозируемом временном отрезке. Казалось бы, мы можем взять суммарное вознаграждение за прогнозируемый период в качестве цели для совершаемого действия. Но кто сказал, что открытая в данный момент сделка должна быть закрыта только по истечении прогнозного периода. Она может быть закрыта как раньше, так и позже. Для случая "позже" мы не можем заглядывать дальше прогнозных значений. Поэтому можем взять только результат на конец прогнозируемого периода. А вот при смене направления ценового движения внутри прогнозируемого периода сделка должна быть закрыта раньше. Поэтому потенциальная наша цель должна быть максимальным значением за прогнозируемы период с учетом коэффициента дисконтирования.

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

         targets = matrix<float>::Zeros(PrecoderBars, NRewards);
         result.Assign(Buffer[tr].States[i + 1].rewards);
         for(int t = 0; t < PrecoderBars; t++)
           {
            target = result;
            result.Assign(Buffer[tr].States[i + t + 2].rewards);
            target = target - result * DiscFactor;
            targets.Row(target, t);
           }

А затем суммируем их в обратном порядке с учетом коэффициента дисконтирования.

         for(int t = 1; t < PrecoderBars; t++)
           {
            target = targets.Row(t - 1) + targets.Row(t) * MathPow(DiscFactor, t);
            targets.Row(target, t);
           }

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

         result = targets.Sum(1);
         ulong row = result.ArgMax();
         target = targets.Row(row);
         bGoal.AssignArray(target);

Я вполне согласен с замечанием, что прибыль (или убыток) полученные на последующих временных шагах могут быть связаны со сделками, которые Агент совершил раньше или позже. Здесь есть 2 момента.

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

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

С учетом подготовленной цели у нас достаточно данных для осуществления прямого прохода Актера.

         //--- Actor
         if(!Actor.feedForward((CNet *)GetPointer(StateEncoder), -1, (CBufferFloat*)GetPointer(bGoal)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

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

         target.Assign(Buffer[tr].States[i].action);
         target.Clip(0, 1);
         Actor.getResults(result);
         vector<float> error = target - result;
         std_count = MathMin(std_count, 999);
         STD_Actor = MathSqrt((MathPow(STD_Actor, 2) * std_count + MathPow(error, 2)) / (std_count + 1));

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

         check = MathAbs(error) - STD_Actor * STD_Multiplier;
         if(check.Max() > 0)
           {
            Result.AssignArray(CAGrad(error) + result);
            if(!Actor.backProp(Result, (CBufferFloat *)GetPointer(bGoal), (CBufferFloat *)GetPointer(bGradient)) ||
               !StateEncoder.backPropGradient(GetPointer(bAccount), (CBufferFloat *)GetPointer(bGradient)))
              {
               PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
               Stop = true;
               break;
              }
           }

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

         //--- Goal
         if(!Goal.feedForward((CNet *)GetPointer(Encoder), -1, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

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

         target=targets.Row(row);
         result = target / (MathAbs(target) + FLT_EPSILON);

В результате данной операции мы ожидаем получить вектор, содержащий "-1" для отрицательных значений и "1" для положительных. Возведя вектор из "2" в степень полученного вектора мы получим "2" для положительных значений и "½" для отрицательных.

        result = MathPow(vector<float>::Full(NRewards, 2), result);

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

         target = target * result;
         Goal.getResults(result);
         error = target - result;
         std_count = MathMin(std_count, 999);
         STD_Goal = MathSqrt((MathPow(STD_Goal, 2) * std_count + MathPow(error, 2)) / (std_count + 1));
         std_count++;
         check = MathAbs(error) - STD_Goal * STD_Multiplier;
         if(check.Max() > 0)
           {
            Result.AssignArray(CAGrad(error) + result);
            if(!Goal.backProp(Result, (CBufferFloat *)NULL, (CBufferFloat *)NULL))
              {
               PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
               Stop = true;
               break;
              }
           }

Здесь мы так же эксплуатируем идею использования выражений в закрытой форме для оптимизации модели с акцентом на максимальные отклонения.

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

         Encoder.getResults(result);
         bLastEncoder.AssignArray(result);

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

         //---
         if(GetTickCount() - ticks > 500)
           {
            double percent = (double(i - state) / ((end - state)) + iter) * 100.0 / (Iterations);
            string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Actor", percent, Actor.getRecentAverageError());
            str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Goal", percent, Goal.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

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

   Comment("");
//---
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor", Actor.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Goal", Goal.getRecentAverageError());
   ExpertRemove();
//---
  }

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


3. Тестирование

Выше была проделана довольно большая работа по реализации метода Goal-Conditioned Predictive Coding средствами MQL5. Размер данной статьи подтверждает объем проделанной работы. И пришло время перейти к проверке её результатов.

Как обычно обучение и тестирование моделей осуществляется на исторических данных инструмента EURUSD, тайм-фрейм H1. Обучение моделей осуществляется на исторических данных за первые 7 месяцев 2023 года. А тестирование обученных моделей проведено на данных августа 2023 года, которые следуют непосредственно за обучающим историческим периодом.

Обучение осуществлялось итерационно. Вначале была собрана обучающая выборка, которую мы собирали в 2 этапа. На первом этапе мы сохранили в обучающую выборку проходы по данным реальных сигналов, как это было предложено в методе Real-ORL. А затем обучающая выборка была дополнена проходами с использованием советника «...\Experts\GCPC\Research.mq5» и случайных политик.

На этих данных был обучен Автокодировщик с использованием советника «...\Experts\GCPC\StudyEncoder.mq5». Как уже было сказано выше, для целей обучения данного советника все проходы идентичны. И обучение модели не требует дополнительного обновления обучающей выборки. Поэтому мы обучаем маскированный Автокодировщих до получения приемлемых результатов.

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


Заключение

В данной статье мы познакомились с довольно интересным методом Goal-Conditioned Predictive Coding. Основным вкладом которого является разделение процесса обучения модели на 2 подпроцесса: изучение траектории и отдельное изучение политики. При изучении траектории акцентируется внимание на возможности проецирования наблюдаемых тенденций на будущие состояния, что в целом повышает информативность данных, передаваемых Агенту для принятия решения.

В практической части данной статьи мы реализовали свое видение предложенного метода средствами MQL5 и на практике подтвердили эффективность предложенного подхода.

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


Ссылки


Программы, используемые в статье

# Имя Тип Описание
1 Research.mq5 Советник Советник сбора примеров
2 ResearchRealORL.mq5
Советник
Советник сбора примеров методом Real-ORL
3 Study.mq5  Советник Советник обучения Политики
4 StudyEncoder.mq5 Советник
Советник обучения Автокодировщика
5 Test.mq5 Советник Советник для тестирования модели
6 Trajectory.mqh Библиотека класса Структура описания состояния системы
7 NeuroNet.mqh Библиотека класса Библиотека классов для создания нейронной сети
8 NeuroNet.cl Библиотека Библиотека кода программы OpenCL

Прикрепленные файлы |
MQL5.zip (755.71 KB)
Популяционные алгоритмы оптимизации: Гибридный алгоритм оптимизации бактериального поиска с генетическим алгоритмом (Bacterial Foraging Optimization - Genetic Algorithm, BFO-GA) Популяционные алгоритмы оптимизации: Гибридный алгоритм оптимизации бактериального поиска с генетическим алгоритмом (Bacterial Foraging Optimization - Genetic Algorithm, BFO-GA)
В статье представлен новый подход к решению оптимизационных задач, путём объединения идей алгоритмов оптимизации бактериального поиска пищи (BFO) и приёмов, используемых в генетическом алгоритме (GA), в гибридный алгоритм BFO-GA. Он использует роение бактерий для глобального поиска оптимального решения и генетические операторы для уточнения локальных оптимумов. В отличие от оригинального BFO бактерии теперь могут мутировать и наследовать гены.
Нейросети — это просто (Часть 70): Улучшение политики с использованием операторов в закрытой форме (CFPI) Нейросети — это просто (Часть 70): Улучшение политики с использованием операторов в закрытой форме (CFPI)
В этой статье мы предлагаем познакомиться с алгоритмом, который использует операторы улучшения политики в закрытой форме для оптимизации действий Агента в офлайн режиме.
Разметка данных в анализе временных рядов (Часть 1):Создаем набор данных с маркерами тренда с помощью графика советника Разметка данных в анализе временных рядов (Часть 1):Создаем набор данных с маркерами тренда с помощью графика советника
В этой серии статей представлены несколько методов маркировки временных рядов, которые могут создавать данные, соответствующие большинству моделей искусственного интеллекта (ИИ). Целевая маркировка данных может сделать обученную модель ИИ более соответствующей пользовательским целям и задачам, повысить точность модели и даже помочь модели совершить качественный скачок!
Нейросети — это просто (Часть 69): Ограничение политики поведения на основе плотности офлайн данных (SPOT) Нейросети — это просто (Часть 69): Ограничение политики поведения на основе плотности офлайн данных (SPOT)
В оффлайн обучении мы используем фиксированный набор данных, что ограничивает покрытие разнообразия окружающей среды. В процессе обучения наш Агент может генерировать действия вне этого набора. При отсутствии обратной связи от окружающей среды корректность оценок таких действий вызывает вопросы. Поддержание политики Агента в пределах обучающей выборки становится важным аспектом для обеспечения надежности обучения. Об этом мы и поговорим в данной статье.