preview
Нейросети — это просто (Часть 85): Многомерное прогнозирование временных рядов

Нейросети — это просто (Часть 85): Многомерное прогнозирование временных рядов

MetaTrader 5Торговые системы | 12 апреля 2024, 13:40
532 1
Dmitriy Gizlyk
Dmitriy Gizlyk

Введение

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

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

Довольно конструктивно к этому вопросу подошли авторы статьи "Client: Cross-variable Linear Integrated Enhanced Transformer for Multivariate Long-Term Time Series Forecasting". Для оценки масштабов проблемы они провели комплексный эксперимент с сериями маскирования части исторического ряда, заменяя случайным образом отдельные данные на "0". Модели, которые более чувствительны к временной зависимости, демонстрируют большое снижения производительности при отсутствии корректных исторических данных. Таким образом, снижение производительности является индикатором способности модели отображать временные закономерности. В результате эксперимента было замечено, что производительность моделей Transformer, основанных на перекрестном внимании, существенно не снижается по мере увеличения масштаба маскирования данных. У некоторых таких моделей производительность прогнозирования остается практически неизменной даже при замене на "0" случайным образом до 80% исторических данных. Это может указывать на то, что результаты прогнозов таких моделей не чувствительны к изменениям анализируемого временного ряда.

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

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

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

Хотя предлагаемый авторами статьи Transformer хорошо моделирует нелинейность и фиксирует зависимости между переменными, он может не сработать при извлечении трендов анализируемых рядов. Но с такой задачей прекрасно справляются линейные модели. С целью объединения лучшего из обоих миров авторы статьи предлагают метод линейно интегрированного расширенного Transformer с перекрестными анализом переменных для многомерного долгосрочного прогнозирования временных рядов (Cross-variable Linear Integrated Enhanced Transformer — Client). Предлагаемый алгоритм сочетает в себе способность линейных моделей извлекать тренды с выразительными возможностями расширенного Transformer.


1. Алгоритм Client

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

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

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

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

Внимание в разрезе переменных (авторская визуализация)

Подготовленные таким образом данные подаются в Энкодер Transformer, который состоит из нескольких слоев многоголового Self-Attention (MHA) и блоков FeedForward (FFN).

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

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

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

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

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

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

Таким образом, метод Client использует линейный модуль для сбора информации о тенденциях и расширенный модуль Transformer для сбора нелинейной информации и зависимостей между переменными. Авторская визуализация метода представлена ниже.

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


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

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

2.1 Создаем новый нейронный слой

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

class CNeuronClientOCL  :  public CNeuronBaseOCL
  {
protected:
   //--- Attention
   CNeuronMLMHAttentionOCL cTransformerEncoder;
   CNeuronConvOCL    cProjection;
   //--- Linear model
   CNeuronConvOCL    cLinearModel[];
   //---
   CNeuronBaseOCL    cInput;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer);

public:
                     CNeuronClientOCL(void) {};
                    ~CNeuronClientOCL(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_key, uint heads,
                          uint at_layers, uint count, uint &mlp[],
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void)   const   {  return defNeuronClientOCL;   }
   //---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual void      SetOpenCL(COpenCLMy *obj);
   virtual void      TrainMode(bool flag);
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau);
  };

Блок внимания мы создадим из 2 объектов:

  • cTransformerEncoder — объект класса CNeuronMLMHAttentionOCL, который позволяет создать блок Энкодера многоголового Transformer из заданного числа последовательных слоев;
  • cProjection — слой проекции. Здесь мы используем сверточный слой для создания независимых прогнозов по отдельным переменным. А глубина прогнозирования определяет количества фильтров в слое.

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

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

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

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

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

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

И тут следует обратить внимание, что в теле класса мы создаем 2 параллельных потока:

  • Блок Transformer;
  • Линейный модуль.

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

  • window — размер вектора 1 элемента последовательности;
  • window_key — размер вектора внутреннего представления 1 элемента последовательности;
  • heads — количество голов внимания;
  • count — количество элементов в последовательности;
  • at_layers — количество слоев в блоке Энкодера.

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

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

При таком подходе, глубину прогнозирования данных мы укажем в последнем элементе массива mlp[].

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

bool CNeuronClientOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                            uint window, uint window_key, uint heads,
                            uint at_layers, uint count, uint &mlp[],
                            ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   uint mlp_layers = mlp.Size();
   if(mlp_layers == 0)
      return false;

В теле метода мы сначала проверяем размер массива описания архитектуры линейного модуля mlp[]. Он должен содержать как минимум один элемент с указанием глубины прогнозирования данных. Если массив пуст, то завершаем работу метода с результатом false.

Следующим этапом идет инициализация объектов класса. Сначала мы изменяем динамический массив линейного модуля.

   if(ArrayResize(cLinearModel, mlp_layers + 1) != (mlp_layers + 1))
      return false;

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

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

   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, mlp[mlp_layers - 1] * count, optimization_type, batch))
      return false;

После чего мы вызываем метод инициализации Энкодера Transformer.

   if(!cTransformerEncoder.Init(0, 0, OpenCL, window, window_key, heads, count, at_layers, optimization, iBatch))
      return false;

И вспомогательного слоя временного хранения исходных данных.

   if(!cInput.Init(0, 1, open_cl, window * count, optimization_type, batch))
      return false;

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

   uint w = window;
   for(uint i = 0; i < mlp_layers; i++)
     {
      if(!cLinearModel[i].Init(0, i + 2, OpenCL, w, w, mlp[i], count, optimization, iBatch))
         return false;
      cLinearModel[i].SetActivationFunction(LReLU);
      w = mlp[i];
     }

Тут следует вспомнить, что авторы метода Client предлагают к результатам линейного модуля применить обучаемые коэффициенты. И надо сказать, что был найден довольно не ординарный метод создания обучаемых множителей. Мы решили заменить их сверточным слоем с количеством фильтров, размером окна и шага свертки равным "1". Его мы добавляем в последний элемент (добавленный нами ранее) массива линейного модуля.

   if(!cLinearModel[mlp_layers].Init(0, mlp_layers + 2, OpenCL, 1, 1, 1, w * count, optimization, iBatch))
      return false;

И ещё один момент. В процессе нормализации исходных данных мы приводим их к среднему значению равному "0" и дисперсии "1". Следовательно, и прогнозные значения должны соответствовать этому распределению. Для ограничения прогнозных значений мы воспользуемся гиперболическим тангенсом (tanh) в качестве функции активации.

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

   cLinearModel[mlp_layers].SetActivationFunction(TANH);
   if(!cProjection.Init(0, mlp_layers + 3, OpenCL, window, window, w, count, optimization, iBatch))
      return false;
   cProjection.SetActivationFunction(TANH);

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

   SetActivationFunction(TANH);

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

   if(!SetGradient(cProjection.getGradient()))
      return false;
   if(!cLinearModel[mlp_layers].SetGradient(Gradient))
      return false;
//---
   return true;
  }

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

После инициализации вложенных объектов класса мы переходим к организации алгоритма прямого прохода в методе CNeuronClientOCL::feedForward. Основные принципы передачи данных мы проговорили при инициализации объектов. А сейчас посмотрим на реализацию предложенных подходов.

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

bool CNeuronClientOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cTransformerEncoder.FeedForward(NeuronOCL))
      return false;

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

   if(!cProjection.FeedForward(GetPointer(cTransformerEncoder)))
      return false;

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

   if(cInput.getOutputIndex() != NeuronOCL.getOutputIndex())
      cInput.getOutput().BufferSet(NeuronOCL.getOutputIndex());

И организуем цикл прямого прохода линейного модуля.

   uint total = cLinearModel.Size();
   CNeuronBaseOCL *neuron = NeuronOCL;
   for(uint i = 0; i < total; i++)
     {
      if(!cLinearModel[i].FeedForward(neuron))
         return false;
      neuron = GetPointer(cLinearModel[i]);
     }

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

   if(!SumAndNormilize(neuron.getOutput(), cProjection.getOutput(), Output, 1, false, 0, 0, 0, 
0.5 ))
      return false;
//---
   return true;
  }

Аналогичным образом, но в обратном порядке, организовано распределение градиента ошибки через вложенные объекты до предшествующего слоя в соответствии с их влиянием на конечный результат. Что мы можем наблюдать в методе CNeuronClientOCL::calcInputGradients.

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

bool CNeuronClientOCL::calcInputGradients(CNeuronBaseOCL *prevLayer)
  {
   if(!cTransformerEncoder.calcHiddenGradients(cProjection.AsObject()))
      return false;
   if(!prevLayer.calcHiddenGradients(cTransformerEncoder.AsObject()))
      return false;

А затем в обратном цикле через линейный модуль.

   CNeuronBaseOCL *neuron = NULL;
   int total = (int)cLinearModel.Size() - 1;
   for(int i = total; i >= 0; i--)
     {
      neuron = (i > 0 ? cLinearModel[i - 1] : cInput).AsObject();
      if(!neuron.calcHiddenGradients(cLinearModel[i].AsObject()))
         return false;
     }

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

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

   if(!SumAndNormilize(neuron.getGradient(), prevLayer.getGradient(), prevLayer.getGradient(), 1, false))
      return false;
//---
   return true;
  }

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

2.2 Архитектура моделей

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

Метод Client был предложен для решения задач прогнозирования временных рядов. И мы будем использовать его в нашем Энкодере.

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

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

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

bool CreateEncoderDescriptions(CArrayObj *encoder)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }

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

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

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

Здесь, как и ранее, размер слоя определяется произведением 2 констант:

  • HistoryBars — глубина анализируемой истории состояний (баров) окружающей среды;
  • BarDescr — размер вектора описания одного бара состояния окружающей среды.

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

Надо сказать, что мы увеличили значение константы HistoryBars до 120. Это позволяет на тайм-фрейме H1 анализировать исторические данные последней недели.

#define        HistoryBars             120           //Depth of history

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

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = 1000;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

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

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

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

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   prev_count = descr.count = HistoryBars;
   int prev_wout = descr.window = BarDescr;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

И после слоя транспонирования мы добавляем экземпляр нашего нового слоя — блока Client.

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronClientOCL;
   descr.count = prev_wout;
   descr.window = prev_count;
   descr.step = 4;
   descr.window_out = EmbeddingSize;
   descr.layers = 5;
     {
      int temp[] = {1024, 1024, 1024, NForecast};
      ArrayCopy(descr.windows, temp);
     }
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Здесь в качестве размера анализируемой последовательности мы указываем количество анализируемых переменных (константа BarDescr). Размер же вектора описания одного элемента последовательности равен глубине анализируемой истории (константа HistoryBars). В блоке Transformer мы используем 4 головы внимания и создаем 5 таких слоев.

Линейный модуль мы создадим из 4 слоев: 3 скрытых слоя размером 1024 и последний слой равен горизонту планирования (константа NForecast).

Далее мы осуществляем обратное транспонирование данных.

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   prev_count = descr.count = BarDescr;
   prev_wout = descr.window = NForecast;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

И восстанавливаем статистическую информацию в них.

//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronRevInDenormOCL;
   prev_count = descr.count = prev_count * prev_wout;
   descr.activation = None;
   descr.optimization = ADAM;
   descr.layers = 1;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

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

Архитектура моделей Актера и Критика представлена в методе CreateDescriptions. В параметрах метода мы получаем указатели на 2 динамических массива для записи архитектуры моделей.

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

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

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

//--- Actor
   actor.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = AccountDescr;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Далее мы формируем эмбединг состояния счета.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = EmbeddingSize;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

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

//--- layer 2-4
   for(int i = 0; i < 3; i++)
     {
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronCrossAttenOCL;
        {
         int temp[] = {1, BarDescr};
         ArrayCopy(descr.units, temp);
        }
        {
         int temp[] = {EmbeddingSize, NForecast};
         ArrayCopy(descr.windows, temp);
        }
      descr.window_out = 16;
      descr.step = 4;
      descr.activation = None;
      descr.optimization = ADAM;
      if(!actor.Add(descr))
        {
         delete descr;
         return false;
        }
     }

Здесь надо обратить внимание, что, следуя духу метода Client, для кросс-анализа мы взяли данные из скрытого состояния Энкодера до повторного транспонирования данных. Что позволяет нам проанализировать зависимости текущего состояния счета с прогнозными значениями отдельных переменных. Это нашло свое отражение в изменении значений массивов descr.units и descr.windows.

Далее, как и ранее, идет блок принятия решений с добавлением стохастичности политике Актера.

//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NActions;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

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

Кроме того, мы изменили значение константы-указателя на скрытый слой Энкодера для извлечения данных.

#define        LatentLayer             3

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

2.3 Советник обучения модели прогнозирования

Обучение модели прогнозирования состояний окружающей среды осуществляется в советнике "...\Experts\Client\StudyEncoder.mq5". В целом структура советника заимствована из предыдущих работ. И мы не будем детально останавливаться на рассмотрении всех его методов. Рассмотрим лишь непосредственно этап обучения модели, которое осуществляется в методе Train.

void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);
//---
   vector<float> result, target;
   bool Stop = false;
//---
   uint ticks = GetTickCount();

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

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

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

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

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

      bState.AssignArray(Buffer[tr].States[i].state);

Этой информации нам достаточно для проведения прямого прохода Энкодера.

      //--- State Encoder
      if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

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

      //--- Collect target data
      if(!bState.AssignArray(Buffer[tr].States[i + NForecast].state))
         continue;
      if(!bState.Resize(BarDescr * NForecast))
         continue;

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

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

      if(!Encoder.backProp(GetPointer(bState), (CBufferFloat*)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

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

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

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

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

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

2.4 Советник обучения политики Актера

В советник обучения политики Актера "...\Experts\Client\Study.mq5" были так же внесены некоторые правки. Как и при рассмотрении предыдущего советника, остановимся лишь на методе обучения моделей.

void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);
//---
   vector<float> result, target;
   bool Stop = false;
//---
   uint ticks = GetTickCount();

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

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

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

      bState.AssignArray(Buffer[tr].States[i].state);
      //--- State Encoder
      if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

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

      //--- Critic
      bActions.AssignArray(Buffer[tr].States[i].action);
      if(bActions.GetIndex() >= 0)
         bActions.BufferWrite();
      if(!Critic.feedForward((CBufferFloat*)GetPointer(bActions), 1, false, GetPointer(Encoder), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

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

      result.Assign(Buffer[tr].States[i + 1].rewards);
      target.Assign(Buffer[tr].States[i + 2].rewards);
      result = result - target * DiscFactor;
      Result.AssignArray(result);

 И оптимизируем параметры Критика с целью минимизации ошибки оценки действий Актера.

      Critic.TrainMode(true);
      if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

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

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

После чего добавим в буфер гармоники временной метки.

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

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

      //--- Actor
      if(!Actor.feedForward((CBufferFloat*)GetPointer(bAccount), 1, false, GetPointer(Encoder), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

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

      if(!Actor.backProp(GetPointer(bActions), GetPointer(Encoder), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

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

      if(!Critic.feedForward((CNet *)GetPointer(Actor), -1, (CNet*)GetPointer(Encoder), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

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

      Critic.TrainMode(false);
      if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder), LatentLayer) ||
         !Actor.backPropGradient((CNet *)GetPointer(Encoder), LatentLayer, -1, true))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

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

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

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

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

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

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

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

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


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

В данной статье мы познакомились с методом многомерного прогнозирования временных рядов Client и реализовали свое видение предложенных подходов средствами MQL5. И теперь мы переходим к заключительному этапу нашей работы — проверке результатов. На этом этапе мы обучим модели на реальных исторических данных инструмента EURUSD тайм-фрейм H1 за 2023 год. После чего проверим результаты обученной модели в тестере стратегий MetaTrader 5 на исторических данных Января 2024 года с сохранением инструмента и тайм-фрейма, используемых при обучении моделей.

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

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

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

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


Заключение

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

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

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

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


Ссылки

  • Client: Cross-variable Linear Integrated Enhanced Transformer for Multivariate Long-Term Time Series Forecasting
  • Другие статьи серии

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

    # Имя Тип Описание
    1 Research.mq5 Советник Советник сбора примеров
    2 ResearchRealORL.mq5
    Советник
    Советник сбора примеров методом Real-ORL
    3 Study.mq5  Советник Советник обучения Моделей
    4 StudyEncoder.mq5 Советник
    Советник обучения Энкодера
    5 Test.mq5 Советник Советник для тестирования модели
    6 Trajectory.mqh Библиотека класса Структура описания состояния системы
    7 NeuroNet.mqh Библиотека класса Библиотека классов для создания нейронной сети
    8 NeuroNet.cl Библиотека Библиотека кода программы OpenCL
    Прикрепленные файлы |
    MQL5.zip (1106.26 KB)
    Последние комментарии | Перейти к обсуждению на форуме трейдеров (1)
    Aleksey Vyazmikin
    Aleksey Vyazmikin | 12 апр. 2024 в 23:57

    Спасибо за труд!

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

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

    Машинное обучение и Data Science (Часть 17): Растут ли деньги на деревьях? Случайные леса в форекс-трейдинге Машинное обучение и Data Science (Часть 17): Растут ли деньги на деревьях? Случайные леса в форекс-трейдинге
    Эта статья познакомит вас с секретами алгоритмической алхимии, познакомит с искусством и точностью особенностей финансовых ландшафтов. Вы узнаете, как случайные леса преобразуют данные в прогнозы и помогают ориентироваться в сложностях финансовых рынков. Мы постараемся определить роль случайных лесов в отношении финансовых данных и проверить, смогут ли они помочь увеличить прибыль.
    Возможности Мастера MQL5, которые вам нужно знать (Часть 09): Сочетание кластеризации k-средних с фрактальными волнами Возможности Мастера MQL5, которые вам нужно знать (Часть 09): Сочетание кластеризации k-средних с фрактальными волнами
    Кластеризация k-средних использует подход к группировке точек данных в виде процесса, изначально фокусирующегося на макропредставлении набора данных, в котором применяются случайно сгенерированные центроиды кластера. Затем эти центроиды масштабируются и настраиваются для точного представления набора данных. В статье рассматриваются кластеризация и несколько вариантов ее использования.
    Шаблоны проектирования в программировании на MQL5 (Часть 4): Поведенческие шаблоны 2 Шаблоны проектирования в программировании на MQL5 (Часть 4): Поведенческие шаблоны 2
    Статья завершает серию о шаблонах проектирования в области программного обеспечения. Я уже упоминал, что существуют три типа шаблонов проектирования - порождающие, структурные и поведенческие. Мы доработаем оставшиеся паттерны поведенческого типа, которые помогут задать способ взаимодействия между объектами таким образом, чтобы сделать наш код чистым.
    Фильтрация и извлечение признаков в частотной области Фильтрация и извлечение признаков в частотной области
    В этой статье мы рассмотрим применение цифровых фильтров к временным рядам, представленным в частотной области, с целью извлечения уникальных признаков, которые могут быть полезными для моделей прогнозирования.