Методы машинного обучения

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

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

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

Элементарная нейронная сеть

Элементарная нейронная сеть

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

Обратите внимание, что если бы не нелинейность в каждом нейроне, многослойную нейронную сеть можно было бы представить в эквивалентной форме в виде одного слоя, коэффициенты которого получаются матричным произведением всех слоев (Wtotal = W1 * W2 * ... * WL, где 1..L — номера слоев). А это получился бы простой линейный сумматор. Таким образом, математически обосновывается важность активационных функций.

Некоторые из наиболее известных активационных функций

Некоторые из наиболее известных активационных функций

Одна из основных классификаций нейронных сетей делит их согласно используемому алгоритму обучения на сети "с учителем" (supervised learning) и "без учителя" (unsupervised learning). Первые требуют, чтобы человек-эксперт предоставил для исходного набора данных желаемые результаты (например, дискретные метки состояния торговой системы или числовые показатели предполагаемых приращений цены). Сети без учителя выявляют кластеры в данных самостоятельно.

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

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

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

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

  • Activation — вычисляет значения функции активации;
  • Derivative — вычисляет значения производной активационной функции;
  • Loss — вычисляет значение функции потерь.

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

Два первых метода записывают результат в переданный вектор/матрицу и возвращают признак успеха (true или false), а функция потерь возвращает число. Приведем их прототипы (под типом object<T> обозначены и matrix<T>, и vector<T>):

bool object<T>::Activation(object<T> &out, ENUM_ACTIVATION_FUNCTION activation)

bool object<T>::Derivative(object<T> &out, ENUM_ACTIVATION_FUNCTION loss)

T object<T>::Loss(const object<T> &target, ENUM_LOSS_FUNCTION loss)

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

Перечень поддерживаемых активационных функций в перечислении ENUM_ACTIVATION_FUNCTION и функций потерь в перечислении ENUM_LOSS_FUNCTION можно найти в справке по MQL5.

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

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

Выберем для нашего случая один из наиболее простых видов нейронных сетей: двунаправленную ассоциативную память (Bidirectional Associative Memory, BAM). Такая сеть имеет всего два слоя: входной и выходной — именно во втором из них формируется некий отклик (ассоциация) в ответ на подачу того или иного сигнала на вход. Размеры слоев могут быть разными. Когда размеры одинаковы, получается конфигурация сети, известная под именем Хопфилда.

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

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

С помощью такой сети мы будем сопоставлять N недавних предыдущих тиков и M следующих предсказываемых, формируя обучающую выборку из ближайшего прошлого на заданную глубину. Тики будут подаваться в сеть в виде положительных или отрицательных приращений цены, преобразованных к двоичным значениям [+1, -1] (бинарные сигналы являются канонической формой кодирования в сетях BAM и Хопфилда).

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

Но, разумеется, эта простота имеет и обратную "сторону медали": емкость BAM (количество образов, которые она способна запомнить) ограничена размером меньшего слоя, и то — при условии особого распределения +1 и -1 в векторах обучающей выборки.

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

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

Итак, чтобы сеть "запомнила" ассоциативные образы (в нашем случае: что было и что будет в потоке тиков) достаточно вычислить следующее уравнение:

W = Σi(AiTBi)

где W — весовая матрица сети, а суммирование выполняется по все попарным произведениям входных векторов Ai и соответствующих выходных векторов Bi.

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

В таком состоянии второй слой сети содержит найденный ассоциированный выходной образ ("предсказание").

Реализуем данный сценарий машинного обучения в скрипте MatrixMachineLearning.mq5.

Во входных параметрах можно задать общее количество последних тиков (TicksToLoad), запрашиваемых из истории, и какое из них количество отведено для тестирования (TicksToTest). Соответственно, модель (веса) будет строиться на основе (TicksToLoad - TicksToTest) тиков.

input int TicksToLoad = 100;
input int TicksToTest = 50;
input int PredictorSize = 20;
input int ForecastSize = 10;

Также во входных переменных выбирается размер входного вектора (количество "известных" тиков PredictorSize) и выходного вектора (количество "будущих" тиков ForecastSize).

Запрос тиков делается в начале функции OnStart. В данном случае мы работаем только с ценами Ask, но никто не мешает подключить сюда также Bid, Last и объем.

void OnStart()
{
   vector ticks;
   ticks.CopyTicks(_SymbolCOPY_TICKS_ALL | COPY_TICKS_ASK0TicksToLoad);
   ...

Разделим тики на обучающую и тестовую выборки.

   vector ask1(n - TicksToTest);
   for(int i = 0i < n - TicksToTest; ++i)
   {
      ask1[i] = ticks[i];
   }
   
   vector ask2(TicksToTest);
   for(int i = 0i < TicksToTest; ++i)
   {
      ask2[i] = ticks[i + TicksToLoad - TicksToTest];
   }
   ...

Для вычисления приращений цен используем метод Convolve с дополнительным вектором {+1, -1}. Отметим, что вектор с приращениями будет на 1 элемент короче исходного.

   vector differentiator = {+1, -1};
   vector deltas = ask1.Convolve(differentiatorVECTOR_CONVOLVE_VALID);
   ...

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

Для перевода непрерывных значений приращений в единичные импульсы (положительные и отрицательные в зависимости от знака исходного элемента вектора) написана вспомогательная функция Binary (здесь не приводится): она возвращает новую копию вектора, в которой каждый элемент равен либо +1, либо -1.

   vector inputs = Binary(deltas);

На основе полученной входной последовательности с помощью функции TrainWeights вычисляется матрица весовых коэффициентов W нейронной сети. Устройство этой функции мы рассмотрим ниже, а пока обратим внимание, что в неё передаются параметры PredictorSize и ForecastSize, которые позволяют "нарезать" непрерывную последовательность тиков на наборы парных входных и выходных векторов по размерам входного и выходного слоя BAM, соответственно.

   matrix W = TrainWeights(inputsPredictorSizeForecastSize);
   Print("Check training on backtest: ");   
   CheckWeights(Winputs);
   ...

Сразу послу "обучения" сети мы проверяем её точность на обучающей выборке — просто, чтобы убедиться, что сеть действительно обучилась. Это действие поручено функции CheckWeights.

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

   vector test = Binary(ask2.Convolve(differentiatorVECTOR_CONVOLVE_VALID));
   Print("Check training on forwardtest: ");   
   CheckWeights(Wtest);
   ...
}

Настало время познакомиться с функцией TrainWeights. В ней мы определяем матрицы A и B для "нарезки" векторов (образов) из переданной входной последовательности — из вектора data.

template<typename T>
matrix<TTrainWeights(const vector<T> &dataconst uint predictorconst uint responce,
   const uint start = 0const uint _stop = 0const uint step = 1)
{
   const uint sample = predictor + responce;
   const uint stop = _stop <= start ? (uint)data.Size() : _stop;
   const uint n = (stop - sample + 1 - start) / step;
   matrix<TA(npredictor), B(nresponce);
   
   ulong k = 0;
   for(ulong i = starti < stop - sample + 1i += step, ++k)
   {
      for(ulong j = 0j < predictor; ++j)
      {
         A[k][j] = data[start + i * step + j];
      }
      for(ulong j = 0j < responce; ++j)
      {
         B[k][j] = data[start + i * step + j + predictor];
      }
   }
   ...

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

Далее нам следует выделить память под матрицу весов W и заполнить её с помощью матричных методов: последовательно умножаем строки из A и B с помощью Outer и матрично суммируем.

   matrix<TW = matrix<T>::Zeros(predictorresponce);
   
   for(ulong i = 0i < k; ++i)
   {
      W += A.Row(i).Outer(B.Row(i));
   }
   
   return W;
}

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

template<typename T>
void CheckWeights(const matrix<T> &W,
   const vector<T> &data,
   const uint start = 0const uint _stop = 0const uint step = 1)
{
   const uint predictor = (uint)W.Rows();
   const uint responce = (uint)W.Cols();
   const uint sample = predictor + responce;
   const uint stop = _stop <= start ? (uint)data.Size() : _stop;
   const uint n = (stop - sample + 1 - start) / step;
   matrix<TA(npredictor), B(nresponce);
   
   ulong k = 0;
   for(ulong i = starti < stop - sample + 1i += step, ++k)
   {
      for(ulong j = 0j < predictor; ++j)
      {
         A[k][j] = data[start + i * step + j];
      }
      for(ulong j = 0j < responce; ++j)
      {
         B[k][j] = data[start + i * step + j + predictor];
      }
   }
   
   const matrix<Tw = W.Transpose();
   ...

Матрицы A и B в данном случае формируются не для расчета W, а выступают "поставщиками" векторов для тестирования. Также нам понадобится и транспонированная копия W для просчета обратных сигналов из второго слоя сети в первый.

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

   const uint limit = 100;
   
   int positive = 0;
   int negative = 0;
   int average = 0;

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

Далее в цикле по тестовым парам образов выполняется активация сети и снимается её окончательный отклик. Каждый очередной входной вектор записывается в вектор a, а выходной слой b заполняется нулями. После этого запускаются итерации по передаче сигнала из a в b с помощью матрицы W и применения активационной функции AF_TANH, а также обратной передачи сигнала из b в a и тоже применения AF_TANH. Процесс продолжается вплоть до достижении limit циклов (что маловероятно) или выполнения условия сходимости, при котором векторы состояния нейронов a и b уже практически не меняются (здесь используется метод Compare и вспомогательные копии векторов x и y от предыдущей итерации).

   for(ulong i = 0i < k; ++i)
   {
      vector a = A.Row(i);
      vector b = vector::Zeros(responce);
      vector xy;
      uint j = 0;
      
      for( ; j < limit; ++j)
      {
         x = a;
         y = b;
         a.MatMul(W).Activation(bAF_TANH);
         b.MatMul(w).Activation(aAF_TANH);
         if(!a.Compare(x0.00001) && !b.Compare(y0.00001)) break;
      }
      
      Binarize(a);
      Binarize(b);
      ...

После достижения стабильного состояния, переводим состояния нейронов из непрерывных (вещественных) в бинарные +1 и -1 с помощью функции Binarize (она похожа на уже упоминавшуюся функцию Binary, но меняет состояние вектора по месту).

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

      const int match = (int)(b.Dot(B.Row(i)));
      if(match > 0positive++;
      else if(match < 0negative++;
      
      average += match;  // 0 в match означает точность 50/50 (т.е. случайное гадание)
   }

После завершения цикла по всем тестовым образцам, выводим статистику.

   float skew = (float)average / k// среднее количество совпадений на вектор
   
   PrintFormat("Count=%d Positive=%d Negative=%d Accuracy=%.2f%%",
      kpositivenegative, ((skew + responce) / 2 / responce) * 100);
}

В скрипте также имеется функция RunWeights, которая фактически представляет собой рабочий прогон нейронной сети (по её матрице весов W) для онлайн-вектора из последних predictor тиков. Функция вернет вектор с предполагаемыми будущими тиками.

template<typename T>
vector<TRunWeights(const matrix<T> &Wconst vector<T> &data)
{
   const uint predictor = (uint)W.Rows();
   const uint responce = (uint)W.Cols();
   vector a = data;
   vector b = vector::Zeros(responce);
   
   vector xy;
   uint j = 0;
   const uint limit = LIMIT;
   const matrix<Tw = W.Transpose();
   
   for( ; j < limit; ++j)
   {
      x = a;
      y = b;
      a.MatMul(W).Activation(bAF_TANH);
      b.MatMul(w).Activation(aAF_TANH);
      if(!a.Compare(x0.00001) && !b.Compare(y0.00001)) break;
   }
   
   Binarize(b);
   
   return b;
}

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

void OnStart()
{
   ...
   Sleep(1000);
   vector ask3;
   ask3.CopyTicks(_SymbolCOPY_TICKS_ALL COPY_TICKS_ASK0PredictorSize + 1);
   vector online = Binary(ask3.Convolve(differentiatorVECTOR_CONVOLVE_VALID));
   Print("Online: "online);
   vector forecast = RunWeights(Wonline);
   Print("Forecast: "forecast);
}

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

Check training on backtest: 
Count=20 Positive=20 Negative=0 Accuracy=85.50%
Check training on forwardtest: 
Count=20 Positive=12 Negative=2 Accuracy=58.50%
Online: [1,1,1,1,-1,-1,-1,1,-1,1,1,-1,1,1,-1,-1,1,1,-1,-1]
Forecast: [-1,1,-1,1,-1,-1,1,1,-1,1]

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

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

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