English 中文 Español Deutsch 日本語 Português
preview
Нейросети — это просто (Часть 32): Распределенное Q-обучение

Нейросети — это просто (Часть 32): Распределенное Q-обучение

MetaTrader 5Торговые системы | 8 ноября 2022, 14:00
1 641 3
Dmitriy Gizlyk
Dmitriy Gizlyk

Введение

В статье "Нейросети — это просто (Часть 27): Глубокое Q-обучение (DQN)" мы уже познакомились с методом Q-обучения. Напомню, тогда мы аппроксимировали Q-функцию, которая представляет собой функцию зависимости вознаграждения от состояния системы и совершенного действия. Но проблема заключается в том, что в реальный мир многогранен. При оценке текущего состояния мы не всегда можем учесть все влияющие факторы. И, как следствие, отсутствует прямая взаимосвязь между оцениваемыми параметрами описания состояния системы, совершаемым действием и получаемы вознаграждением. В результате аппроксимации Q-функции мы лишь получаем усредненное наиболее вероятное значение ожидаемого вознаграждения. При этом мы не видим всего распределения вознаграждений, получаемых в процессе обучения модели. В то же время, среднее значение подвержено искажению в результате значительных резких выпадов. В 2017 году были представлены 2 статьи, в которых авторы предложили алгоритмы изучения распределения значений получаемого вознаграждения. В обоих статьях, авторам удалось значительно улучшить результаты классического Q-обучения в компьютерных играх Atari.


1. Особенности распределенного Q-обучение

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

Для определения квантилей вводятся дополнительные гиперпараметры: минимальное (Vmin), максимальное (Vmax) значение диапазона ожидаемых вознаграждений и количество квантилей (N). Тогда диапазон значений одного квантиля рассчитывается по формуле.

В отличие от оригинального метода Q-обучения, которое аппроксимировало натуральное значение вознаграждение, алгоритм распределенного Q-обучения аппроксимирует вероятностное распределение получения вознаграждения в пределах квантиля при совершении определенного действия в конкретном состоянии. Именно перевод задачи в плоскость вероятностного распределения позволяет переквалифицировать задачу аппроксимации Q-Функции в стандартную задачу классификации. А из этого следует изменение функции потерь. Если в оригинальном Q-обучении используется среднеквадратическое отклонение в качестве функции потерь. То в методе распределенного Q-обучения мы будем использовать LogLoss. Мы уже познакомились с этой функцией при изучении Policy Gradient.

LogLoss

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

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

Здесь следует обратить внимание на тот момент, что при совершении агентом любого из возможных действий среда обязательно нам даст вознаграждение. Следовательно, для любого действия агента из текущего состояния среды мы со 100% вероятностью ожидаем получит вознаграждение. И сумма вероятностей по каждому действию агента должна давать "1". Добиться такого результата нам поможет использование функции SoftMax в разрезе возможных действий.

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

Обучение модели строится на принципах оригинального Q-обучения. И в основе процесса лежит уже известное уравнение Беллмана.

Уравнение Беллмана

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

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

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

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

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

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

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

Обобщим вышесказанное.

  1. Метод распределенного Q-обучение строится на базе классического Q-обучения и дополняет его.
  2. В качестве модели используется нейронная сеть.
  3. В процессе обучения модели мы аппроксимируем вероятностное распределение ожидаемого вознаграждения за переход в новое состояния в зависимости от пары "состояние — действие".
  4. Распределение представляется набором квантилей фиксированного диапазона вознаграждения.
  5. Количество квантилей и диапазон возможных значений определяются гиперпараметрами.
  6. Распределение по каждому возможному действию представляется одинаковым вектором вероятностей.
  7. Для нормализации вероятностного распределения мы используем функцию SoftMax в разрезе каждого отдельного действия.
  8. Обучение модели осуществляется на базе уравнения Беллмана.
  9. Вероятностный подход к решению задачи требует использование LogLoss в качестве функции потерь.
  10. Для стабилизации процесса обучения используются эвристики оригинального алгоритма Q-обучения (Target Net, буфер воспроизведения опыта).

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


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

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

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

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

В данном случае у нас есть 2 варианта. Мы можем создать новый класс или модернизировать существующий. Я решил воспользоваться вторым вариантом. Напомню структуру ранее созданного класса.

class CNeuronSoftMaxOCL    :  public CNeuronBaseOCL
  {
protected:
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return true; }

public:
                     CNeuronSoftMaxOCL(void) {};
                    ~CNeuronSoftMaxOCL(void) {};
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);   
   virtual bool      calcOutputGradients(CArrayFloat *Target, float& error) override;
   //---
   virtual int       Type(void) override  const   {  return defNeuronSoftMaxOCL; }
  };

Первым делом мы добавим переменную для хранения количества нормализуемых векторов iHeads и метод указания данного параметра SetHeads. По умолчанию укажем 1 вектор, что соответствует нормализации данных в рамках всего слоя.

class CNeuronSoftMaxOCL    :  public CNeuronBaseOCL
  {
protected:
   uint              iHeads;
.........
.........
public:
                     CNeuronSoftMaxOCL(void) : iHeads(1) {};
                    ~CNeuronSoftMaxOCL(void) {};
.........
.........
   virtual void      SetHeads(int heads)  { iHeads = heads; }
.........
.........
  };

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

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

SoftMax

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

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

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

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

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

__kernel void SoftMax_FeedForward(__global float *inputs,
                                  __global float *outputs,
                                  const uint total)
  {
   uint i = (uint)get_global_id(0);
   uint l = (uint)get_local_id(0);
   uint h = (uint)get_global_id(1);
   uint ls = min((uint)get_local_size(0), (uint)256);
   uint shift_head = h * total;

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

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

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

   __local float temp[256];
   uint count = 0;
   if(l < 256)
      do
        {
         uint shift = shift_head + count * ls + l;
         temp[l] = (count > 0 ? temp[l] : 0) + (shift < ((h + 1) * total) ? exp(inputs[shift]) : 0);
         count++;
        }
      while((count * ls + l) < total);
   barrier(CLK_LOCAL_MEM_FENCE);

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

   count = ls;
   do
     {
      count = (count + 1) / 2;
      if(l < 256)
         temp[l] += (l < count && (l + count) < total ? temp[l + count] : 0);
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);
//---
   float sum = temp[0];

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

   if(sum != 0)
     {
      count = 0;
      while((count * ls + l) < total)
        {
         uint shift = shift_head + count * ls + l;
         if(shift < ((h + 1) * total))
            outputs[shift] = exp(inputs[shift] / 10) / (sum + 1e-37f);
         count++;
        }
     }
  }

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

__kernel void SoftMax_HiddenGradient(__global float* outputs,
                                     __global float* output_gr,
                                     __global float* input_gr)
  {
   size_t i = get_global_id(0);
   size_t outputs_total = get_global_size(0);
   size_t h = get_global_id(1);
   uint shift = h * outputs_total;
   float output = outputs[shift + i];
   float result = 0;
   for(int j = 0; j < outputs_total ; j++)
      result += outputs[shift + j] * output_gr[shift + j] * ((float)(i == j) - output);
   input_gr[shift + i] = result;
  }

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

__kernel void SoftMax_OutputGradient(__global float* outputs,
                                     __global float* targets,
                                     __global float* output_gr)
  {
   size_t i = get_global_id(0);
   output_gr[i] = targets[i] / (outputs[i] + 1e-37f);
  }

На этом мы заканчиваем работу на стороне OpenCL программы и возвращаемся к коду нашего класса CNeuronSoftMaxOCL. Первым мы вносили изменения в кернел прямого прохода. В аналогичном порядке внесем изменения в методы нашего класса.

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

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

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

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

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

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

bool CNeuronSoftMaxOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!OpenCL || !NeuronOCL)
      return false;
   uint global_work_offset[2] = {0, 0};
   uint size = Output.Total() / iHeads;
   uint global_work_size[2] = { size, iHeads };
   uint local_work_size[2] = { size, 1 };
   OpenCL.SetArgumentBuffer(def_k_SoftMax_FeedForward, def_k_softmaxff_inputs, NeuronOCL.getOutputIndex());
   OpenCL.SetArgumentBuffer(def_k_SoftMax_FeedForward, def_k_softmaxff_outputs, getOutputIndex());
   OpenCL.SetArgument(def_k_SoftMax_FeedForward, def_k_softmaxff_total, size);
   if(!OpenCL.Execute(def_k_SoftMax_FeedForward, 2, global_work_offset, global_work_size, local_work_size))
     {
      printf("Error of execution kernel SoftMax FeedForward: %d", GetLastError());
      return false;
     }
//---
   return true;
  }

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

bool CNeuronSoftMaxOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID)
      return false;
   uint global_work_offset[2] = {0, 0};
   uint size = Output.Total() / iHeads;
   uint global_work_size[2] = {size, iHeads};
   OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_input_gr, NeuronOCL.getGradientIndex());
   OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_output_gr, getGradientIndex());
   OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_outputs, getOutputIndex());
   if(!OpenCL.Execute(def_k_SoftMax_HiddenGradient, 2, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel SoftMax InputGradients: %d", GetLastError());
      return false;
     }
//---
   return true;
  }

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

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

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

bool CNeuronSoftMaxOCL::Save(const int file_handle)
  {
   if(!CNeuronBaseOCL::Save(file_handle))
      return false;
   if(FileWriteInteger(file_handle, iHeads) <= 0)
      return false;
//---
   return true;
  }

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

bool CNeuronSoftMaxOCL::Load(const int file_handle)
  {
   if(!CNeuronBaseOCL::Load(file_handle))
      return false;
   iHeads = (uint)FileReadInteger(file_handle);
   if(iHeads <= 0)
      iHeads = 1;
//---
   return true;
  }

На этом мы заканчиваем работу с классом CNeuronSoftMaxOCL. Нам лишь надо добавить возможность пользователю указать количество нормализуемых векторов. Мы не будем вносить изменение в объект описания нейронного слоя. А для указания количества нормализуемых векторов воспользуемся параметром step. В методе инициализации нейронной сети CNet::Create при создании слоя SoftMax мы добавим передачу указанного параметра в созданный экземпляр класса CNeuronSoftMaxOCL. Изменения выделены заливкой в приведенном ниже коде.

void CNet::Create(CArrayObj *Description)
  {
.........
.........
//---
   for(int i = 0; i < total; i++)
     {
.........
.........
      if(!!opencl)
        {
.........
.........
         CNeuronSoftMaxOCL *softmax = NULL;
         switch(desc.type)
           {
.........
.........
            case defNeuronSoftMaxOCL:
               softmax = new CNeuronSoftMaxOCL();
               if(!softmax)
                 {
                  delete temp;
                  return;
                 }
               if(!softmax.Init(outputs, 0, opencl, desc.count, desc.optimization, desc.batch))
                 {
                  delete softmax;
                  delete temp;
                  return;
                 }
               softmax.SetHeads(desc.step);
               if(!temp.Add(softmax))
                 {
                  delete softmax;
                  delete temp;
                  return;
                 }
               softmax = NULL;
               break;
.........
.........
           }
        }
.........
.........
//---
   return;
  }

Других изменений в архитектуре нейронной сети для реализации рассматриваемого метода не требуется.

Непосредственно процесс обучения модели осуществляется в советнике "DistQ-learning.mq5". Данный советник был создан на базе советника "Q-learning.mq5", который использовался для обучения модели оригинальным методом Q-обучения.

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

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

int                  Actions     =  3; 

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

input double               Step = 5e-4;

Прочие внешние параметры советника остались без изменений.

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

int OnInit()
  {
.........
.........
//---
   float temp1, temp2;
   if(!StudyNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, false) ||
      !TargetNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, false))
      return INIT_FAILED;
   if(!StudyNet.TrainMode(true))
      return INIT_FAILED;
//---
   if(!StudyNet.GetLayerOutput(0, TempData))
      return INIT_FAILED;
   HistoryBars = TempData.Total() / 12;
   StudyNet.getResults(TempData);
   action_dist = TempData.Total() / Actions;
   if(action_dist <= 0)
      return INIT_PARAMETERS_INCORRECT;
   action_midle = (action_dist + 1) / 2;
//---
.........
.........
//---
   return(INIT_SUCCEEDED);
  }

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

Вначале мы подготовим вектор прогнозной стоимости будущего состояния. Данный вектор будет содержать 3 элемента — по одному значению для каждого действия. Для вычисления значений вектора мы воспользуемся векторными операциями. Вначале мы переносим буфер результатов Target Net в матрицу-строку. Затем переформатируем матрицу в табличную из 3 строк — по одной строке для каждого действия. В каждой строке найдем элемент с максимальной вероятностью. И квантили максимальных элементов переведем в натуральное выражение вознаграждения.

void Train(void)
  {
//---
.........
.........
//---
   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
.........
.........
      for(int batch = 0; batch < (Batch * UpdateTarget); batch++)
        {
.........
.........
//---
         vectorf add = vectorf::Zeros(Actions); 
         if(use_target)
           {
            if(!TargetNet.feedForward(GetPointer(State2), 12, true))
               return;
            TargetNet.getResults(TempData);
            vectorf temp;
            TempData.GetData(temp);
            matrixf target = matrixf::Zeros(1, temp.Size());
            if(!target.Row(temp, 0) || !target.Reshape(Actions, action_dist))
               return;
            add = DiscountFactor * (target.ArgMax(1) - action_midle) * Step;
           }

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

         Rewards.BufferInit(Actions * action_dist, 0);
         double reward = Rates[i].close - Rates[i].open;

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

         if(reward >= 0)
           {
            int rew = (int)fmax(fmin((2 * reward + add[0]) / Step + action_midle, action_dist - 1), 0);
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((-5 * reward + add[1]) / Step + action_midle, action_dist - 1), 0) + action_dist;
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((-reward + add.Max()) / Step + action_midle, action_dist - 1), 0) + 2 * action_dist;
            if(!Rewards.Update(rew, 1))
               return;
           }

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

         else
           {
            int rew = (int)fmax(fmin((5 * reward + add[0]) / Step + action_midle, action_dist - 1), 0);
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((-2 * reward + add[1]) / Step + action_midle, action_dist - 1), 0) + action_dist;
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((reward + add.Max()) / Step + action_midle, action_dist - 1), 0) + 2 * action_dist;
            if(!Rewards.Update(rew, 1))
               return;
           }

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


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

С помощью созданного выше советника была обучена модель, состоящая из:

  • 3-х сверточных слоёв предварительной обработки данных,
  • 3-х полносвязных скрытых слоёв из 1000 нейронов в каждом,
  • 1-го полносвязного слоя принятия решения из 45 нейронов (по 15 нейронов на 3 вероятностных распределения действий),
  • 1-го слоя SoftMax для нормализации вероятностных распределений.

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

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

Для тестирования модели в тестере стратегий мы создали советник "DistQ-learning-test.mq5". Данный советник является практически полной копией советника "Q-learning-test.mq5", на котором тестировалась модель обученная оригинальным методом Q-обучения. Единственным изменением в коде советника является добавление функции выбора действия GetAction.

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

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

int GetAction(CBufferFloat* probability)
  {
   vectorf prob;
   if(!probability.GetData(prob))
      return -1;
   matrixf dist = matrixf::Zeros(1, prob.Size());
   if(!dist.Row(prob, 0))
      return -1;
   if(!dist.Reshape(Actions, prob.Size() / Actions))
      return -1;
   prob = dist.ArgMax(1);

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

   if(prob[0] == prob[1])
     {
      if(prob[2] > prob[0])
         return 2;
      if(dist[0, (int)prob[0]] >= dist[1, (int)prob[1]])
         return 0;
      else
         return 1;
     }

Иначе выбираем действие с максимальным ожидаемым вознаграждением.

//---
   return (int)prob.ArgMax();
  }

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

С полным кодом советника можно ознакомиться во вложении.

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

Тестирование модели в тестере стратегий

Тестирование модели распределенного Q-обучения

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

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


Заключение

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

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

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


Ссылки

  1. Нейросети — это просто (Часть 26): Обучение с подкреплением
  2. Нейросети — это просто (Часть 27): Глубокое Q-обучение (DQN)
  3. Нейросети — это просто (Часть 28): Policy gradient алгоритм
  4. A Distributional Perspective on Reinforcement Learning
  5. Distributional Reinforcement Learning with Quantile Regression

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

# Имя Тип Описание
1 DistQ-learning.mq5 Советник Советник для оптимизации модели
2 DistQ-learning-test.mq5 Советник
Советник для тестирования модели в тестере стратегий
3 NeuroNet.mqh Библиотека классов Библиотека для организации моделей нейронных сетей
4 NeuroNet.cl Библиотека
Библиотека кода программы OpenCL для организации моделей нейронных сетей
NetCreator.mq5 Советник Инструмент создания моделей
6 NetCreatotPanel.mqh  Библиотека классов Библиотека класса для создания инструмента
Прикрепленные файлы |
MQL5.zip (82.71 KB)
Последние комментарии | Перейти к обсуждению на форуме трейдеров (3)
Ivan Butko
Ivan Butko | 8 нояб. 2022 в 18:05
  • 3-х сверточных слоёв предварительной обработки данных,

Каковы параметры Activation, Optimization, Window, Step и Window Out у них?

  • 3-х полносвязных скрытых слоёв из 1000 нейронов в каждом,

Каковы параметры Activation, Optimization у них?

  • 1-го полносвязного слоя принятия решения из 45 нейронов (по 15 нейронов на 3 вероятностных распределения действий),

Каковы параметры Activation, Optimization у них? 

  • 1-го слоя SoftMax для нормализации вероятностных распределений.

В NetCreator указан SiftMax. У него в итоге выйдет outputs 45? 

Dmitriy Gizlyk
Dmitriy Gizlyk | 8 нояб. 2022 в 23:42

В NetCreator указан SiftMax. У него в итоге выйдет outputs 45? 

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

Dmitriy Gizlyk
Dmitriy Gizlyk | 8 нояб. 2022 в 23:48
Ivan Butko #:

Каковы параметры Activation, Optimization у них? 

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

Возможности Мастера MQL5, которые вам нужно знать (Часть 3): Энтропия Шеннона Возможности Мастера MQL5, которые вам нужно знать (Часть 3): Энтропия Шеннона
Современный трейдер почти всегда находится в поиске новых идей. Он постоянно пробует новые стратегии, модифицирует их и отбрасывает те, что не оправдали себя. В этой серии статей я постараюсь доказать, что Мастер MQL5 является настоящей опорой трейдера.
Магия временных торговых интервалов с инструментом Frames Analyzer Магия временных торговых интервалов с инструментом Frames Analyzer
Что такое Frames Analyzer? Это подключаемый модуль к любому торговому эксперту для анализа фреймов оптимизации во время оптимизации параметров в тестере стратегий, а также вне тестера посредством чтения MQD-файла или базы данных, которая создаётся сразу после оптимизации параметров. Вы сможете делиться этими результатами оптимизации с другими пользователями, у которых есть инструмент Frames Analyzer, чтобы обсудить полученные результаты оптимизации вместе.
Разработка торговой системы на основе индикатора VIDYA Разработка торговой системы на основе индикатора VIDYA
Представляю вашему вниманию новую статью из серии, в которой мы учимся строить торговые системы на основе самых популярных индикаторов. В этой статье мы поговорим об индикаторе Скользящей средней с динамическим периодом усреднения (Variable Index Dynamic Average, VIDYA) и создадим торговую систему по его показателям.
Машинное обучение и Data Science. Нейросети (Часть 02): архитектура нейронных сетей с прямой связью Машинное обучение и Data Science. Нейросети (Часть 02): архитектура нейронных сетей с прямой связью
В предыдущей статье мы начали изучать нейросети с прямой связью, однако остались неразобранными некоторые моменты. Один из них — проектирование архитектуры. Поэтому в этой статье мы рассмотрим, как спроектировать гибкую нейронную сеть с учетом входных данных, количества скрытых слоев и узлов для каждой сети.