preview
Нейросети — это просто (Часть 69): Ограничение политики поведения на основе плотности офлайн данных (SPOT)

Нейросети — это просто (Часть 69): Ограничение политики поведения на основе плотности офлайн данных (SPOT)

MetaTrader 5Торговые системы | 22 декабря 2023, 13:44
1 330 2
Dmitriy Gizlyk
Dmitriy Gizlyk

Введение

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

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

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

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

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

В данном контексте положительно выделяется метод Supported Policy OpTimization (SPOT), который был представлен в статье "Supported Policy Optimization for Offline Reinforcement Learning". Его подходы напрямую вытекают из теоретической формализации ограничения политики на основании плотности распределения обучающей выборки. SPOT использует оценщик плотности на основе вариационного автокодировщика (VAE). Который представляет собой простой, но эффективный элемент регуляризации. И его можно встраивать в готовые алгоритмы обучения с подкреплением. SPOT достигает лучшей в своем классе производительности на стандартных бенчмарках для офлайн RL. А благодаря гибкому дизайну, модели, предварительно обученные в офлайн режиме с использованием SPOT, также могут получить тонкую настройку в онлайн режиме.


1. Алгоритм Supported Policy OpTimization (SPOT)

Ограничения поддержки — это типичный метод смягчения ошибки в офлайн обучении с подкреплением. В свою очередь, ограничение поддержки можно формализовать на основе плотности стратегии поведения. Авторы метода Supported Policy OpTimization предлагают алгоритм регуляризации с перспективой явной оценки плотности. SPOT включает в себя регуляризационный член, который прямо вытекает из теоретической формализации ограничения поддержки плотностью распределения. В качестве элемента регуляризации используется расширенный вариационный автокодировщик (CVAE), который изучает плотность обучающей выборки.

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

В случае аппроксимации функции это соответствует проблеме оптимизации стратегии с ограничением.

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

где ϵ'=log ϵ для удобства обозначения.

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

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

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

где λ — множитель Лагранжа.

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

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


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

После рассмотрения теоретических аспектов метода Supported Policy Optimization мы переходим его практической реализации средствами MQL5. Свою модель мы будем реализовывать на базе советников из статьи про метод Real-ORL. Напомню, что используемая базовая модель построена на основе метода Soft Actor-Critic близкого к TD3, используемого авторами SPOT. При этом наша модель дополнена рядом подходов, которые были рассмотрены в предыдущих статьях.

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

Таким образом, становится очевидным, что сразу переходим к советнику обучения модели. Однако, следует обратить внимание, что до момента начала обучения политики нам необходимо обучить автоэнкодер функции плотности обучающей выборки. Следовательно, процесс обучения мы разделим на 2 этапа. И обучение автоэнкодера вынесем в отдельный советник "...\SPOT\StudyCVAE.mq5".

2.1 Обучение модели плотности

Прежде чем приступить к построению советника обучения модели плотности давайте сначала обсудим, что и как мы будем учить. Авторы метода SPOT предложили использовать расширенный автоэнкодер для изучения плотности обучающей выборки. Что же это значит с практической точки зрения?

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

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

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

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

Таким образом, нам предстоит построить модель автоэнкодера, которая на вход прямого прохода должна получить 3 тензора:

  • Состояние окружающей среды (на вход Энкодера)
  • Действие Агента (на вход Энкодера)
  • Состояние окружающей среды (Ключ на вход Декодера)

Ранее мы строили модели только с исходными данными из 2 тензоров. А перед нами стоит вопрос реализации исходных данных из 3 тензоров. Конечно, эта задача может быть решена несколькими способами.

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

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

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

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

bool CreateCVAEDescriptions(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;
     }
//--- 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;
     }

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

//--- layer 2
   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 3
   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 4
   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 5
   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 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = NActions;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Затем с помощью 2 полносвязных слоёв мы сжимаем данные.

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

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

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

Далее идет описание архитектуры Декодера. На вход модели подается латентное представление, сгенерированное Энкродером.

//--- Decoder
   decoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = 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 = defNeuronConcatenate;
   descr.count = EmbeddingSize;
   descr.window = prev_count;
   descr.step = (HistoryBars * BarDescr);
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

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

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

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!decoder.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(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

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

CNet                 Encoder;
CNet                 Decoder;

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

//+------------------------------------------------------------------+
//| 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 CVAE");
      CArrayObj *encoder = new CArrayObj();
      CArrayObj *decoder = new CArrayObj();
      if(!CreateCVAEDescriptions(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.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;
     }
//---
   Encoder.getResults(Result);
   int latent_state = Result.Total();
   Decoder.GetLayerOutput(0, Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Decoder doesn't match result of Encoder (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }

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

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

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

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//---
   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;
  }

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

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

//+------------------------------------------------------------------+
//| Train function                                                   |
//+------------------------------------------------------------------+
void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   uint ticks = GetTickCount();
   int bar = (HistoryBars - 1) * BarDescr;

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

   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
      int tr = int((MathRand() * MathRand() / MathPow(32767, 2)) * (total_tr));
      int i = int((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2));
      if(i < 0)
         continue;

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

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

      State.AssignArray(Buffer[tr].States[i].state);
      Actions.AssignArray(Buffer[tr].States[i].action);
      if(Actions.GetIndex() >= 0)
         Actions.BufferWrite();

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

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

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

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

      if(!Decoder.backProp(GetPointer(Actions), GetPointer(Encoder), 1) ||
         !Encoder.backPropGradient(GetPointer(Actions), GetPointer(Actions)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

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

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

      if(GetTickCount() - ticks > 500)
        {
         string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Decoder", iter * 100.0 / (double)(Iterations), 
                                                                                    Decoder.getRecentAverageError());
         Comment(str);
         ticks = GetTickCount();
        }
     }

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

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

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

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

2.2 Обучение политики Агента

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

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

STrajectory          Buffer[];
CNet                 Actor;
CNet                 Critic1;
CNet                 Critic2;
CNet                 TargetCritic1;
CNet                 TargetCritic2;
CNet                 Convolution;
CNet                 Encoder;
CNet                 Decoder;

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

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

Затем, ещё до загрузки обучаемых моделей мы загружаем Автоэнкодер. При невозможности загрузки моделей мы информируем пользователя и завершаем работу метода инициализации с результатом 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("Cann't load CVAE");
      return INIT_FAILED;
     }

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

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

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

   if(!Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetCritic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetCritic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Init new models");
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      CArrayObj *convolution = new CArrayObj();
      if(!CreateDescriptions(actor, critic, convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor) || !Critic1.Create(critic) || !Critic2.Create(critic) ||
         !Convolution.Create(convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      if(!TargetCritic1.Create(critic) || !TargetCritic2.Create(critic))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      delete actor;
      delete critic;
      delete convolution;
      //---
      TargetCritic1.WeightsUpdate(GetPointer(Critic1), 1.0f);
      TargetCritic2.WeightsUpdate(GetPointer(Critic2), 1.0f);
      StartTargetIter = StartTargetIteration;
     }
   else
      StartTargetIter = 0;
   if(!Convolution.Load(FileName + "CNN.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Init new Encoder model");
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      CArrayObj *convolution = new CArrayObj();
      if(!CreateDescriptions(actor, critic, convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      if(!Convolution.Create(convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      delete actor;
      delete critic;
      delete convolution;
     }

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

   OpenCL = Actor.GetOpenCL();
   Critic1.SetOpenCL(OpenCL);
   Critic2.SetOpenCL(OpenCL);
   TargetCritic1.SetOpenCL(OpenCL);
   TargetCritic2.SetOpenCL(OpenCL);
   Convolution.SetOpenCL(OpenCL);
   Encoder.SetOpenCL(OpenCL);
   Decoder.SetOpenCL(OpenCL);
   Encoder.TrainMode(false);
   Decoder.TrainMode(false);

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

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

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

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

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

   Actor.GetLayerOutput(0, Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Input size of Actor doesn't match state description (%d <> %d)", Result.Total(), (HistoryBars * BarDescr));
      return INIT_FAILED;
     }

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

   Actor.GetLayerOutput(LatentLayer, Result);
   int latent_state = Result.Total();
   Critic1.GetLayerOutput(0, Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Critic doesn't match latent state Actor (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }

Аналогичные проверки сделаем и для моделей Энкодера и Декодера автоэнкодера.

   Decoder.getResults(Result);
   if(Result.Total() != NActions)
     {
      PrintFormat("The scope of the Decoder does not match the actions count (%d <> %d)", NActions, 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;
     }
   Encoder.getResults(Result);
   latent_state = Result.Total();
   Decoder.GetLayerOutput(0, Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Decoder doesn't match result of Encoder (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }

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

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

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

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

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

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

void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   uint ticks = GetTickCount();
//---
   int total_states = Buffer[0].Total;
   for(int i = 1; i < total_tr; i++)
      total_states += Buffer[i].Total;
   vector<float> temp, next;
   Convolution.getResults(temp);
   matrix<float> state_embedding = matrix<float>::Zeros(total_states, temp.Size());
   matrix<float> rewards = matrix<float>::Zeros(total_states, NRewards);
   matrix<float> actions = matrix<float>::Zeros(total_states, NActions);

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

   int state = 0;
   for(int tr = 0; tr < total_tr; tr++)
     {
      for(int st = 0; st < Buffer[tr].Total; st++)
        {
         State.AssignArray(Buffer[tr].States[st].state);

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

         float PrevBalance = Buffer[tr].States[MathMax(st - 1, 0)].account[0];
         float PrevEquity = Buffer[tr].States[MathMax(st - 1, 0)].account[1];
         State.Add((Buffer[tr].States[st].account[0] - PrevBalance) / PrevBalance);
         State.Add(Buffer[tr].States[st].account[1] / PrevBalance);
         State.Add((Buffer[tr].States[st].account[1] - PrevEquity) / PrevEquity);
         State.Add(Buffer[tr].States[st].account[2]);
         State.Add(Buffer[tr].States[st].account[3]);
         State.Add(Buffer[tr].States[st].account[4] / PrevBalance);
         State.Add(Buffer[tr].States[st].account[5] / PrevBalance);
         State.Add(Buffer[tr].States[st].account[6] / PrevBalance);

 Тут же мы в буфер вносим гармоники временной метки.

         double x = (double)Buffer[tr].States[st].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_MN1);
         State.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_W1);
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_D1);
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         State.AddArray(vector<float>::Zeros(NActions));

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

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

         if(!Convolution.feedForward((CBufferFloat *)GetPointer(State), 1, false, (CBufferFloat *)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            ExpertRemove();
            return;
           }

Результаты его работы сохраним в матрицу эмбедингов.

         Convolution.getResults(temp);
         if(!state_embedding.Row(temp, state))
            continue;

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

         if(!temp.Assign(Buffer[tr].States[st].action) ||
            !actions.Row(temp, state))
            continue;
         if(!temp.Assign(Buffer[tr].States[st].rewards) ||
            !next.Assign(Buffer[tr].States[st + 1].rewards) ||
            !rewards.Row(temp - next * DiscFactor, state))
            continue;

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

         state++;
         if(GetTickCount() - ticks > 500)
           {
            string str = StringFormat("%-15s %6.2f%%", "Embedding ", state * 100.0 / (double)(total_states));
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

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

   if(state != total_states)
     {
      rewards.Resize(state, NRewards);
      actions.Resize(state, NActions);
      state_embedding.Reshape(state, state_embedding.Cols());
      total_states = state;
     }

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

   vector<float> rewards1, rewards2, target_reward;
   STarget target;
   int bar = (HistoryBars - 1) * BarDescr;
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);

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

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

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

Далее согласно методу SAC нам предстоит рассчитать ожидаемое вознаграждение до конца эпизода. Для этого мы используем целевые модели Критиков. Однако осуществлять данные операции мы будем только с использованием предварительно обученных моделей. Поэтому перед началом операций мы проверяем прохождение минимально-необходимого числа итераций предварительного обучения.

      target_reward = vector<float>::Zeros(NRewards);
      //--- Target
      if(iter >= StartTargetIter)
        {
         State.AssignArray(Buffer[tr].States[i + 1].state);

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

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

         float PrevBalance = Buffer[tr].States[i].account[0];
         float PrevEquity = Buffer[tr].States[i].account[1];
         Account.Clear();
         Account.Add((Buffer[tr].States[i + 1].account[0] - PrevBalance) / PrevBalance);
         Account.Add(Buffer[tr].States[i + 1].account[1] / PrevBalance);
         Account.Add((Buffer[tr].States[i + 1].account[1] - PrevEquity) / PrevEquity);
         Account.Add(Buffer[tr].States[i + 1].account[2]);
         Account.Add(Buffer[tr].States[i + 1].account[3]);
         Account.Add(Buffer[tr].States[i + 1].account[4] / PrevBalance);
         Account.Add(Buffer[tr].States[i + 1].account[5] / PrevBalance);
         Account.Add(Buffer[tr].States[i + 1].account[6] / PrevBalance);

В него же мы добавим гармоники временной метки.

         double x = (double)Buffer[tr].States[i + 1].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
         Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_MN1);
         Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_W1);
         Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_D1);
         Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         //---
         if(Account.GetIndex() >= 0)
            Account.BufferWrite();

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

         if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

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

         if(!TargetCritic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
            !TargetCritic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

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

         TargetCritic1.getResults(rewards1);
         TargetCritic2.getResults(rewards2);
         target_reward.Assign(Buffer[tr].States[i + 1].rewards);
         if(rewards1.Sum() <= rewards2.Sum())
            target_reward = rewards1 - target_reward;
         else
            target_reward = rewards2 - target_reward;
         target_reward *= DiscFactor;
         target_reward[NRewards - 1] = EntropyLatentState(Actor);
        }

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

      //--- Q-function study
      State.AssignArray(Buffer[tr].States[i].state);

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

      float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
      float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
      Account.Clear();
      Account.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance);
      Account.Add(Buffer[tr].States[i].account[1] / PrevBalance);
      Account.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity);
      Account.Add(Buffer[tr].States[i].account[2]);
      Account.Add(Buffer[tr].States[i].account[3]);
      Account.Add(Buffer[tr].States[i].account[4] / PrevBalance);
      Account.Add(Buffer[tr].States[i].account[5] / PrevBalance);
      Account.Add(Buffer[tr].States[i].account[6] / PrevBalance);

Вносим гармоники временной метки.

      double x = (double)Buffer[tr].States[i].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
      Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_MN1);
      Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
      x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_W1);
      Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_D1);
      Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      if(Account.GetIndex() >= 0)
         Account.BufferWrite();

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

      if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

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

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

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

      Actions.AssignArray(Buffer[tr].States[i].action);
      if(Actions.GetIndex() >= 0)
         Actions.BufferWrite();
      //---
      if(!Critic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)) ||
         !Critic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

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

      if(!State.AddArray(GetPointer(Account)) || !State.AddArray(vector<float>::Zeros(NActions)) ||
         !Convolution.feedForward((CBufferFloat *)GetPointer(State), 1, false, (CBufferFloat *)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

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

      Convolution.getResults(temp);
      target = GetTargets(Quant, temp, state_embedding, rewards, actions);

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

      Critic1.getResults(rewards1);
      Result.AssignArray(CAGrad(target.rewards + target_reward - rewards1) + rewards1);
      if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }
      Critic2.getResults(rewards2);
      Result.AssignArray(CAGrad(target.rewards + target_reward - rewards2) + rewards2);
      if(!Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

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

      //--- Policy study
      Actor.getResults(rewards1);
      Result.AssignArray(CAGrad(target.actions - rewards1) + rewards1);
      if(!Actor.backProp(Result, GetPointer(Account), GetPointer(Gradient)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

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

      Decoder.getResults(rewards2);
      if(rewards2.Loss(rewards1, LOSS_MSE) > MeanCVAEError)
        {
         Actions.AssignArray(rewards1);
         if(!Decoder.backProp(GetPointer(Actions), GetPointer(Encoder), 1) ||
            !Encoder.backPropGradient((CNet*)GetPointer(Actor)) ||
            !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
        }

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

      CNet *critic = NULL;
      if(Critic1.getRecentAverageError() <= Critic2.getRecentAverageError())
         critic = GetPointer(Critic1);
      else
         critic = GetPointer(Critic2);
      if(MathAbs(critic.getRecentAverageError()) <= MaxErrorActorStudy)
        {
         if(!critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
         critic.getResults(rewards1);
         Result.AssignArray(CAGrad(target.rewards + target_reward - rewards1) + rewards1);
         critic.TrainMode(false);
         if(!critic.backProp(Result, GetPointer(Actor)) ||
            !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            critic.TrainMode(true);
            break;
           }
         critic.TrainMode(true);
        }

Далее нам остается обновить целевые модели Критиков.

      //--- Update Target Nets
      if(iter >= StartTargetIter)
        {
         TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
         TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
        }
      else
        {
         TargetCritic1.WeightsUpdate(GetPointer(Critic1), 1);
         TargetCritic2.WeightsUpdate(GetPointer(Critic2), 1);
        }

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

      if(GetTickCount() - ticks > 500)
        {
         string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic1", iter * 100.0 / (double)(Iterations),
                                                                                    Critic1.getRecentAverageError());
         str += StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic2", iter * 100.0 / (double)(Iterations), 
                                                                                    Critic2.getRecentAverageError());
         str += StringFormat("%-14s %5.2f%% -> Error %15.8f\n", "Actor", iter * 100.0 / (double)(Iterations), 
                                                                                      Actor.getRecentAverageError());
         Comment(str);
         ticks = GetTickCount();
        }
     }

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

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

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


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

Выше была проведена работа по реализации метода Supported Policy OpTimization (SPOT) средствами MQL5 и пришло время проверить на практике результаты нашей работы. Как всегда, я хочу обратить Ваше внимание, что в данной работе представлено собственное видение предложенных авторами метода подходов. Более того, они наложены на созданные ранее наработки с использованием других методов. В результате мы построили модель из некоторого конгломерата различных идей, собранных моим видением процесса. Следовательно, все возможно замеченные недостатки нельзя полностью проецировать ни на один из используемых методов.

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

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

На первом этапе мы осуществляем обучение Автоэнкодера. Обучающая выборка начитывает 500 траекторий по 3591 состояния окружающей среды в каждой. Что в целом составляет без малого 1.8 млн. комплектов "Состояние-Действие-Вознаграждение". На данном этапе я осуществил 5 циклов обучения Автоэнкодера по 0.5 млн. итераций в каждом, что на 40% превышает объем обучающей выборки.

После первичного обучения Автоэнкодера мы запускаем процесс обучения моделей в советнике "...\SPOT\Study.mq5". Сразу скажу, что продолжительность процесса обучения моделей значительно превышает время обучения Автоэнкодера.

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

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

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

На основе обновленной обучающей выборки мы повторяем обучение Автоэнкодера на 0.5 млн. итераций. И производим дообучение политики Актера.

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

Результаты тестирования

Результаты тестирования

Как можно заметить на представленных данных, за месяц тестирования стратегии модель совершила 124 сделки (92 коротких и 32 длинных). Из них почти 47% было закрыто с прибылью. Примечательно, что доля прибыльных по длинным и коротким позициям близка (50% и 46% соответственно). При этом средняя прибыльная сделка на 25% превышает средний убыток. А максимальная прибыльная сделка почти в 2 раза превышает максимальный убыток. В целом по результатам торговли профит-фактор составил 1.15.


Заключение

В данной статье мы познакомились с методом Supported Policy OpTimization (SPOT), который представляет собой успешное решение проблемы офлайн обучения в условиях ограниченной обучающей выборки. Его способность регулировать политику, учитывая оцененную плотность поведенческой стратегии, демонстрирует превосходные результаты на стандартных тестовых сценариях. SPOT легко интегрируется в существующие офлайн RL алгоритмы, обеспечивая гибкость применения в различных контекстах. Его модульная структура позволяет использовать его вместе с различными подходами к обучению.

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

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

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

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


Ссылки

  • Supported Policy Optimization for Offline Reinforcement Learning
  • Нейросети — это просто (Часть 67): Использование прошлого опыта для решения новых задач

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

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


    Прикрепленные файлы |
    MQL5.zip (652.13 KB)
    Последние комментарии | Перейти к обсуждению на форуме трейдеров (2)
    Tabata Voegele
    Tabata Voegele | 24 дек. 2023 в 10:44

    Это намеренно, что к этой статье нет вложений?

    Dmitriy Gizlyk
    Dmitriy Gizlyk | 24 дек. 2023 в 11:15
    Tabata Voegele #:

    Это намеренно, что к этой статье нет вложений?

    Это досадная ошибка и опубликована рабочая версия статьи. Исправили.

    Нейросети — это просто (Часть 70): Улучшение политики с использованием операторов в закрытой форме (CFPI) Нейросети — это просто (Часть 70): Улучшение политики с использованием операторов в закрытой форме (CFPI)
    В этой статье мы предлагаем познакомиться с алгоритмом, который использует операторы улучшения политики в закрытой форме для оптимизации действий Агента в офлайн режиме.
    Теория категорий в MQL5 (Часть 18): Квадрат естественности Теория категорий в MQL5 (Часть 18): Квадрат естественности
    Статья продолжает серию о теории категорий, представляя естественные преобразования, которые являются ключевым элементом теории. Мы рассмотрим сложное на первый взгляд определение, затем углубимся в примеры и способы применения преобразований в прогнозировании волатильности.
    Нейросети — это просто (Часть 71): Прогнозирование будущих состояний с учетом поставленных целей (GCPC) Нейросети — это просто (Часть 71): Прогнозирование будущих состояний с учетом поставленных целей (GCPC)
    В предыдущих работах мы познакомились с методом Decision Transformer и несколькими производными от него алгоритмами. Мы экспериментировали с различными методами постановки цели. В процессе экспериментов мы работали с различными способами постановки целей, однако изучение моделью уже пройденной траектории всегда оставалось вне нашего внимания. В данной статье я хочу познакомить Вас с методом, который заполняет этот пробел.
    Популяционные алгоритмы оптимизации: Алгоритмы эволюционных стратегий (Evolution Strategies, (μ,λ)-ES и (μ+λ)-ES) Популяционные алгоритмы оптимизации: Алгоритмы эволюционных стратегий (Evolution Strategies, (μ,λ)-ES и (μ+λ)-ES)
    В этой статье будет рассмотрена группа алгоритмов оптимизации, известных как "Эволюционные стратегии" (Evolution Strategies или ES). Они являются одними из самых первых популяционных алгоритмов, использующих принципы эволюции для поиска оптимальных решений. Будут представлены изменения, внесенные в классические варианты ES, а также пересмотрена тестовая функция и методика стенда для алгоритмов.