English 中文 Español Deutsch 日本語 Português
preview
Нейросети — это просто (Часть 46): Обучение с подкреплением, направленное на достижение целей (GCRL)

Нейросети — это просто (Часть 46): Обучение с подкреплением, направленное на достижение целей (GCRL)

MetaTrader 5Торговые системы | 21 июня 2023, 09:18
922 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Введение

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

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


1. Особенности GCRL

Обучение с подкреплением, направленное на достижение целей (Goal-conditioned reinforcement learning, GCRL) относится к набору сложных задач обучения с подкреплением. Мы обучаем агента достигать различных целей в определенных сценариях. Ранее мы обучали агента выбирать то или действие в зависимости от текущего состояния окружающей среды. В контексте же GCRL мы хотим обучить агента таким образом, чтобы его действие было обусловлено не только текущим состоянием, но и конкретной подзадачей на данном этапе. Иными словами, помимо вектора описания текущего состояния мы должны каким-либо образом указать агенту подзадачу для достижения в каждом конкретном моменте. Согласитесь, очень похоже на задачу обучения навыков, когда в каждый момент времени мы указывали агенту навык. Ведь указать использовать навык "открытия позиции" или задачу "открыть позицию" кажется игрой слов. Но за этими словами кроются различия в подходах обучения агентов.

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

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

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

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

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

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

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

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

Обязательно наличие выраженной логической, но не математической зависимости между вектором описания подзадачи и желаемого результата. Мы можем использовать обычный one-hot вектор, каждый элемент которого будет соответствовать отдельной подзадачи. И передавать его агенту вместе с описанием текущего состояния окружающей среды. Главное, чтобы агент мог четко интерпретировать подзадачу и выстроить свои внутренние связи между подзадачей и вознаграждением. В этой связи следует обратить внимание и на вознаграждение. Вводимое дополнительное вознаграждение должно сопоставляться с конкретной подзадачей.

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

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

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


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

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

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

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

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

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

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

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

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

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

В параметрах кернела Concat_FeedForward мы видим единую матрицу весов, 2 тензора исходных данных, вектор результатов и 3 числовых параметра (размеры тензоров исходных данных и идентификатор функции активации)

__kernel void Concat_FeedForward(__global float *matrix_w,
                                 __global float *matrix_i1,
                                 __global float *matrix_i2,
                                 __global float *matrix_o,
                                 int inputs1,
                                 int inputs2,
                                 int activation
                                )

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

  {
   int i = get_global_id(0);
   float sum = 0;
   float4 inp, weight;
   int shift = (inputs1 + inputs2 + 1) * i;

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

   for(int k = 0; k < inputs1; k += 4)
     {
      switch(inputs1 - k)
        {
         case 1:
            inp = (float4)(matrix_i1[k], 0, 0, 0);
            weight = (float4)(matrix_w[shift + k], 0, 0, 0);
            break;
         case 2:
            inp = (float4)(matrix_i1[k], matrix_i1[k + 1], 0, 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], 0, 0);
            break;
         case 3:
            inp = (float4)(matrix_i1[k], matrix_i1[k + 1], matrix_i1[k + 2], 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], 0);
            break;
         default:
            inp = (float4)(matrix_i1[k], matrix_i1[k + 1], matrix_i1[k + 2], matrix_i1[k + 3]);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], matrix_w[shift + k + 3]);
            break;
        }
      float d = dot(inp, weight);
      if(isnan(sum + d))
         continue;
      sum += d;
     }

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

   shift += inputs1;
   for(int k = 0; k < inputs2; k += 4)
     {
      switch(inputs2 - k)
        {
         case 1:
            inp = (float4)(matrix_i2[k], 0, 0, 0);
            weight = (float4)(matrix_w[shift + k], 0, 0, 0);
            break;
         case 2:
            inp = (float4)(matrix_i2[k], matrix_i2[k + 1], 0, 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], 0, 0);
            break;
         case 3:
            inp = (float4)(matrix_i2[k], matrix_i2[k + 1], matrix_i2[k + 2], 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], 0);
            break;
         default:
            inp = (float4)(matrix_i2[k], matrix_i2[k + 1], matrix_i2[k + 2], matrix_i2[k + 3]);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], matrix_w[shift + k + 3]);
            break;
        }
      float d = dot(inp, weight);
      if(isnan(sum + d))
         continue;
      sum += d;
     }

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

   sum += matrix_w[shift + inputs2];
//---
   if(isnan(sum))
      sum = 0;
   switch(activation)
     {
      case 0:
         sum = tanh(sum);
         break;
      case 1:
         sum = 1 / (1 + exp(-sum));
         break;
      case 2:
         if(sum < 0)
            sum *= 0.01f;
         break;
      default:
         break;
     }
   matrix_o[i] = sum;
  }

Точно такой же подход был применен при модификации кернелов обратного прохода и обновления матрицы весов. С ними вы можете самостоятельно познакомиться в файле "NeuroNet_DNG\NeuroNet.cl" (добавлен к статье).

После создания кернеов мы переходим к работе над кодом класса CNeuronConcatenate в основной программе. Набор методов класса довольно стандартный:

  • конструктор CNeuronConcatenate и деструктор ~CNeuronConcatenate
  • инициализации нейронного слоя Init
  • прямого прохода feedForward
  • распределения градиента ошибки calcHiddenGradients
  • обновления матрицы весов updateInputWeights
  • идентификации объекта Type
  • работы с файлами Save и Load.

class CNeuronConcatenate   :  public CNeuronBaseOCL
  {
protected:
   int               i_SecondInputs;
   CBufferFloat     *ConcWeights;
   CBufferFloat     *ConcDeltaWeights;
   CBufferFloat     *ConcFirstMomentum;
   CBufferFloat     *ConcSecondMomentum;

public:
                     CNeuronConcatenate(void);
                    ~CNeuronConcatenate(void);
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                          uint inputs1, uint inputs2, ENUM_OPTIMIZATION optimization_type, uint batch);
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput);
   virtual bool      calcHiddenGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput);
   //--- methods for working with files
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual int       Type(void)        const                      {  return defNeuronConcatenate; }
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

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

В конструкторе класса мы инициализируем буферы данных.

CNeuronConcatenate::CNeuronConcatenate(void) : i_SecondInputs(0)
  {
   ConcWeights = new CBufferFloat();
   ConcDeltaWeights = new CBufferFloat();
   ConcFirstMomentum = new CBufferFloat();
   ConcSecondMomentum = new CBufferFloat;
  }

А в деструкторе класса осуществляем очистку данных и удаление объектов.

CNeuronConcatenate::~CNeuronConcatenate()
  {
   if(!!ConcWeights)
      delete ConcWeights;
   if(!!ConcDeltaWeights)
      delete ConcDeltaWeights;
   if(!!ConcFirstMomentum)
      delete ConcFirstMomentum;
   if(!!ConcSecondMomentum)
      delete ConcSecondMomentum;
  }

Указание размерности всех необходимых буферов данных организовано в методе инициализации объекта Init. В параметрах метод получает необходимые исходные данные:

  • numOutputs — количество нейронов в последующем слое
  • open_cl  —  указатель на объект работы с контекстом OpenCL
  • numNeurons  —  количество нейронов в текущем слое
  • numInputs1  —  количество элементов в предыдущем слое
  • numInputs2  —  количество элементов в дополнительном буфере исходных данных
  • optimization_type  — идентификатор метода оптимизации параметров.
bool CNeuronConcatenate::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                              uint numInputs1, uint numInputs2, ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, numNeurons, optimization_type, batch))
      return false;

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

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

   i_SecondInputs = (int)numInputs2;
   if(!ConcWeights)
     {
      ConcWeights = new CBufferFloat();
      if(!ConcWeights)
         return false;
     }
   int count = (int)((numInputs1 + numInputs2 + 1) * numNeurons);
   if(!ConcWeights.Reserve(count))
      return false;
   float k = (float)(1.0 / sqrt(numNeurons + 1.0));
   for(int i = 0; i < count; i++)
     {
      if(!ConcWeights.Add((2 * GenerateWeight()*k - k)*WeightsMultiplier))
         return false;
     }
   if(!ConcWeights.BufferCreate(OpenCL))
      return false;

Далее, в зависимости от указанного в параметрах метода обновления весовых коэффициентов, мы инициализируем буферы моментов. Напомню, для SGD мы используем один буфер моментов. А в случае использования метода Adam будет инициализировано 2 буфера моментов. Не используемые объекты мы удаляем, что позволит эффективнее использовать имеющиеся ресурсы.

   if(optimization == SGD)
     {
      if(!ConcDeltaWeights)
        {
         ConcDeltaWeights = new CBufferFloat();
         if(!ConcDeltaWeights)
            return false;
        }
      if(!ConcDeltaWeights.BufferInit(count, 0))
         return false;
      if(!ConcDeltaWeights.BufferCreate(OpenCL))
         return false;
      if(!!ConcFirstMomentum)
         delete ConcFirstMomentum;
      if(!!ConcSecondMomentum)
         delete ConcSecondMomentum;
     }
   else
     {
      if(!!ConcDeltaWeights)
         delete ConcDeltaWeights;
      //---
      if(!ConcFirstMomentum)
        {
         ConcFirstMomentum = new CBufferFloat();
         if(CheckPointer(ConcFirstMomentum) == POINTER_INVALID)
            return false;
        }
      if(!ConcFirstMomentum.BufferInit(count, 0))
         return false;
      if(!ConcFirstMomentum.BufferCreate(OpenCL))
         return false;
      //---
      if(!ConcSecondMomentum)
        {
         ConcSecondMomentum = new CBufferFloat();
         if(!ConcSecondMomentum)
            return false;
        }
      if(!ConcSecondMomentum.BufferInit(count, 0))
         return false;
      if(!ConcSecondMomentum.BufferCreate(OpenCL))
         return false;
     }
//---
   return true;
  }

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

bool CNeuronConcatenate::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput)
  {
   if(!OpenCL || !NeuronOCL || !SecondInput)
      return false;

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

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

   if(SecondInput.Total() < i_SecondInputs)
      return false;
   if(SecondInput.GetIndex() < 0 && !SecondInput.BufferCreate(OpenCL))
      return false;

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

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

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

   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_w, ConcWeights.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_i1, NeuronOCL.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_i2, SecondInput.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_o, Output.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_ConcatFeedForward, def_k_cff_inputs1, (int)NeuronOCL.Neurons()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_ConcatFeedForward, def_k_cff_inputs2, (int)i_SecondInputs))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_ConcatFeedForward, def_k_cff_activation, (int)activation))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

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

   uint global_work_offset[1] = {0};
   uint global_work_size[1];
   global_work_size[0] = Output.Total();
   if(!OpenCL.Execute(def_k_ConcatFeedForward, 1, global_work_offset, global_work_size))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
//---
   return true;
  }

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

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

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

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

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

В параметрах диспетчерского метода CNeuronBaseOCL::FeedForward мы добавляем указатель на буфер данных и присваиваем ему значение по умолчанию. Такая хитрость позволит нам по-прежнему использовать метод с указанием только указателя на предыдущий нейронный слой. Это будет полезно при использовании библиотеки для ранее созданных моделей. И позволит компилировать ранее созданные программы без каких-либо изменений.

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

bool CNeuronBaseOCL::FeedForward(CObject *SourceObject, CBufferFloat *SecondInput = NULL)
  {
   if(CheckPointer(SourceObject) == POINTER_INVALID)
      return false;
//---
   CNeuronBaseOCL *temp = NULL;
   if(Type() == defNeuronConcatenate)
     {
      temp = SourceObject;
      CNeuronConcatenate *concat = GetPointer(this);
      return concat.feedForward(temp, SecondInput);
     }

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

А мы переходим ближе к рассматриваемому методу обучения с подкреплением GCRL и рассмотрим процессы построения и обучения модели. Как и ранее мы создадим 3 советника:

  • первичного сбора примеров "GCRL\Research.mq5"
  • обучения агента "GCRL\StudyActor.mq5"
  • проверки работы модели "GCRL\Test.mq5"

Архитектуру модели мы укажем во включаемом файле "GCRL\Trajectory.mqh".

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

bool CreateDescriptions(CArrayObj *actor)
  {
//---
   CLayerDescription *descr;
//---
   if(!actor)
     {
      actor = new CArrayObj();
      if(!actor)
         return false;
     }
//--- Actor
   actor.Clear();

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

//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.window = 0;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.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(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

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

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count - 1;
   descr.window = 2;
   descr.step = 1;
   descr.window_out = 4;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronProofOCL;
   prev_count = descr.count = prev_count;
   descr.window = 4;
   descr.step = 4;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count - 1;
   descr.window = 2;
   descr.step = 1;
   descr.window_out = 4;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronProofOCL;
   prev_count = descr.count = prev_count;
   descr.window = 4;
   descr.step = 4;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.optimization = ADAM;
   descr.activation = TANH;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 128;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NSkills;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NSkills;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

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

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

  • поиск точки входа в позицию
  • поиск точки выхода из позиции

В структуре описания состояния счета мы указывали открытые позиции. Следовательно, если объем открытых позиций равен "0", то задача открытия позиции. В противном случае мы ищем точку выхода. Идея проста и напоминает использование one-hot вектора. Отличие лишь в объеме открытых позиций. Оно редко будет равно "1". Ведь мы используем минимальный лот и допускаем одновременное открытие нескольких позиций.

//--- layer 10
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = 256;
   descr.window=prev_count;
   descr.step=AccountDescr;
   descr.optimization = ADAM;
   descr.activation = TANH;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

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

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

//--- layer 11
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 12
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 13
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFQF;
   descr.count = NActions;
   descr.window_out = 32;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

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

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

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

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

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

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

Время открытия позиций учитывается в секундах, а мы работаем с тайм-фреймом H1. Определим сразу множитель для корректировки времени действия позиции в часы. Тут же мы добавим переменную для подсчета штрафа за удержание позиции по выше приведенной формуле. Однако, мы не хотим, чтобы штраф за удержание превышал доход от такой позиции. И с этой целью мы определим, что каждый час мы будем штрафовать на 1/10 накопленной прибыли. А использование абсолютной величины прибыли в формуле выше позволит нам штрафовать как прибыльные, так и убыточные позиции.

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

   sState.account[0] = (float)AccountInfoDouble(ACCOUNT_BALANCE);
   sState.account[1] = (float)AccountInfoDouble(ACCOUNT_EQUITY);
//---
   double buy_value = 0, sell_value = 0, buy_profit = 0, sell_profit = 0;
   double position_discount = 0;
   double multiplyer = 1.0 / (60.0 * 60.0 * 10.0);
   int total = PositionsTotal();
   datetime current = TimeCurrent();
   for(int i = 0; i < total; i++)
     {
      if(PositionGetSymbol(i) != Symb.Name())
         continue;
      switch((int)PositionGetInteger(POSITION_TYPE))
        {
         case POSITION_TYPE_BUY:
            buy_value += PositionGetDouble(POSITION_VOLUME);
            buy_profit += PositionGetDouble(POSITION_PROFIT);
            break;
         case POSITION_TYPE_SELL:
            sell_value += PositionGetDouble(POSITION_VOLUME);
            sell_profit += PositionGetDouble(POSITION_PROFIT);
            break;
        }
      position_discount -= (current - PositionGetInteger(POSITION_TIME)) * multiplyer*MathAbs(PositionGetDouble(POSITION_PROFIT));
     }
   sState.account[2] = (float)buy_value;
   sState.account[3] = (float)sell_value;
   sState.account[4] = (float)buy_profit;
   sState.account[5] = (float)sell_profit;
   sState.account[6] = (float)position_discount;

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

А перед передачей данных в нашу модель мы переведем их в поле относительных единиц.

   State.AssignArray(sState.state);
   Account.Clear();
   float PrevBalance = (Base.Total <= 0 ? sState.account[0] : Base.States[Base.Total - 1].account[0]);
   float PrevEquity = (Base.Total <= 0 ? sState.account[1] : Base.States[Base.Total - 1].account[1]);
   Account.Add((sState.account[0] - PrevBalance) / PrevBalance);
   Account.Add(sState.account[1] / PrevBalance);
   Account.Add((sState.account[1] - PrevEquity) / PrevEquity);
   Account.Add(sState.account[2]);
   Account.Add(sState.account[3]);
   Account.Add(sState.account[4] / PrevBalance);
   Account.Add(sState.account[5] / PrevBalance);
   Account.Add(sState.account[6] / PrevBalance);

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

   if(Account.GetIndex()>=0)
      if(!Account.BufferWrite())
         return;
   if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
      return;

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

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

   float reward = Account[0];
   if((buy_value+sell_value)>0)
     reward+=(float)position_discount;
   else
     reward-=atr;
   if(!Base.Add(sState, act, reward))
      ExpertRemove();
//---
  }

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

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

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

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

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

Как всегда, обучение модели осуществляется в функции Train советника "GCRL\StudyActor.mq5". Вначале функции мы определяем количество проходом в нашей базе примеров. Затем мы организуем первый цикл, в котором найдем проход с максимальным количеством шагов. Мы не сохраняем конкретный проход, а только количество шагов. Его мы будем использовать при семплировании конкретного исторического момента для обучения.

void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   int total_steps = 0;
   for(int tr = 0; tr < total_tr; tr++)
     {
      if(Buffer[tr].Total > total_steps)
         total_steps = Buffer[tr].Total;
     }

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

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

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

         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);
         //---
         if(Account.GetIndex()>=0)
            Account.BufferWrite();
         if(!Actor.feedForward(GetPointer(State), 1, false,GetPointer(Account)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            ExpertRemove();
            break;
           }
         //---
      ActorResult = vector<float>::Zeros(NActions);
      ActorResult[Buffer[tr].Actions[i]] = Buffer[tr].Revards[i];
      Result.AssignArray(ActorResult);
      if(!Actor.backProp(Result, 0, NULL, 1, false,GetPointer(Account),GetPointer(Gradient)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         break;
        }
         if(GetTickCount() - ticks > 500)
           {
            string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Actor", 
                                       iter * 100.0 / (double)(Iterations),
                                       Actor.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

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

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


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

После завершения работы над советниками мы переходим к этапу обучения модели и тестирования полученных результатов. Мы не меняем параметры обучения модели. Как и ранее, модель обучается на исторических данных EURUSD тайм-фрейм H1. Параметры индикаторов используются по умолчанию. Наш агент был обучен на данных 4 месяцев 2023 года. Качество обучения и возможность работы Агента на новых данных мы проверили на интервале 1-18 июня 2023 года.

Результаты тестирования представлены на скриншотах ниже. Как можно заметить, в процессе тестирования модели удалось получить прибыль. На графике баланса есть этапы подъема и есть боковое движение. Радует отсутствие провалов. В целом, за 12 торговых дней профит фактор составил 2.2, а фактор восстановления 1.47. Советник совершил 220 сделок. Боле 53% из них были закрыты с прибылью. При этом средняя прибыльная позиция почти в 2 раза превышает среднюю убыточную. К сожалению, советник открывал только длинные позиции. С подобным эффектом мы уже сталкивались. И используемый подход не решил эту задачу.

График тестирования

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

Время удержания позиции

К положительным моментам использования метода GCRL можно отнести сокращение времени удержания позиции. В процессе тестирования максимальное время удержания позиции составило 21 час 15 минут. Среднее время удержания позиции 5 часов 49 минут. Напомню, что за невыполнение задачи закрытия позиции мы устанавливали штраф в размере 1/10 накопленной прибыли за каждый час удержания. То есть после 10 часов удержания штраф превышал доход от позиции.


Заключение

В данной статье мы познакомились с методом обучения с подкреплением, направленным на достижение локальных целей (Goal-conditioned reinforcement learning, GCRL). Особенностью данного метода является ввод локальных подзадач и вознаграждения за их достижение. Это позволяет нам разделить одну глобальную задачу на несколько более мелких и шаг за шагом идти к её достижению.

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

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

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

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

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

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


Ссылки

  • Variational Empowerment as Representation Learning for Goal-Based Reinforcement Learning
  • Нейросети — это просто (Часть 43): Освоение навыков без функции вознаграждения
  • Нейросети — это просто (Часть 44): Изучение навыков с учетом динамики
  • Нейросети — это просто (Часть 45): Обучение навыков исследования состояний

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

    # Имя Тип Описание
    1 Research.mq5 Советник Советник сбора примеров
    StudyActor.mq5  Советник Советник обучения агента
    3 Test.mq5 Советник Советник для тестирования модели
    4 Trajectory.mqh Библиотека класса Структура описания состояния системы
    5 FQF.mqh Библиотека класса Библиотека класса организации работы полностью параметризированной модели
    6 NeuroNet.mqh Библиотека класса Библиотека классов для создания нейронной сети
    7 NeuroNet.cl Библиотека Библиотека кода программы OpenCL
    8 VAE.mqh
    Библиотека класса
    Библиотека класса латентного слоя вариационного автоэнкодера
    Прикрепленные файлы |
    MQL5.zip (602.06 KB)
    Разработка системы репликации - Моделирование рынка (Часть 02): Первые эксперименты (II) Разработка системы репликации - Моделирование рынка (Часть 02): Первые эксперименты (II)
    В этот раз попробуем другой подход для достижения цели в 1 минуту. Однако эта задача не так проста, как можно подумать.
    Разработка системы репликации — моделирование рынка (Часть 01): Первые эксперименты (I) Разработка системы репликации — моделирование рынка (Часть 01): Первые эксперименты (I)
    Что вы думаете: создавать системы для изучения рынка, когда он закрыт, или создать систему, которая позволит моделировать рыночные ситуации? Здесь мы начнем новую серию статей, посвященных этому вопросу.
    Реализация алгоритма обучения ARIMA на MQL5 Реализация алгоритма обучения ARIMA на MQL5
    В этой статье мы реализуем алгоритм, который применяет интегрированную модель авторегрессии скользящей средней (модель Бокса-Дженкинса) с использованием метода минимизации функции Пауэллса. Бокс и Дженкинс утверждали, что большинство временных рядов можно смоделировать с помощью одной или обеих из двух структур.
    MQL5 — Вы тоже можете стать мастером этого языка MQL5 — Вы тоже можете стать мастером этого языка
    В этой статье я проведу нечто вроде интервью с самим собой и расскажу, как я делал свои первые шаги в языке MQL5. С помощью данного руководства я хочу помочь вам стать выдающимся программистом на MQL5, поэтому мы рассмотрим необходимые основы, чтобы достичь этого. Всё, что вам нужно иметь при себе - это искреннее желание учиться.