English 中文 Español Deutsch 日本語 Português
preview
Нейросети — это просто (Часть 54): Использование случайного энкодера для эффективного исследования (RE3)

Нейросети — это просто (Часть 54): Использование случайного энкодера для эффективного исследования (RE3)

MetaTrader 5Торговые системы | 16 августа 2023, 15:06
1 353 2
Dmitriy Gizlyk
Dmitriy Gizlyk

Введение

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

Однако, для оценки новизны действий и посещаемых состояний нам приходилось обучать дополнительные модели. Важно отметить, что понятие "новизны действий" не всегда совпадает с полнотой и равномерностью исследования окружающей среды. И в этом аспекте наиболее привлекательными выглядят методы на основе оценки энтропии действий и состояний. Но они накладывают свои ограничения на обучаемые модели. Использование энтропии требует определенного представления о вероятностях совершения действий и переходов в новые состояния, что в случае непрерывного пространства действий и состояний может быть достаточно сложным для прямого вычисления. В поисках более простых и эффективных методов я предлагаю Вам познакомиться с алгоритмом Random Encoders for Efficient Exploration (RE3), который был представлен в статье "State Entropy Maximization with Random Encoders for Efficient Exploration".


1. Основная идея RE3

Анализируя реальные кейсы с непрерывным пространством действий и состояний, мы сталкиваемся с ситуацией, когда каждая пара состояние-действие на обучающей выборке встречается только 1 раз. Шансы наблюдать идентичное состояние в будущем близки к "0". И мы приходим к поиску методов группировки близких (схожих) состояний и действий. Что приводит к обучению дополнительных моделей. Например, в методе BAC мы обучали автоэнкодер для оценки новизны состояний и действий.

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

Основная цель метода Random Encoders for Efficient Exploration (RE3) лежит в минимизации количества обучаемых моделей. В своей работе авторы метода RE3 обращают внимание, что в области обработки изображения только сверточные сети способны выделять отдельные признаки и характеристики объекта. Именно сверточные сети помогут понизить размерность многомерного пространства, выделить характерные особенности и справиться с масштабированием исходного объекта.

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

В этом аспекте ключевым является слово "обучаемых". Авторы метода обратили внимание на тот факт, что даже инициализированный случайными параметрами сверточный кодировщик эффективно улавливает информацию о близости двух состояний. Ниже представлена визуализация k-ближайших состояний, найденных путем измерения расстояний в пространстве представления случайно инициализированного кодировщика (Random Encoder) и в пространстве истинного состояния (True State), из авторской статьи.

Визуализация k-ближайших состояний

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

Метод случайного кодировщика для эффективного исследования (Random Encoders for Efficient Exploration, RE3), стимулирует исследование в пространствах высокой размерности наблюдений путем максимизации энтропии состояния. Основная идея RE3 заключается в оценке энтропии с использованием оценки k ближайших соседей в пространстве низкой размерности, полученной с помощью случайно инициализированного кодировщика.

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

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

В этом случае внутреннее вознаграждение пропорционально оценке энтропии состояния и определяется по формуле:

где yi - представление состояния в пространстве случайного кодировщика.

В представленной формуле внутреннего вознаграждения мы используем L2 норму расстояния, которая всегда неотрицательная. Увеличение нормы на "1" позволяет получить всегда неотрицательное значение логарифма. Таким образом, мы получаем всегда неотрицательное внутреннее вознаграждение. Кроме того, легко заметить, что при достаточном количестве близких состояний внутреннее вознаграждение близко к "0".

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

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

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

где β — коэффициент температуры определяющий баланс между исследованием и эксплуатацией (β≥0).

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

где p — это скорость убывания.

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

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

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

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

В статье "State Entropy Maximization with Random Encoders for Efficient Exploration" представлены результаты различных тестов, демонстрирующих эффективность метода. Ну а мы реализуем свой вариант предложенного алгоритма и оценим его эффективность для решения наших задач.


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

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

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

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

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

//--- Actor
   actor.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(!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;
     }
//--- 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 = 8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count;
   descr.window = 8;
   descr.step = 8;
   descr.window_out = 8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = 128;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.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 = AccountDescr;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 7
   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 8
   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 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NActions;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 10
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

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

//+------------------------------------------------------------------+
//| Rewards structure                                                |
//|   0     -  Delta Balance                                         |
//|   1     -  Delta Equity ( "-" Drawdown / "+" Profit)             |
//|   2     -  Penalty for no open positions                         |
//|   3     -  Mean distance                                         |
//+------------------------------------------------------------------+

В результате мы получим следующую архитектуру Критика.

//--- Critic
   critic.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = LatentCount;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = NActions;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!critic.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(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = NRewards;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }

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

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

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

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 512;
   descr.window = prev_count;
   descr.step = NActions;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!convolution.Add(descr))
     {по
      delete descr;
      return false;
     }

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

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = 512 / 8;
   descr.window = 8;
   descr.step = 8;
   int prev_wout = descr.window_out = 2;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = (prev_count * prev_wout) / 4;
   descr.window = 4;
   descr.step = 4;
   prev_wout = descr.window_out = 2;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = (prev_count * prev_wout) / 4;
   descr.window = 4;
   descr.step = 4;
   prev_wout = descr.window_out = 2;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
      delete descr;
      return false;
     }

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

//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = EmbeddingSize;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

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

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

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

Из данной ситуации я вижу 2 выхода:

  • предварительное создание и сохранение моделей перед первым запуском советника "...\RE3\Research.mq5"
  • генерация кодировщика и кодирование представлений в теле советника обучения моделей  "...\RE3\Study.mq5".

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

Далее мы переходим к работе над советником обучения модели "...\RE3\Study.mq5". Здесь мы создаем объекты для 6 моделей, а обучать будем только 3 из них. Для целевых моделей применяем мягкое обновление параметров с использованием коэффициента ꚍ.

CNet                 Actor;
CNet                 Critic1;
CNet                 Critic2;
CNet                 TargetCritic1;
CNet                 TargetCritic2;
CNet                 Convolution;

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

int OnInit()
  {
//---
   ResetLastError();
   if(!LoadTotalBase())
     {
      PrintFormat("Error of load study data: %d", GetLastError());
      return INIT_FAILED;
     }
//--- load models
   float temp;
   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) ||
      !Convolution.Load(FileName + "CNN.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))
     {
      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;

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

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

//---
   OpenCL = Actor.GetOpenCL();
   Critic1.SetOpenCL(OpenCL);
   Critic2.SetOpenCL(OpenCL);
   TargetCritic1.SetOpenCL(OpenCL);
   TargetCritic2.SetOpenCL(OpenCL);
   Convolution.SetOpenCL(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;
     }

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

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

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

void OnDeinit(const int reason)
  {
//---
   TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
   TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
   Actor.Save(FileName + "Act.nnw", 0, 0, 0, TimeCurrent(), true);
   TargetCritic1.Save(FileName + "Crt1.nnw", Critic1.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   TargetCritic2.Save(FileName + "Crt2.nnw", Critic2.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   Convolution.Save(FileName + "CNN.nnw", 0, 0, 0, TimeCurrent(), true);
   delete Result;
  }

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

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

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;
   Convolution.getResults(temp);
   matrix<float> state_embedding = matrix<float>::Zeros(total_states,temp.Size());
   matrix<float> rewards = matrix<float>::Zeros(total_states,NRewards);

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

   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,0)].account[0];
         float PrevEquity = Buffer[tr].States[MathMax(st,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(Buffer[tr].States[st].action);

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

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

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

         Convolution.getResults(temp);
         state_embedding.Row(temp,state);
         temp.Assign(Buffer[tr].States[st].rewards);
         rewards.Row(temp,state);
         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);
      state_embedding.Reshape(state,state_embedding.Cols());
      total_states = state;
     }

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

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

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

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

      vector<float> reward, target_reward = vector<float>::Zeros(NRewards);
      reward.Assign(Buffer[tr].States[i].rewards);
      //--- 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();

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

  • Критики не имеют блока предварительной обработки исходных данных (ни используют латентное представление Актера);
  • Целевая  модель Критика оценивает последующее состояние в свете использования текущей политики Актера (необходима генерация нового вектора действий).

Поэтому, мы сначала осуществляем прямой проход Актера.

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

А потом вызываем методы прямого прохода 2 моделей целевых Критиков.

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

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

         TargetCritic1.getResults(rewards1);
         TargetCritic2.getResults(rewards2);
         if(rewards1.Sum() <= rewards2.Sum())
            target_reward = rewards1;
         else
            target_reward = rewards2;

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

         for(ulong r = 0; r < target_reward.Size(); r++)
            target_reward -= Buffer[tr].States[i + 1].rewards[r];
         target_reward *= DiscFactor;
        }

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

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

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

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

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

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

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

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

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

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

Далее нам необходимо сформировать целевые значения и осуществить обратный проход Критиков. Мы уже неоднократно выполняли подобные операции. Обычно, на этом этапе мы корректировали фактическое вознаграждение из буфера воспроизведения опыта на влияние измененной политики и передавали полученное значение в качестве целевого для обоих моделей Критиков. Но в данной реализации мы используем декомпозированное вознаграждение. И в прошлой статье мы использовали алгоритм Conflict-Averse Gradient Descent (CAGrad) для корректировки градиента ошибки. Тогда мы корректировали отклонение значений в методе CNet_SAC_D_DICE::CAGrad и сохраняли полученные значения непосредственно в буфер градиентов ошибки нейронного слоя результатов. Сейчас у нас нет возможности прямого обращения к буферу градиентов последнего нейронного слоя моделей и нам нужны целевые значения.

Для получения целевых значений, скорректированных по методу Conflict-Averse Gradient Descent, мы совершим небольшой маневр данными. Сначала мы формируем целевые значения из имеющихся данных. Затем вычтем из них прогнозные значения Критика, получив тем самым отклонение (ошибку). Скорректируем полученное отклонение с использованием уже знакомого метода CAGrad. И к полученному результату прибавим прогнозное значение Критика, которое вычитали ранее.

Таким образом мы получили целевое значение, скорректированное по методу Conflict-Averse Gradient Descent. Однако, такое целевое значение актуально только для одной модели Критика. Для второй модели Критика нам придется повторить операции, но уже с учетом его прогнозных значений.

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

      Critic1.getResults(rewards1);
      Result.AssignArray(CAGrad(reward + 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(reward + 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;
        }

За обновлением параметров Критиков идет блок обновления политики Актера. В соответствии с алгоритмом Soft Actor-Critic для обновления параметров Актера используется Критик с минимальной оценкой состояния. Мы же будем использовать Критика с минимальной средней ошибкой, что потенциально даст более корректную передачу градиента ошибки.

      //--- Policy study
      CNet *critic = NULL;
      if(Critic1.getRecentAverageError() <= Critic2.getRecentAverageError())
         critic = GetPointer(Critic1);
      else
         critic = GetPointer(Critic2);

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

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

      Actor.getResults(rewards1);
      State.AddArray(GetPointer(Account));
      State.AddArray(rewards1);
      if(!critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
         !Convolution.feedForward(GetPointer(State)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

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

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

      Convolution.getResults(rewards1);
      critic.getResults(reward);
      reward += CAGrad(KNNReward(7,rewards1,state_embedding,rewards) - reward);
      //---
      Result.AssignArray(reward + target_reward);

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

      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
      TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
      TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
      //---
      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());
         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());
   ExpertRemove();
//---
  }

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

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

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

vector<float> KNNReward(ulong k, vector<float> &embedding, matrix<float> &state_embedding, matrix<float> &rewards)
  {
   if(embedding.Size() != state_embedding.Cols())
     {
      PrintFormat("%s -> %d Inconsistent embedding size", __FUNCTION__, __LINE__);
      return vector<float>::Zeros(0);
     }

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

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

   ulong size = embedding.Size();
   ulong states = state_embedding.Rows();
   ulong rew_size = rewards.Cols();
   matrix<float> temp = matrix<float>::Zeros(states,size);
//---
   for(ulong i = 0; i < size; i++)
      temp.Col(MathPow(state_embedding.Col(i) - embedding[i],2.0f),i);

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

   temp.Col(MathSqrt(temp.Sum(1)),0);

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

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

   temp.Resize(states,1 + rew_size);
   for(ulong i = 0; i < rew_size; i++)
      temp.Col(rewards.Col(i),i + 1);

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

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

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

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

   matrix<float> min_dist = temp;
   min_dist.Resize(k,rew_size + 1);
   float max = min_dist.Col(0).Max();
   ulong max_row = min_dist.Col(0).ArgMax();
   for(ulong i = k; i < states; i++)
     {
      if(temp[i,0] >= max)
         continue;
      min_dist.Row(temp.Row(i),max_row);
      max = min_dist.Col(0).Max();
      max_row = min_dist.Col(0).ArgMax();
     }

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

   vector<float> t = vector<float>::Ones(k);
   vector<float> ri = MathLog(min_dist.Col(0) + 1.0f);

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

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

   t = (t - ri) / k;
//---
   vector<float> result = vector<float>::Zeros(rew_size);
   for(ulong i = 0; i < rew_size - 1; i++)
      result[i] = (t * min_dist.Col(i + 1)).Sum();

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

   result[rew_size - 1] = ri.Mean();
//---
   return (result);
  }

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

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


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

Представленную выше реализацию, наверное, сложно назвать методом Random Encoders for Efficient Exploration (RE3) в чистом виде. Тем не менее, мы использовали основные подходы данного алгоритма и дополнили их своим видением ранее изученных алгоритмов. И пришло время оценить результаты наших трудов на реальных исторических данных.

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

Ещё раз повторюсь, что процесс обучения моделей итерационный. Сначала мы в тестере стратегий запускаем советник взаимодействия с окружающей средой и сбора обучающих примеров "...\RE3\Research.mq5".


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

Собранные обучающие примеры используются советником обучения моделей "...\RE3\Study.mq5" в процессе обучения Критиков и Актера.

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

При подготовке статьи мне удалось обучить политику Актера, способную генерировать прибыль на обучающей выборке. На обучающей выборке советник показал впечатляющие 83% прибыльных сделок. Хотя должен признать, что количество совершаемых торговых операций очень мало. За 5 месяцев обучающего периода мой Актер совершил всего 6 сделок. И только одна из них была закрыта с относительно небольшим убытком в размере 18.62 USD. При этом средняя прибыльная сделка составила 114.96 USD. Как следствие, профит фактор превысил отметку 30, а фактор восстановления составил 4.62.

Результаты обучения модели Результаты обучения модели

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


Заключение

В данной статье мы познакомились с методов Random Encoders for Efficient Exploration (RE3), который представляет собой эффективный подход к исследованию окружающей среды в контексте обучения с подкреплением. Этот метод призван решить проблему эффективного исследования сложных окружающих пространств, которая является одной из главных трудностей в области глубокого обучения с подкреплением.

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

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

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


Ссылки


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

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


Прикрепленные файлы |
MQL5.zip (421.06 KB)
Последние комментарии | Перейти к обсуждению на форуме трейдеров (2)
Ivan Butko
Ivan Butko | 16 авг. 2023 в 23:31
Не читал статью, но 5 сделок за 5 месяцев - это уже как минимум странный результат для современной технологии
JimReaper
JimReaper | 19 авг. 2023 в 12:43
@Dmitriy Gizlyk Dmtry__1.PNG (1916×320) (mql5.com)

Dmtry__1.PNG (1916×320) (mql5.com)
I was able to make 600+ trades for those 5 months by modifying the reward funtion and added some neurons,


Dmtry__1.PNG (1916×320) (mql5.com)

Thanks a lot! We love you! From the Philippines! <3
Dmtry__1.PNG (1916×320) (mql5.com)
Нейросети — это просто (Часть 55): Контрастный внутренний контроль (CIC) Нейросети — это просто (Часть 55): Контрастный внутренний контроль (CIC)
Контрастное обучение (Contrastive learning) - это метод обучения представлению без учителя. Его целью является обучение модели выделять сходства и различия в наборах данных. В данной статье мы поговорим об использовании подходов контрастного обучения для исследования различных навыков Актера.
Нейросети — это просто (Часть 53): Декомпозиция вознаграждения Нейросети — это просто (Часть 53): Декомпозиция вознаграждения
Мы уже не раз говорили о важности правильного подбора функции вознаграждения, которую используем для стимулирования желательного поведения Агента, добавляя вознаграждения или штрафы за отдельные действия. Но остается открытым вопрос о дешифровке наших сигналов Агентом. В данной статье мы поговорим о декомпозиции вознаграждения в части передачи отдельных сигналов обучаемому Агенту.
Брутфорс-подход к поиску закономерностей (Часть VI): Циклическая оптимизация Брутфорс-подход к поиску закономерностей (Часть VI): Циклическая оптимизация
В этой статье я покажу первую часть доработок, которые позволили мне не только замкнуть всю цепочку автоматизации для торговли в MetaTrader 4 и 5, но и сделать что-то гораздо интереснее. Отныне данное решение позволяет мне полностью автоматизировать как процесс создания советников, так и процесс оптимизации, а также минимизировать трудозатраты на поиск эффективных торговых конфигураций.
Простая торговая стратегия возврата к среднему Простая торговая стратегия возврата к среднему
Возврат к среднему - это метод контртрендовой торговли, при котором трейдер ожидает, что цена вернется к некоторой форме равновесия, которое обычно измеряется средним значением или другим статистическим показателем усредненной тенденции.