English 中文 Español Deutsch 日本語 Português
preview
Нейросети — это просто (Часть 35): Модуль внутреннего любопытства (Intrinsic Curiosity Module)

Нейросети — это просто (Часть 35): Модуль внутреннего любопытства (Intrinsic Curiosity Module)

MetaTrader 5Торговые системы | 6 декабря 2022, 16:13
2 228 7
Dmitriy Gizlyk
Dmitriy Gizlyk

Содержание


Введение

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

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

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

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


1. Любопытство - стремление к познанию

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

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

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

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

Именно такие подходы попытались применить авторы статьи "Curiosity-driven Exploration by Self-supervised Prediction" при построении своего алгоритма. Данная статья была представлена миру в мая 2017 года. В основе метода лежит формирования любопытства, как ошибки способности предсказания модели последствий своих действий. Тем самым повышая интерес к ранее несовершаемым действиям. В статье исследуются 3 больших задачи:

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

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

Основная новизна именно в архитектуре блока ICM, который и генерирует это внутренне вознаграждение. Модуль внутреннего любопытства содержит 3 отдельных модели:

  • Encoder
  • Inverse Model
  • Forward Model

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

  1. На которые агент оказывает влияние.
  2. Независимые от агента, но оказывающие на него влияние.
  3. Независимые от агента и не оказывающие на него влияние.

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

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

Модель прямого действия (Forward Model) обучается по кодированному текущему состоянию и совершенному действию спрогнозировать следующее состояние. Именно качество прогноза является мерой любопытства. И ошибка прогнозирования, посчитанная с помощью MSE, является внутренним вознаграждением.

Модуль внутреннего любопытства

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

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

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


2. Блок внутреннего любопытства средствами MQL5

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

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

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

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

Из этого следует необходимость создания буфера памяти воспроизведения опыта. О причинах создания подобного буфера мы говорили в статье "Нейросети — это просто (Часть 27): Глубокое Q-обучение (DQN)". Раннее в качестве буфера мы использовали всю историю инструмента за период обучения. Но добавления данных о состоянии счета на оставляет нам такой возможности. И мы будем реализовывать накопительный буфер опыта внутри программы.

Кроме того, мы позволим советнику открывать одновременно несколько позиций. В том числе и разнонаправленных. И вместе с этим изменим пространство допустимых действий агента. Мы дадим агенту возможность совершение 4 действий:

0 — покупка

1 — продажа

2 — закрытие всех открытых позиций

3 — пропуск хода, ожидание подходящего состояния.

А начнем мы нашу работу с создания буфера воспроизведения опыта.


2.1. Буфер воспроизведения опыта (Experience replay)

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

  • тензор описания состояния среды
  • совершаемое действие
  • получаемое внешнее вознаграждение

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

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

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

class CReplayState : public CObject
  {
protected:
   CBufferFloat      cState;
   int               iAction;
   double            dReaward;

public:
                     CReplayState(CBufferFloat *state, int action, double reward);
                    ~CReplayState(void) {};
   bool              GetCurrent(CBufferFloat *&state, int &action);
   bool              GetNext(CBufferFloat *&state, double &reward);
  };

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

CReplayState::CReplayState(CBufferFloat *state, int action, double reward)
  {
   cState.AssignArray(state);
   iAction = action;
   dReaward = reward;
  }

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

Добавим в наш класс ещё 2 метода для получения сохранённых данных GetCurrent и GetNext. В первом случае мы возвращаем состояние и действие. А во втором — действие и вознаграждение.

bool CReplayState::GetCurrent(CBufferFloat *&state, int &action)
  {
   action = iAction;
   double reward;
   return GetNext(state, reward);
  }

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

bool CReplayState::GetNext(CBufferFloat *&state, double &reward)
  {
   reward = dReaward;
   if(!state)
     {
      state = new CBufferFloat();
      if(!state)
         return false;
     }
   return state.AssignArray(GetPointer(cState));
  }

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

class CReplayBuffer : protected CArrayObj
  {
protected:
   uint              iMaxSize;
public:
                     CReplayBuffer(void) : iMaxSize(500) {};
                    ~CReplayBuffer(void) {};
   //---
   void              SetMaxSize(uint size)   {  iMaxSize = size; }
   bool              AddState(CBufferFloat *state, int action, double reward);
   bool              GetRendomState(CBufferFloat *&state1, int &action, double &reward, CBufferFloat*& state2);
   bool              GetState(int position, CBufferFloat *&state1, int &action, double &reward, CBufferFloat*& state2);
   int               Total(void) { return CArrayObj::Total(); }
  };

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

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

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

bool CReplayBuffer::AddState(CBufferFloat *state, int action, double reward)
  {
   if(!state)
      return false;
//---
   if(!Add(new CReplayState(state, action, reward)))
      return false;
   while(Total() > (int)iMaxSize)
      Delete(0);
//---
   return true;
  }

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

bool CReplayBuffer::GetRendomState(CBufferFloat *&state1, int &action, double &reward, CBufferFloat *&state2)
  {
   int position = (int)(MathRand() * MathRand() / pow(32767.0, 2.0) * (Total() - 1));
   return GetState(position, state1, action, reward, state2);
  }

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

Давайте вспомним как организован процесс Q-обучения. В основе обучения лежат 4 объекта данных:

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

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

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

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

bool CReplayBuffer::GetState(int position, CBufferFloat *&state1, int &action, double &reward, CBufferFloat *&state2)
  {
   if(position < 0 || position >= (Total() - 1))
      return false;
   CReplayState* element = m_data[position];
   if(!element || !element.GetCurrent(state1, action))
      return false;
   element = m_data[position + 1];
   if(!element.GetNext(state2, reward))
      return false;
//---
   return true;
  }

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


2.2. Модуль внутреннего любопытства (ICM)

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

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

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

Для реализации алгоритма мы создадим новый класс диспетчера нейронной сети CICM наследником нашего базового диспетчерского класса нейронной сети CNet. В теле данного класса мы добавим 3 внутренних переменных:

  • iMinBufferSize — минимальный размер буфера опыта для начала обучения моделей.
  • iStateEmbedingLayer — номер нейронного слоя в обучаемой модели, с которого будем считывать закодированное состояние среды. Это то нейронный слой, который завершает энкодер обучаемой модели.
  • dPrevBalance — переменная для записи последнего состояния баланса счета. Мы будем использовать её для определения внешнего вознаграждения.

Кроме того, мы объявим 4 внутренних объекта. Среди них один объект буфера накопления опыта и 3 объекта нейронных сетей cTargetNet, cInverseNet и cForwardNet.

Напомню, мы будем использовать Q-обучение и Target Net является одним из основных столпов данного метода обучения.

class CICM : protected CNet
  {
protected:
   uint              iMinBufferSize;
   uint              iStateEmbedingLayer;
   double            dPrevBalance;
   //---
   CReplayBuffer     cReplay;
   CNet              cTargetNet;
   CNet              cInverseNet;
   CNet              cForwardNet;

   virtual bool      AddInputData(CArrayFloat *inputVals);

public:
                     CICM(void);
                     CICM(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse);
   bool              Create(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse);
   int               feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true, bool sample = true); 
   bool              backProp(int batch, float discount = 0.9f);
   int               getAction(void);      
   int               getSample(void);
   float             getRecentAverageError() { return recentAverageError; }
   bool              Save(string file_name, bool common = true);
   bool              Save(string dqn, string forward, string invers, bool common = true);
   virtual bool      Load(string file_name, bool common = true);
   bool              Load(string dqn, string forward, string invers, uint state_layer, bool common = true);
   //---
   virtual int       Type(void)   const   {  return defICML;   }
   virtual bool      TrainMode(bool flag)
            { return (CNet::TrainMode(flag) && cForwardNet.TrainMode(flag) && cInverseNet.TrainMode(flag)); } 
   virtual bool      GetLayerOutput(uint layer, CBufferFloat *&result) 
     { return        CNet::GetLayerOutput(layer, result); }
   //---
   virtual bool      UpdateTarget(string file_name);
   virtual void      SetStateEmbedingLayer(uint layer) { iStateEmbedingLayer = layer; }
   virtual void      SetBufferSize(uint min, uint max);
  };

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

bool CICM::Create(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse)
  {
   if(!CNet::Create(Description))
      return false;
   if(!cForwardNet.Create(Forward))
      return false;
   if(!cInverseNet.Create(Inverse))
      return false;
   cTargetNet.Create(NULL);
//---
   return true;
  }

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

   virtual void      SetStateEmbedingLayer(uint layer) { iStateEmbedingLayer = layer; }

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

Мы изменили тип возвращаемого значения. Если ранее метод возвращал логическое значение выполнения операций метода и для получения результатов прямого прохода использовался метод CNet::getResults, что было связано с возвращением тензора результатов. То метод прямого прохода нового класса будет возвращать дискретное значение выбранного действия. При этом мы оставляем за пользователем выбора жадной стратегии или семплирование действия из вероятностного распределения. За это отвечает дополнительный параметр метода sample.

int CICM::feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true, bool sample = true)
  {
   if(!AddInputData(inputVals))
      return -1;
//---
   if(!CNet::feedForward(inputVals, window, tem))
      return -1;
   double balance = AccountInfoDouble(ACCOUNT_BALANCE);
   double reward = (dPrevBalance == 0 ? 0 : balance - dPrevBalance);
   dPrevBalance = balance;
   int action = (sample ? getSample() : getAction());
   if(!cReplay.AddState(inputVals, action, reward))
      return -1;
//---
   return action;
  }

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

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

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

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

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

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

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

bool CICM::backProp(int batch, float discount = 0.900000f)
  {
//---
   if(cReplay.Total() < (int)iMinBufferSize)
      return true;
   if(!UpdateTarget(TargetNetFile))
      return false;

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

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

   CLayer *currentLayer, *nextLayer, *prevLayer;
   CNeuronBaseOCL *neuron;
   CBufferFloat *state1, *state2, *targetVals = new CBufferFloat();
   vector<float> target, actions, st1, st2, result;
   double reward;
   int action;

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

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

//--- цикл обучения в размере batch
   for(int i = 0; i < batch; i++)
     {
      //--- получаем случайное состояние и реплай буфера
      if(!cReplay.GetRendomState(state1, action, reward, state2))
         return false;
      //--- прямой проход обучаемой моделм ("текущее" состояие)
      if(!CNet::feedForward(state1, 1, false))
         return false;

За успешным выполнением прямого прохода основной модели мы проведем подготовительную работу для запуска прямого прохода Forward Model. Здесь мы извлекаем эмбединг текущего состояния системы и создаём one-hot вектор совершенного действия.

      //--- выгружаем эмбединг состояния
      if(!GetLayerOutput(iStateEmbedingLayer, state1))
         return false;
      //--- подготавливаем one-hote вектор действия и конкатенируем с вектором текущего состояния
      getResults(target);
      actions = vector<float>::Zeros(target.Size());
      actions[action] = 1;
      if(!targetVals.AssignArray(actions) || !targetVals.AddArray(state1))
         return false;

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

      //--- прямой проход forward net - прогноз следующего состояния
      if(!cForwardNet.feedForward(targetVals, 1, false))
         return false;

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

      //--- прямой проход
      if(!cTargetNet.feedForward(state2, 1, false))
         return false;
      //--- выгружаем эмбединг состояния и соединяем с эмбедингом "текущего" состояния
      if(!cTargetNet.GetLayerOutput(iStateEmbedingLayer, state2))
         return false;

Полученные 2 эмбединга последовательных состояний мы объединяем в единый тензор и вызываем метод прямого прохода Inverse Model.

      //--- прямой проход inverse net - определение совершенного действия.
      if(!state1.AddArray(state2) || !cInverseNet.feedForward(state1, 1, false))
         return false;

И следом выполняем обратные проходы Forward Model и Inverse Model. Целевые значения для них мы уже подготовили ранее в виде эмбединга последующего состояния и one-hot вектором выполненного действия.

      //--- обратный проход inverse net
      if(!targetVals.AssignArray(actions) || !cInverseNet.backProp(targetVals))
         return false;
      //--- обратный проход forward net
      if(!cForwardNet.backProp(state2))
         return false;

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

      //--- корректировка вознаграждения
      cForwardNet.getResults(st1);
      state2.GetData(st2);
      reward += (MathPow(st2 - st1, 2)).Sum();
      cTargetNet.getResults(targetVals);
      target[action] = (float)(reward + discount * targetVals.Maximum());
      if(!targetVals.AssignArray(target))
         return false;

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

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

      //--- обратный проход обучаемой модели
        {
         getResults(result);
         float error = result.Loss(target, LOSS_MSE);
         //---
         currentLayer = layers.At(layers.Total() - 1);
         if(CheckPointer(currentLayer) == POINTER_INVALID)
            return false;
         neuron = currentLayer.At(0);
         if(!neuron.calcOutputGradients(targetVals, error))
            return false;
         //---
         backPropCount++;
         recentAverageError += (error - recentAverageError) / fmin(recentAverageSmoothingFactor, (float)backPropCount);

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

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

         //--- Calc Hidden Gradients
         int total = layers.Total();
         for(int layerNum = total - 2; layerNum >= 0; layerNum--)
           {
            nextLayer = currentLayer;
            currentLayer = layers.At(layerNum);
            neuron = currentLayer.At(0);
            if(!neuron.calcHiddenGradients(nextLayer.At(0)))
               return false;

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

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

Для сложения 2 тензоров я воспользовался ранее созданным кернелом MatrixSum. Подробное описание его алгоритма можно найти в статье "Нейросети — это просто (Часть 8): Механизмы внимания".

            if(layerNum == iStateEmbedingLayer)
              {
               CLayer* temp = cInverseNet.layers.At(0);
               CNeuronBaseOCL* inv = temp.At(0);
               uint global_work_offset[1] = {0};
               uint global_work_size[1];
               global_work_size[0] = neuron.Neurons();
               opencl.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix1, neuron.getGradientIndex());
               opencl.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix2, inv.getGradientIndex());
               opencl.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix_out, neuron.getGradientIndex());
               opencl.SetArgument(def_k_MatrixSum, def_k_sum_dimension, 1);
               opencl.SetArgument(def_k_MatrixSum, def_k_sum_multiplyer, 1);
               if(!opencl.Execute(def_k_MatrixSum, 1, global_work_offset, global_work_size))
                 {
                  printf("Error of execution kernel MatrixSum: %d", GetLastError());
                  return false;
                 }
              }
           }

Для корректного выполнения данного действия следует обратить внимание на 2 момента.

Первое, метод обратного прохода инверсной модели должен передать градиент ошибки на слой исходных данных. А для этого в цикле распределения градиента через скрытые слои должно стоять условие «layerNum >= 0».

         //--- Calc Hidden Gradients
         int total = layers.Total();
         for(int layerNum = total - 2; layerNum >= 0; layerNum--)
           {

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

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

         //---
         prevLayer = layers.At(total - 1);
         for(int layerNum = total - 1; layerNum > 0; layerNum--)
           {
            currentLayer = prevLayer;
            prevLayer = layers.At(layerNum - 1);
            neuron = currentLayer.At(0);
            if(!neuron.UpdateInputWeights(prevLayer.At(0)))
               return false;
           }
         //---
         for(int layerNum = 0; layerNum < total; layerNum++)
           {
            currentLayer = layers.At(layerNum);
            CNeuronBaseOCL *temp = currentLayer.At(0);
            if(!temp.TrainMode())
               continue;
            if((layerNum + 1) == total && !temp.getGradient().BufferRead())
               return false;
            break;
           }
        }
     }

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

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

   delete state1;
   delete state2;
   delete targetVals;
//---
   return true;
  }

Несколько слов скажу о методах работы с файлами. Так как мы используем в данном алгоритме несколько моделей, то перед нами становится резонный вопрос о способе сохранения обученных моделей. И тут я вижу 2 варианта. Мы можем сохранить все модели в один файл. А можем сохранить каждую модель в отдельном файле. Я предлагаю сохранять модели в отдельных файлах, так как это даёт больше свободы действий. Обученную таким образом DQN-модель мы можем выгрузить в отдельный файл и далее использовать её наравне с рассмотренными ранее моделями. А можем загрузить все 3 модели и использовать описанный в данной статье метод. Единственное неудобство доставляет необходимость каждый раз указывать слой эмбединга состояния в основной модели. Зато мы можем в процессе обучения экспериментировать с архитектурой каждой отдельной модели для достижения оптимальных результатов.

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


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

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

Для тестирования работы модели был создан советник «ICM-learning.mq5». Для описания рыночной ситуации мы использовали те же индикаторы с аналогичными параметрами. Поэтому внешние параметры советника остались практически без изменений. То же можно сказать и об объявлении глобальных переменных и классов.

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

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

void OnTick()
  {
   if(!IsNewBar())
      return;

Далее мы загружаем исторические данные в размере анализируемого окна.

   int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), HistoryBars, Rates);
   if(!ArraySetAsSeries(Rates, true))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return;
     }
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();

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

   State1.Clear();
   for(int b = 0; b < (int)HistoryBars; b++)
     {
      float open = (float)Rates[b].open;
      TimeToStruct(Rates[b].time, sTime);
      float rsi = (float)RSI.Main(b);
      float cci = (float)CCI.Main(b);
      float atr = (float)ATR.Main(b);
      float macd = (float)MACD.Main(b);
      float sign = (float)MACD.Signal(b);
      if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
         continue;
      //---
      if(!State1.Add((float)Rates[b].close - open) || !State1.Add((float)Rates[b].high - open) ||
         !State1.Add((float)Rates[b].low - open) || !State1.Add((float)Rates[b].tick_volume / 1000.0f) ||
         !State1.Add(sTime.hour) || !State1.Add(sTime.day_of_week) || !State1.Add(sTime.mon) ||
         !State1.Add(rsi) || !State1.Add(cci) || !State1.Add(atr) || !State1.Add(macd) || !State1.Add(sign))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }
     }

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

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

   switch(StudyNet.feedForward(GetPointer(State1), 12, true, true))
     {
      case 0:
         Trade.Buy(Symb.LotsMin(), Symb.Name());
         break;
      case 1:
         Trade.Sell(Symb.LotsMin(), Symb.Name());
         break;
      case 2:
         for(int i=PositionsTotal()-1;i>=0;i--)
            if(PositionGetSymbol(i)==Symb.Name())
              Trade.PositionClose(PositionGetInteger(POSITION_IDENTIFIER));
         break;
     }

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

В завершение метода мы вызываем метод обратного прохода модели.

   StudyNet.backProp(Batch, DiscountFactor);
//---
  }

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

void OnDeinit(const int reason)
  {
//---
   StudyNet.Save(FileName + ".nnw", FileName + ".fwd", FileName + ".inv", true);
  }

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

void OnTesterPass()
  {
   StudyNet.Save(FileName + ".nnw", FileName + ".fwd", FileName + ".inv", true);
  }

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

Для обучения советника все модели были созданы с помощью инструмента NetCreator. Надо добавить, что для работы советника в тестере стратегий файлы моделей должны находиться в общем каталоге терминалом «Terminal\Common\Files», так как каждый агент работает в собственной «песочнице» и обмен данными возможен только через общую папку терминалов.

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

Честно говоря, я ожидал, что процесс обучения начнется со слива депозита. Но при первом проходе модель продемонстрировала результат близкий к «0». А при повторном проходе была получена даже прибыль. Модель совершила 330 трейдов с результативностью более 98% прибыльных операций.

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


Заключение

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

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

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


Ссылки

  1. Нейросети — это просто (Часть 26): Обучение с подкреплением
  2. Нейросети — это просто (Часть 27): Глубокое Q-обучение (DQN)
  3. Нейросети — это просто (Часть 28): Policy gradient алгоритм
  4. Нейросети — это просто (Часть 32): Распределенное Q-обучение
  5. Нейросети — это просто (Часть 33): Квантильная регрессия в распределенном Q-обучении
  6. Нейросети — это просто (Часть 34): Полностью параметризированная квантильная функция
  7. Curiosity-driven Exploration by Self-supervised Prediction

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

# Имя Тип Описание
1 ICM-learning.mq5 Советник Советник для обучения модели. 
2 ICM.mqh Библиотека класса Библиотека класса организации работы модели
3 NeuroNet.mqh Библиотека класса Библиотека классов для создания нейронной сети
4 NeuroNet.cl Библиотека Библиотека кода программы OpenCL


Прикрепленные файлы |
MQL5.zip (106.2 KB)
Последние комментарии | Перейти к обсуждению на форуме трейдеров (7)
Zhongquan Jiang
Zhongquan Jiang | 12 дек. 2022 в 04:36

Hi Thank you for your awesome article.

I am eager to do some testing, but I have difficulty in creating the models even you mentioned " To train the Expert Advisor, all models were created using the    NetCreator   tool .  "

What should I do? thanks.

Kekeletso Mofokeng
Kekeletso Mofokeng | 29 дек. 2022 в 23:54

I don't know what damages the file

Please help 

Thanks

Gergely Szabó
Gergely Szabó | 10 янв. 2023 в 11:56

Very successful article. Could you possibly help me to create the models? neuron numbers, which layers, with which activation, with which outputs. Thank you in advance for your help.

star-ik
star-ik | 28 февр. 2023 в 20:38
В предыдущих статьях вы описывали создание моделей, слои и их характеристики. А как быть в этом случае?
star-ik
star-ik | 1 мар. 2023 в 07:52
Автор пропал. Может, кто из форумчан сумел запустить этого зверя? Поделитесь, будьте добры, информацией, какие слои создавать для моделей.
Разработка торговой системы на основе индикатора Alligator Разработка торговой системы на основе индикатора Alligator
Это новая статья из серии, в которой мы учимся создавать торговые системы по показателям самых популярных технических индикаторов. В ней мы будем изучать индикатор Alligator, а также создадим на его основе торговые системы.
Машинное обучение и Data Science (Часть 07): Полиномиальная регрессия Машинное обучение и Data Science (Часть 07): Полиномиальная регрессия
Полиномиальная регрессия — это гибкая модель, предназначенная для эффективного решения задач, с которыми не справляется модель линейной регрессии. В этой статье узнаем, как создавать полиномиальные модели на MQL5 и извлекать из них выгоду.
Популяционные алгоритмы оптимизации: Алгоритм оптимизации с кукушкой (Cuckoo Optimization Algorithm — COA) Популяционные алгоритмы оптимизации: Алгоритм оптимизации с кукушкой (Cuckoo Optimization Algorithm — COA)
Следующий алгоритм, который рассмотрим — оптимизация поиском кукушки с использованием полётов Леви. Это один из новейших алгоритмов оптимизации и новый лидер в рейтинговой таблице.
Популяционные алгоритмы оптимизации: Оптимизация Стаей Серых Волков (Grey Wolf Optimizer - GWO) Популяционные алгоритмы оптимизации: Оптимизация Стаей Серых Волков (Grey Wolf Optimizer - GWO)
Рассмотрим один из новейших современных алгоритмов оптимизации "Стаи серых волков". Оригинальное поведение на тестовых функциях делает этот алгоритм одним из самых интересных среди рассмотренных ранее. Один из лидеров для применения в обучении нейронных сетей, гладких функций с многими переменными.