Нейронные сети обратного распространения ошибки на матрицах MQL5

Stanislav Korotky | 21 февраля, 2023

Среди инструментов трейдера уже довольно давно присутствуют машинное обучение и, в частности, нейронные сети. В свою очередь, среди нейронных сетей, в той части их классификации, которая объединяет так называемые методы "обучения с учителем", особое место занимают сети с обратным распространением ошибки (backpropagation neural network, BPNN). Существует множество различных модификаций подобных алгоритмов. На их основе построены, например, глубокие, рекуррентные и сверточные нейронные сети. Так что, нас не должно удивлять обилие материалов на данную тему (как и статей на данном сайте). Сегодня мы разовьем её в относительно новом для MQL5 направлении. Дело в том, что некоторое время назад в MQL5 появились новые возможности API, предназначенные для работы с матрицами и векторами. Они позволяют реализовать расчеты в нейронной сети в пакетном режиме, когда данные обрабатываются целиком (или блоками), а не поэлементно.

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

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

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


Введение в теорию нейронных сетей

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

Синапс соединяет выход одного нейрона со входом другого и характеризуется величиной, называемой весом wi. Текущее состоянием нейрона получается, как взвешенная сумма сигналов, поступивших на его связи (входы).

Схема нейрона

Схема нейрона

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

f1
(1)
f2
(2)

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

f3
(3)

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

Популярные функции активации

Популярные функции активации

Активационные функции могут иметь различные диапазоны значений — ограниченные или неограниченные. В частности, сигмоида (3) отображает данные в диапазон [0,+1] (лучше подходит для задач классификации), а гиперболический тангенс — в диапазон [-1,+1] (лучше подходит для задач регрессии и прогнозирования).

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

f4
(4)

Нейронная сеть из одного слоя представлена на следующем рисунке.

Однослойная нейронная сеть

Однослойная нейронная сеть

Принцип её работы математически описывает такое уравнение:

f5
(5)

Очевидно, что все весовые коэффициенты одного слоя можно свести в матрицу W, в которой каждый элемент wij задает величину i-ой связи j-ого нейрона. Таким образом, процесс, происходящий в НС, может быть записан в матричной форме:

Y = F(X W) (6)

где X и Y — соответственно входной и выходной сигнальные векторы, F(V) — активационная функция, применяемая поэлементно к компонентам вектора V.

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

Для иллюстрации приведем схему сети из двух слоев.

Двухслойная нейронная сеть

Двухслойная нейронная сеть

Теперь рассмотрим один нюанс, опущенный ранее. Из рисунка активационных функций очевидно, что существует некое значение T, где S-образные функции имеют максимальный наклон и хорошо передают сигналы, а прочие функции имеют характерную точку перелома (или несколько таких точек). Поэтому основная работа каждого нейрона происходит в окрестности T. Обычно T=0 или лежит возле 0, а потому желательно иметь средство для автоматического сдвига аргумента активационной функции к T.

Данное явление не было отражено в формуле (1), которая должна была бы выглядеть так:

f7
(7)

Такое смещение обычно вводится путем добавления к слою нейронов еще одного псевдо-входа, значение которого всегда равно 1. Присвоим этому входу номер 0. Тогда:

f8
(8)

где w0 = –T, x0 = 1.

Обучение НС с "учителем" предполагает наличие обучающих данных, заранее подготовленных и "размеченных" человеком-экспертом. В этих данных входным векторам сопоставлены желаемые выходные.

Само обучение производится по следующим этапам.

1. Проинициализировать элементы весовой матрицы (обычно небольшими случайными значениями);

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

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

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

Для случая однослойной НС формула для модификации весов довольно очевидна:

f9
(9)
f10
(10)

где δ — ошибка сети (разница между полученным ответом сети и идеалом), t и t+1 — номера соответственно текущей и следующей итераций; ν — коэффициент скорости обучения, 0<ν<1; i — номер входа; j — номер нейрона в слое.

Однако, что делать, когда сеть многослойная? Здесь мы и подходим к идее обратного распространения ошибки.


Алгоритм обратного распространения ошибки

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

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

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

f11
(11)

где yjpᴺ — реальное выходное состояние нейрона j выходного слоя N нейронной сети при подаче на ее входы p-го образа; djp — идеальное (желаемое) выходное состояние этого нейрона.

Суммирование ведется по всем нейронам выходного слоя и по всем обрабатываемым образам. Коэффициент 1/2 вставлен только для получения "красивой" производной от E (двойки сокращаются), которая будет использоваться далее для обучения (см. формулу (12)) и в любом случае "взвешивается" важным параметром алгоритма — скоростью (которую можно увеличить в 2 раза или даже менять динамически по некоторым условиям).

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

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

f12
(12)

Здесь wij — весовой коэффициент связи, соединяющей i-ый нейрон слоя n-1 с j-ым нейроном слоя n, η — коэффициент скорости обучения.

Вспомним внутреннее устройство нейрона и на его основе выделим в формуле (12) каждый этап вычислений в частную производную:

f13
(13)

Здесь под yj, как и раньше, подразумевается выход нейрона j, а под sj — взвешенная сумма его входных сигналов, то есть аргумент активационной функции. Так как множитель dyj/dsj является производной этой функции, из этого следует требование дифференцируемости активационной функции на всей оси абсцисс для использования в рассматриваемом алгоритме обратного распространения ошибки.

Например, в случае гиперболического тангенса:

f14
(14)

Третий множитель в (13) ∂sj/∂wij равен выходу нейрона yi предыдущего слоя (n-1). Почему это так? Напомним, что в многослойной сети сигнал попадает с выхода нейрона предыдущего слоя на вход нейрона текущего слоя. Поэтому формулу (1) для sможно в более общем виде переписать так:

f15
(15)

где M — число нейронов в слое n-1 с учетом нейрона с постоянным выходным состоянием +1, задающего смещение; yi(n-1)=xij(n) — i-ый вход нейрона j слоя n, связанный с выходом i-го нейрона (n-1)-го слоя;

Что касается первого множителя в (13), его логично разложить по приращениям ошибок в соседнем, более старшем слое (ведь значения ошибок распространяются в обратную сторону):

f16
(16)

Здесь суммирование по k выполняется среди нейронов слоя n+1.

Нетрудно заметить, что первые два множителя в (13) для одного слоя (с индексами j при нейронах) повторяются в (16) для следующего слоя (с индексами k) в виде коэффициента перед весом wjk.

Введем промежуточную переменную, включающую эти два множителя:

f17
(17)

В результате мы получим рекурсивную формулу для расчетов величин δj(n) слоя n из величин δk(n+1) более старшего слоя n+1.

f18
(18)

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

f19
(19)

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

Теперь мы можем записать формулу (12) для коррекции весов в процессе обучения в раскрытом виде:

f20
(20)

Иногда для придания процессу коррекции весов некоторой инерционности, сглаживающей резкие скачки производной при перемещении по поверхности целевой функции, формула (20) дополняется значением изменения веса на предыдущей итерации:

f21
(21)

где µ — коэффициент инерционности, t — номер текущей итерации.

Таким образом, полный алгоритм обучения НС с помощью процедуры обратного распространения строится так:

1. Проинициализировать весовые матрицы малыми случайными числами.

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

f22
(22)

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

f23
(23)

Iq — q-ая компонента входного вектора, поданная на 0-ой слой.

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

4. Рассчитать для выходного слоя N: δ по формуле (19), а также изменения весов Δw по формулам (20) или (21).

5. Для всех остальных слоев, в обратном порядке, n=N-1,...1 рассчитать δ  и Δw по формулам (18) и (20) (или (18) и (21)) соответственно.

6. Скорректировать все веса в НС, для итерации t на основе предыдущей итерации t-1.

f24
(24)

7. Повторяем процесс в цикле с шага 2.

Диаграмма сигналов в сети при обучении по алгоритму обратного распространения приведена на следующем рисунке.

Схема сигналов в алгоритме обратного распространения ошибки

Схема сигналов в алгоритме обратного распространения ошибки

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

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


Обзор активационных функций

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

Правильный выбор активационной функции зависит от типа нейронной сети и решаемой задачи. Более того, в одной сети может применяться несколько разных АФ. Например, SoftMax отличается от прочих функций тем, что обрабатывает выходные значения слоя не поэлементно, а во взаимной увязке — нормализует их таким образом, что величины можно интерпретировать как вероятности (их сумма равна 1), что используется для множественной классификации.

Данная тема настолько обширна, что потребовала отдельной статьи или цикла статей. Здесь мы лишь ограничимся предупреждением, что все функции имеют как положительные, так и отрицательные стороны, которые потенциально могут приводить к неработоспособности сети. В частности, для S-образных функций характерна так называемая проблема обнуления градиента ("vanishing gradient", когда сигналы начинают попадать на участки "насыщения" S-кривой и потому подстройка весов стремится к нулю), а для монотонно возрастающих — проблема взрывного роста градиента ("exploding gradient", то есть постоянно увеличивающиеся веса, вплоть до численного переполнения и получения NaN (Not A Number)). Обе упомянутые проблемы становятся тем более вероятны, чем больше количество слоев в сети. Для их решения можно использовать различные приемы, такие как нормализация данных (в том числе, не только на входе, но и в промежуточных слоях), алгоритмы прореживания сетей (выбрасывание нейронов ("dropout"), пропуск связей), обучение пакетами данных, зашумление и прочие варианты регуляризации — некоторые из них мы рассмотрим и реализуем далее.

Демо-скрипт со всеми функциями активации

Демо-скрипт со всеми функциями активации


Реализация нейронной сети в классе MatrixNet

Приступим к написанию класса нейронной сети на основе матриц MQL5. Поскольку сеть состоит из слоев, опишем массивы весовых коэффициентов и выходных значений нейронов каждого слоя. Количество слоев будет храниться в переменной n, а веса нейронов в слоях и сигналы на выходе каждого слоя — в матрицах weights и outputs, соответственно. Обратите внимание, что термин outputs обозначает сигналы на выходах нейронов любого слоя, а не только на выходе сети. Так что outputs[i] описывают и промежуточные слои, и даже 0-й слой, куда подаются входные данные.

Индексация массивов weights и outputs иллюстрируется следующей схемой (связи каждого нейрона с источником смещения +1 не показаны для упрощения):

Схема индексации массивов матриц в двухслойной сети

Схема индексации массивов матриц в двухслойной сети

Количество n не включает входной слой, потому что он не требует весов.

  class MatrixNet
  {
  protected:
     const int n;
     matrix weights[/* n */];
     matrix outputs[/* n + 1 */];
     ENUM_ACTIVATION_FUNCTION af;
     ENUM_ACTIVATION_FUNCTION of;
     double speed;
     bool ready;
     ...

В нашей сети будут поддерживаться 2 типа активационных функций (на выбор пользователя): один — для всех слоев, кроме выходного (хранится в af), и отдельный — для выходного слоя (в переменной of). В переменной speed хранится скорость обучения (коэффициент η из формулы (20)).

Переменная ready содержит признак успешной инициализации объекта сети.

Конструктор сети принимает целочисленный массив layers, который определяет количество и размеры всех слоев. 0-й элемент задает размер входного псевдо-слоя, то есть количество признаков в каждом векторе входных данных. Последний элемент определяет размер выходного слоя, все остальные — промежуточных скрытых слоев. Количество слоев не может быть меньше двух. Для выделения памяти под массивы матриц написан вспомогательный метод allocate (мы будем его далее дополнять по мере расширения самого класса).

  public:
     MatrixNet(const int &layers[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
        const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE):
        ready(false), af(f1), of(f2), n(ArraySize(layers) - 1)
     {
        if(n < 2) return;
        
        allocate();
        for(int i = 1; i <= n; ++i)
        {
           // NB: матрица weights транспонирована, то есть индексы [row][column] указывают [synapse][neuron]
           weights[i - 1].Init(layers[i - 1] + 1, layers[i]);
        }
        ...
     }
        
  protected:
     void allocate()
     {
        ArrayResize(weights, n);
        ArrayResize(outputs, n + 1);
        ...
     }

Для инициализации каждой матрицы весов в качестве количества строк берется размер предыдущего слоя layers[i - 1] и к нему добавляется один синапс для постоянного регулируемого источника смещения +1. В качестве количества колонок берется размер текущего слоя layers[i]. В каждой матрице весов первый индекс относится к слою слева от матрицы, а второй — к слою справа.

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

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

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

  public:
     MatrixNet(const int &layers[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
        const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE):
        ready(false), af(f1), of(f2), n(ArraySize(layers) - 1)
     {
        ...
        ready = true;
        randomize();
     }
     
     // NB: задайте значения с подходящим распределением под конкретные активационные функции
     void randomize(const double from = -0.5, const double to = +0.5)
     {
        if(!ready) return;
        
        for(int i = 0; i < n; ++i)
        {
           weights[i].Random(from, to);
        }
     }

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

     bool feedForward(const matrix &data)
     {
        if(!ready) return false;
        
        if(data.Cols() != weights[0].Rows() - 1)
        {
           PrintFormat("Column number in data %d <> Inputs layer size %d",
              data.Cols(), weights[0].Rows() - 1);
           return false;
        }
        
        outputs[0] = data; // подаем данные на вход сети
        for(int i = 0; i < n; ++i)
        {
           // расширяем каждый слой (кроме последнего) одним нейроном для сигнала смещения
           // (справа от последнего слоя нет весовой матрицы, сигнал далее не идет)
           if(!outputs[i].Resize(outputs[i].Rows(), weights[i].Rows()) ||
              !outputs[i].Col(vector::Ones(outputs[i].Rows()), weights[i].Rows() - 1))
              return false;
           // проводим сигнал с i-го слоя на (i+1)-й: взвешенная сумма
           matrix temp = outputs[i].MatMul(weights[i]);
           // применяем активационную функцию, результат попадает в outputs[i + 1]
           if(!temp.Activation(outputs[i + 1], i < n - 1 ? af : of))
              return false;
        }
        
        return true;
     }

Количество столбцов во входной матрице data должно совпадать с количеством строк в 0-й матрице весов за вычетом 1 (вес к сигналу смещения).

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

     matrix getResults(const int layer = -1) const
     {
        static const matrix empty = {};
        if(!ready) return empty;
        
        if(layer == -1) return outputs[n];
        if(layer < -1 || layer > n) return empty;
        
        return outputs[layer];
     }

Оценить текущее качество модели можно методом test: он принимает на вход не только матрицу входных данных, но и матрицу с желаемым откликом сети.

     double test(const matrix &data, const matrix &target, const ENUM_LOSS_FUNCTION lf = LOSS_MSE)
     { 
        if(!ready || !feedForward(data)) return NaN();
        
        return outputs[n].Loss(target, lf);
     }

После выполнения прямого прохода сигнала с помощью feedForward, мы здесь вычисляем меру "потерь" заданного типа. По умолчанию, это среднеквадратическая ошибка (LOSS_MSE), которая применима для задач регрессии и прогнозирования. Однако если сеть будет использоваться для классификации образов, следует выбрать другой тип оценки, например, кроссэнтропию LOSS_CCE.

В случае ошибки вычислений метод возвращает "не число" (NaN).

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

     bool backProp(const matrix &target)
     {
        if(!ready) return false;
     
        if(target.Rows() != outputs[n].Rows() ||
           target.Cols() != outputs[n].Cols())
           return false;
        
        // выходной слой
        matrix temp;
        if(!outputs[n].Derivative(temp, of))
           return false;
        matrix loss = (outputs[n] - target) * temp; // все данные построчно

Матрица loss содержит величины δ из формулы (19).

Далее для всех слоев кроме выходного выполняется такой цикл:

        for(int i = n - 1; i >= 0; --i) // все слои кроме выходного, в обратном порядке
        {
           // убираем псевдо-потери в последнем элементе, добавленном как источник смещения,
           // потому что это не нейрон, и в нем дальнейшее распространение ошибки неприменимо
           // (делаем это во всех слоях кроме последнего, куда элемент смещения не добавлялся)
           if(i < n - 1) loss.Resize(loss.Rows(), loss.Cols() - 1);
           
           matrix delta = speed * outputs[i].Transpose().MatMul(loss);

Здесь мы видим "один в один" формулу (20): получаем приращения весов на основе скорости обучения η, δ текущего слоя и соответствующих выходов предыдущего (младшего) слоя.

Далее для каждого слоя вычисляем формулу (18), рекурсивно получая остальные δ: в ход опять идет производная АФ и умножение более старшей δ на транспонированную весовую матрицу. Следует напомнить, что индекс i в массиве outputs[] соответствует слою с весами в (i-1)-й матрице weights[], потому что входной псевдо-слой (outputs[0]) не имеет весов. Другими словами, при прямом прохождении сигнала матрица weights[0], будучи применена к outputs[0], производит outputs[1], weights[1] производит outputs[2] и так далее, а при обратном распространении ошибки индексы совпадают: например, outputs[2] (после дифференцирования) умножается на транспонированную weights[2].

           if(!outputs[i].Derivative(temp, af))
              return false;
           loss = loss.MatMul(weights[i].Transpose()) * temp;

Только после того как мы подсчитали loss δ для младшего слоя, можно подстраивать веса матрицы weights[i], то есть корректировать их на полученную выше delta.

           weights[i] -= delta;
        }
        return true;
     }

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


Обучение и регуляризация

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

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

Мы реализуем некоторые из них в своем классе.

Для начала предусмотрим передачу в метод обучения не только входных и выходных обучающих данных (параметры data и target, соответственно), но также валидационного набора (он тоже состоит из входных и парных им выходных векторов: validation и check).

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

Помимо двух наборов данных, метод train позволяет задать максимальное количество эпох обучения (epochs), желаемую точность (accuracy, т.е. среднюю минимальную ошибку, которой нам достаточно: при этом обучение также заканчивается с признаком успеха) и способ подсчета ошибки (lf).

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

     double train(const matrix &data, const matrix &target,
        const matrix &validation, const matrix &check,
        const int epochs = 1000, const double accuracy = 0.001,
        const ENUM_LOSS_FUNCTION lf = LOSS_MSE)
     {
        if(!ready) return NaN();
        
        speed = accuracy;
        ...

Величины ошибок сети в текущей эпохе сохраним в переменных mse и msev — для обучающего и валидационного наборов. Но чтобы не реагировать на неизбежные случайные флуктуации, потребуется усреднять ошибки за некоторый период p, рассчитываемый из общего заданного числа эпох. Сглаженные значения ошибок будут храниться в переменных msema и msevma, а их предыдущие значения — в переменных msemap и msevmap.

        double mse = DBL_MAX;
        double msev = DBL_MAX;
        double msema = 0;       // усреднение MSE обучающей выборки
        double msemap = 0;      // усреднение MSE обучающей выборки на предыдущей эпохе
        double msevma = 0;      // усреднение MSE валидационной выборки
        double msevmap = 0;     // усреднение MSE валидационной выборки на предыдущей эпохе
        double ema = 0;         // коэффициент экспоненциального сглаживания
        int p = 0;              // EMA период
        
        p = (int)sqrt(epochs);  // эмпирически выбираем период EMA-усреднения ошибок
        ema = 2.0 / (p + 1);
        PrintFormat("EMA for early stopping: %d (%f)", p, ema);

Далее запускаем цикл по эпохам обучения. Мы разрешаем не предоставлять валидационные данные, т.к. впоследствии реализуем другой способ регуляризации "dropout". Если валидационная выборка не пуста, рассчитываем msev вызовом метода test на ней. В любом случае рассчитываем mse с помощью вызова test на обучающей выборке. Напоминаем, что test осуществляет вызов метода feedForward и расчет ошибки результата сети относительно целевых значений.

        int ep = 0;
        for(; ep < epochs; ep++)
        {
           if(validation.Rows() && check.Rows())
           {
              // если есть валидация, делаем её до обычного прохода/обучения
              msev = test(validation, check, lf);
              // сглаживаем ошибки
              msevma = (msevma ? msevma : msev) * (1 - ema) + ema * msev;
           }
           mse = test(data, target, lf);  // включает запуск feedForward(data)
           msema = (msema ? msema : mse) * (1 - ema) + ema * mse;
           ...

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

           if(!MathIsValidNumber(mse))
           {
              PrintFormat("NaN at epoch %d", ep);
              break; // will return NaN as error indication
           }

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

           const int scale = (int)(data.Rows() / (validation.Rows() + 1)) + 1;
           if(msevmap != 0 && ep > p && msevma > msevmap + scale * (msemap - msema))
           {
              // пропускаем первые p эпох для аккумулирования значений для усреднения
              PrintFormat("Stop by validation at %d, v: %f > %f, t: %f vs %f", ep, msevma, msevmap, msema, msemap);
              break;
           }
           msevmap = msevma;
           msemap = msema;
           ...

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

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

           if(mse <= accuracy)
           {
              PrintFormat("Done by accuracy limit %f at epoch %d", accuracy, ep);
              break;
           }

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

           if(!progress(ep, epochs, mse, msev, msema, msevma))
           {
              PrintFormat("Interrupted by user at epoch %d", ep);
              break;
           }

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

           if(!backProp(target))
           {
              mse = NaN(); // error flag
              break;
           }
        }
        
        if(ep == epochs)
        {
           PrintFormat("Done by epoch limit %d with accuracy %f", ep, mse);
        }
        
        return mse;
     }

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

     virtual bool progress(const int epoch, const int total,
        const double error, const double valid = DBL_MAX,
        const double ma = DBL_MAX, const double mav = DBL_MAX)
     {
        static uint trap;
        if(GetTickCount() > trap)
        {
           PrintFormat("Epoch %d of %d, loss %.5f%s%s%s", epoch, total, error,
              ma == DBL_MAX ? "" : StringFormat(" ma(%.5f)", ma),
              valid == DBL_MAX ? "" : StringFormat(", validation %.5f", valid),
              valid == DBL_MAX ? "" : StringFormat(" v.ma(%.5f)", mav));
           trap = GetTickCount() + 1000;
        }
        return !IsStopped();
     }

Возвращаемое значение true продолжает обучение, в то время как false приведет к прерыванию цикла.

Помимо "ранней остановки", класс MatrixNet умеет случайным образом "отключать" часть связей а-ля "dropout".

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

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

     void enableDropOut(const uint percent = 10)
     {
        dropOutRate = (int)percent;
     }

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


Адаптивная скорость обучения

До сих пор подразумевалось, что у нас используется постоянная скорость обучения (переменная speed), но это непрактично (обучение может идти очень медленно при малой скорости или "возбуждаться" при большой).

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

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

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

  #define BATCH_PROP

Для начала обратим внимание, что вместо переменной speed в этом режиме используется массив матриц speed, а также потребуется запоминать приращения весов с прошлой эпохи в массиве матриц deltas.

  class MatrixNet
  {
  protected:
     ...
     #ifdef BATCH_PROP
     matrix speed[];
     matrix deltas[];
     #else
     double speed;
     #endif

Кроме того, коэффициенты ускорения и торможения, а также максимальная и минимальные скорости задаются выделенными для этого 4-мя переменными.

     double plus;
     double minus;
     double max;
     double min;

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

     void allocate()
     {
        ArrayResize(weights, n);
        ArrayResize(outputs, n + 1);
        ArrayResize(bestWeights, n);
        dropOutRate = 0;
        #ifdef BATCH_PROP
        ArrayResize(speed, n);
        ArrayResize(deltas, n);
        plus = 1.1;
        minus = 0.1;
        max = 50;
        min = 0.0;
        #endif
     }

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

В конструкторе MatrixNet массивы матриц speed и deltas инициализируются копированием массива матриц weights — просто так удобнее получить матрицы аналогичных размеров по слоям сети. Заполнение speed и deltas значащими данными происходит на последующих этапах. В частности, в начале метода train вместо простого присваивания точности (accuracy) в скалярную переменную speed теперь производится заполнение этим значением всех матриц в массиве speed.

     double train(const matrix &data, const matrix &target,
        const matrix &validation, const matrix &check,
        const int epochs = 1000, const double accuracy = 0.001,
        const ENUM_LOSS_FUNCTION lf = LOSS_MSE)
     {
        ...
        #ifdef BATCH_PROP
        for(int i = 0; i < n; ++i)
        {
           speed[i].Fill(accuracy); // подстроим скорости на лету
           deltas[i].Fill(0);
        }
        #else
        speed = accuracy;
        #endif
        ...
     }

Внутри метода backProp выражение с вычислением приращений теперь ссылается на матрицу соответствующего слоя, а не на скаляр. Сразу же после получения приращений delta вызывается метод adjustSpeed (приведен далее), в который для сопоставления прежнего и нового направления передается произведение delta * deltas[i]. Наконец, новые приращения весов сохраняются в deltas[i], чтобы анализировать их на следующей эпохе.

     bool backProp(const matrix &target)
     {
        ...
        for(int i = n - 1; i >= 0; --i) // все слои кроме выходного, в обратном порядке
        {
           ...
           #ifdef BATCH_PROP
           matrix delta = speed[i] * outputs[i].Transpose().MatMul(loss);
           adjustSpeed(speed[i], delta * deltas[i]);
           deltas[i] = delta;
           #else
           matrix delta = speed * outputs[i].Transpose().MatMul(loss);
           #endif
           ...
        }
        ...
     }

Метод adjustSpeed довольно прост. Положительный знак в элементе матричного произведения означает сохранение градиента, и скорость увеличивается в plus раз, но не более чем до величины max. Отрицательный знак означает смену градиента, и скорость уменьшается в minus раз, но не менее величины min.

     void adjustSpeed(matrix &subject, const matrix &product)
     {
        for(int i = 0; i < (int)product.Rows(); ++i)
        {
           for(int j = 0; j < (int)product.Cols(); ++j)
           {
              if(product[i][j] > 0)
              {
                 subject[i][j] *= plus;
                 if(subject[i][j] > max) subject[i][j] = max;
              }
              else if(product[i][j] < 0)
              {
                 subject[i][j] *= minus;
                 if(subject[i][j] < min) subject[i][j] = min;
              }
           }
        }
     }


Сохранение и восстановление лучшего состояния обученной сети

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

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

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

  class MatrixNet
  {
     ...
  public:
     struct Stats
     {
        double bestLoss; // наименьшая ошибка за все эпохи
        int bestEpoch;   // номер эпохи с минимальной ошибкой
        int epochsDone;  // сколько всего эпох выполнено
     };
     
     Stats getStats() const
     {
        return stats;
     }
     
  protected:
     matrix bestWeights[];
     Stats stats;
     ...

Внутри метода train, перед началом цикла по эпохам инициализируем структуру со статистикой.

     double train(const matrix &data, const matrix &target,
        const matrix &validation, const matrix &check,
        const int epochs = 1000, const double accuracy = 0.001,
        const ENUM_LOSS_FUNCTION lf = LOSS_MSE)
     {
        ...
        stats.bestLoss = DBL_MAX;
        stats.bestEpoch = -1;
        DropOutState state(dropOutRate);

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

        int ep = 0;
        for(; ep < epochs; ep++)
        {
           ...
           const double candidate = (msev != DBL_MAX) ? msev : mse;
           if(candidate < stats.bestLoss)
           {
              stats.bestLoss = candidate;
              stats.bestEpoch = ep;
              // запоминаем лучшие веса из 'weights'
              for(int i = 0; i < n; ++i)
              {
                 bestWeights[i].Assign(weights[i]);
              }
           }
        }
        ...

После обучения легко запросить как финальные веса сети, так и лучшие веса.

     bool getWeights(matrix &array[]) const
     {
        if(!ready) return false;
        
        ArrayResize(array, n);
        for(int i = 0; i < n; ++i)
        {
           array[i] = weights[i];
        }
        
        return true;
     }
     
     bool getBestWeights(matrix &array[]) const
     {
        if(!ready) return false;
        if(!n || !bestWeights[0].Rows()) return false;
        
        ArrayResize(array, n);
        for(int i = 0; i < n; ++i)
        {
           array[i] = bestWeights[i];
        }
        
        return true;
     }

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

     MatrixNet(const matrix &w[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
        const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE):
        ready(false), af(f1), of(f2), n(ArraySize(w))
     {
        if(n < 2) return;
        
        allocate();
        for(int i = 0; i < n; ++i)
        {
           weights[i] = w[i];
           #ifdef BATCH_PROP
           speed[i] = weights[i];  // вместо .Init(.Rows(), .Cols())
           deltas[i] = weights[i]; // вместо .Init(.Rows(), .Cols())
           #endif
        }
        
        ready = true;
     }

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


Визуализация прогресса обучения сети

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

Графическое отображение предоставляет стандартный класс CGraphic (поставляемый с MetaTrader 5), а точнее — небольшой производный от него класс CMyGraphic.

Объект данного класса является частью MatrixNetVisual. Также внутри "визуализируемой" сети описан массив из 5-ти кривых и массивы типа double, предназначенные непосредственно для отображаемых линий.

  class MatrixNetVisual: public MatrixNet
  {
     CMyGraphic graphic;
     CCurve *c[5];
     double p[], x[], y[], z[], q[], b[];
     ...

Здесь:

  • p — номер эпохи (общая горизонтальная ось X для всех кривых);
  • x — ошибка обучающего набора (Y);
  • y — ошибка валидационного набора (Y);
  • z — сглаженная ошибка валидации (Y);
  • q — сглаженная ошибка обучения (Y);
  • b — точка (эпоха) с минимальной ошибкой (Y);

  • В методе graph, вызываемом из конструкторов MatrixNetVisual, создается графический объект размером на все окно и добавляются 5 вышеописанных кривых (CCurve).

       void graph()
       {
          ulong width = ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
          ulong height = ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS);
    
          bool res = false;
          const string objname = "BPNNERROR";
          if(ObjectFind(0, objname) >= 0) res = graphic.Attach(0, objname);
          else res = graphic.Create(0, objname, 0, 0, 0, (int)(width - 0), (int)(height - 0));
          if(!res) return;
    
          c[0] = graphic.CurveAdd(p, x, CURVE_LINES, "Training");
          c[1] = graphic.CurveAdd(p, y, CURVE_LINES, "Validation");
          c[2] = graphic.CurveAdd(p, z, CURVE_LINES, "Val.EMA");
          c[3] = graphic.CurveAdd(p, q, CURVE_LINES, "Train.EMA");
          c[4] = graphic.CurveAdd(p, b, CURVE_POINTS, "Best/Minimum");
          ...
       }
    
    public:
       MatrixNetVisual(const int &layers[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
          const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE): MatrixNet(layers, f1, f2)
       {
          graph();
       }
    

    В переопределенном методе progress аргументы добавляются в соответствующие массивы double, а затем вызывается метод plot для актуализации изображения.

         virtual bool progress(const int epoch, const int total,
            const double error, const double valid = DBL_MAX,
            const double ma = DBL_MAX, const double mav = DBL_MAX) override
         {
            // пополняем все массивы
            PUSH(p, epoch);
            PUSH(x, error);
            if(valid != DBL_MAX) PUSH(y, valid); else PUSH(y, nan);
            if(ma != DBL_MAX) PUSH(q, ma); else PUSH(q, nan);
            if(mav != DBL_MAX) PUSH(z, mav); else PUSH(z, nan);
            plot();
            
            return MatrixNet::progress(epoch, total, error, valid, ma, mav);
         }
    
    

    Метод plot производит пополнение и отображение кривых.

       void plot()
       {
          c[0].Update(p, x);
          c[1].Update(p, y);
          c[2].Update(p, z);
          c[3].Update(p, q);
          double point[1] = {stats.bestEpoch};
          b[0] = stats.bestLoss;
          c[4].Update(point, b);
          ...
          graphic.CurvePlotAll();
          graphic.Update();
       }
    

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


    Тестовый скрипт

    Классы семейства MatrixNet готовы для первой проверки. Ею станет скрипт MatrixNet.mq5, в котором исходные данные генерируются искусственно на основе известной аналитической записи. Формула взята из раздела справки про машинное обучение, где приведен собственный пример обучения по алгоритму обратного распространения ошибки — не столь универсальный, как наши классы, и потому требующий значительного кодирования (сравните далее количество строк с использованием класса и без).

    f = ((x + y + z)^2 / (x^2 + y^2 + z^2)) / 3

    Единственное маленькое отличие нашей формулы заключается в делении значения на 3, что дает диапазон функции от 0 до 1.

    Форму функции можно оценить с помощью следующего рисунка, где поверхности (x<->y) показаны для трех разных значений z: 0.05, 0.5 и 5.0.

    Тестовая функция в 3-х сечениях

    Тестовая функция в 3-х сечениях

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

      input int Epochs = 1000;
      input double Accuracy = 0.001;
      input double RandomNoise = 0.0;
    

    Генерацией экспериментальных данных "заведует" функция CreateData. Её параметры-матрицы data и target будут заполнены точками описанной выше функции, в количестве count. Один входной вектор (строка матрицы data) имеет 3 колонки (для x, y, z). Выходной вектор (строка матрицы target) — это единственное значение f. Точки (x,y,z) генерируются случайно в диапазоне от -10 до +10.

      bool CreateData(matrix &data, matrix &target, const int count)
      { 
         if(!data.Init(count, 3) || !target.Init(count, 1))
            return false;
         data.Random(-10, 10);
         vector X1 = MathPow(data.Col(0) + data.Col(1) + data.Col(2), 2);
         vector X2 = MathPow(data.Col(0), 2) + MathPow(data.Col(1), 2) + MathPow(data.Col(2), 2);
         if(!target.Col(X1 / X2 / 3.0, 0))
            return false;
         if(RandomNoise > 0)
         {
            matrix noise;
            noise.Init(count, 3);
            noise.Random(0, RandomNoise);
            data += noise - RandomNoise / 2;
            
            noise.Resize(count, 1);
            noise.Random(-RandomNoise / 2, RandomNoise / 2);
            target += noise;
         }
         return true; 
      }
    

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

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

      #define BATCH_PROP
      #include <MatrixNet.mqh>
    

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

      void OnStart()
      {
         const int layers[] = {3, 11, 7, 1};
         MatrixNetVisual net(layers);
         matrix data, target;
         CreateData(data, target, 100);
         matrix valid, test;
         CreateData(valid, test, 25);
         ...
    

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

    Обучение выполняется методом train на матрицах data и target. Ранняя остановка произойдет по мере ухудшения показателей на наборе valid/test, однако на незашумленных данных мы, скорее всего, достигнем требуемой точности или же предельного числа циклов, в зависимости от того, что случится быстрее.

         Print("Training result: ", net.train(data, target, valid, test, Epochs, Accuracy));
         matrix w[];
         if(net.getBestWeights(w))
         {
            MatrixNet net2(w);
            if(net2.isReady())
            {
               Print("Best copy on training data: ", net2.test(data, target));
               Print("Best copy on validation data: ", net2.test(valid, test));
            }
         }
    

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

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

         while(!IsStopped())
         {
            Sleep(1000);
         }
      }
    

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

    Динамика изменения ошибки сети при обучении

    Динамика изменения ошибки сети при обучении

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

    В журнале увидим похожие записи:

      EMA for early stopping: 31 (0.062500)
      Epoch 0 of 1000, loss 0.20296 ma(0.20296), validation 0.18167 v.ma(0.18167)
      Epoch 120 of 1000, loss 0.02319 ma(0.02458), validation 0.04566 v.ma(0.04478)
      Stop by validation at 155, v: 0.034642 > 0.034371, t: 0.016614 vs 0.016674
      Training result: 0.015707719706513287
      Best copy on training data: 0.015461956812387292
      Best copy on validation data: 0.03211748853774414
    

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

    Вот, например, как выглядит график при добавлении шума 3.0.

    Динамика изменения ошибки сети при обучении с добавленным шумом

    Динамика изменения ошибки сети при обучении с добавленным шумом

    Показатель ошибки, согласно журналу, на порядок хуже.

      Epoch 0 of 1000, loss 2.40352 ma(2.40352), validation 2.23536 v.ma(2.23536)
      Stop by validation at 163, v: 1.082419 > 1.080340, t: 0.432023 vs 0.432526
      Training result: 0.4244786772678285
      Best copy on training data: 0.4300476339855798
      Best copy on validation data: 1.062895214094978
    

    Убедившись, что нейросетевой инструментарий работает, перейдем к более практичным примерам: индикатору и эксперту.


    Прогнозирующий индикатор

    В качестве примера прогнозирующего индикатора на основе НС рассмотрим BPNNMatrixPredictorDemo.mq5 — это модификация готового индикатора из кодовой базы, в котором НС реализована на MQL5 без применения матриц, путем портирования с языка C++ другой, более ранней версии того же индикатора (с подробным описанием, включая релевантные части теории НС).

    Принцип работы индикатора заключается в формировании входных векторов заданной длины из прошлых приращений EMA-усредненной цены на отрезках между барами, отстоящими друг от друга по последовательности Фибоначчи (1,2,3,5,8,13,21,34,55,89,144...). По этой информации требуется предсказать приращение цены на следующем баре (справа от исторических баров, попавших в соответствующий вектор). Размер вектора определяется заданным пользователем размером входного слоя НС (_numInputs). Количество слоев (вплоть до 6) и их размеры вводятся в другие входные переменные.

      input int _lastBar = 0;     // Last bar in the past data
      input int _futBars = 10;    // # of future bars to predict
      input int _smoothPer = 6;   // Smoothing period
      input int _numLayers = 3;   // # of layers including input, hidden & output (2..6)
      input int _numInputs = 12;  // # of inputs (that is neurons in input 0-th layer)
      input int _numNeurons1 = 5; // # of neurons in the 1-st hidden or output layer
      input int _numNeurons2 = 1; // # of neurons in the 2-nd hidden or output layer
      input int _numNeurons3 = 0; // # of neurons in the 3-rd hidden or output layer
      input int _numNeurons4 = 0; // # of neurons in the 4-th hidden or output layer
      input int _numNeurons5 = 0; // # of neurons in the 5-th hidden or output layer
      input int _ntr = 500;       // # of training sets / bars
      input int _nep = 1000;      // Max # of epochs
      input int _maxMSEpwr = -7;  // Error (as power of 10) for training to stop; mse < 10^this
    

    Также здесь указывается размер обучающей выборки (_ntr), максимальное количество эпох (_nep) и минимальная MSE-ошибка (_maxMSEpwr).

    Период EMA-усреднения цены задается в _smoothPer.

    По умолчанию, индикатор берет обучающие данные, начиная с последнего бара (_lastBar равно 0), и делает прогноз на _futBars вперед (очевидно, что имея на выходе сети прогноз на 1 бар, мы можем его поэтапно "задвигать" во входной вектор, чтобы предсказывать несколько последующих баров). Если ввести в _lastBar положительное число, получим прогноз по состоянию на соответствующее количество баров в прошлом, что позволит его визуально оценить (сравнить с уже имеющимися котировками).

    Индикатор выводит 3 буфера:

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

    Основные преобразования произошли в двух функциях Train и Test — теперь они полностью делегируют работу НС объектам класса MatrixNet. Train обучает сеть на основе собранных данных и возвращает массив с весами сети (при запуске в тестере обучение делается лишь однажды, а при запуске онлайн открытие нового бара вызывает повторное обучение — это легко поменять в исходном коде). Test воссоздает сеть по весам и выполняет штатный однократный расчет прогноза. В принципе, более оптимально было бы сохранить объект обученной сети, и эксплуатировать его без воссоздания. Мы сделаем так в следующем примере эксперта, а в случае индикатора специально оставлена исходная структура кода старой версии, чтобы было удобнее сравнивать подходы кодирования с матрицами и без. В частности, можно обратить внимание на то, что в матричной версии мы избавлены от необходимости "прогонять" векторы через сеть в цикле по одному и вручную делать "reshape" массивов с данными, в соответствии с их размерностью.

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

    Прогноз нейросетевого индикатора

    Прогноз нейросетевого индикатора

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


    Хранение сетей в файлах

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

    Для этой цели в рамках статьи создан класс MatrixNetStore, определенный в заголовочном файле MatrixNetStore.mqh. В классе имеются шаблонные методы save и load, предполагающие в качестве параметра шаблона M любой класс из семейства MatrixNet (пока их у нас только два, с учетом MatrixNetVisual, но желающие могут расширить набор). Оба метода имеют аргумент с именем файла и оперируют стандартными данными НС: количеством слоев, их размером, весовыми матрицами, функциями активации.

    Вот как делается сохранения сети.

      class MatrixNetStore
      {
         static string signature;
      public:
         template<typename M> // M is a MatrixNet
         static bool save(const string filename, const M &net, Storage *storage = NULL, const int flags = 0)
         {
            // получаем матрицу весов (лучших весов, если они есть)
            matrix w[];
            if(!net.getBestWeights(w))
            {
               if(!net.getWeights(w))
               {
                  return false;
               }
            }
            // открываем файл
            int h = FileOpen(filename, FILE_WRITE | FILE_BIN | FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_ANSI | flags);
            if(h == INVALID_HANDLE) return false;
            // записываем мета-данные сети
            FileWriteString(h, signature);
            FileWriteInteger(h, net.getActivationFunction());
            FileWriteInteger(h, net.getActivationFunction(true));
            FileWriteInteger(h, ArraySize(w));
            // записываем весовые матрицы
            for(int i = 0; i < ArraySize(w); ++i)
            {
               matrix m = w[i];
               FileWriteInteger(h, (int)m.Rows());
               FileWriteInteger(h, (int)m.Cols());
               double a[];
               m.Swap(a);
               FileWriteArray(h, a);
            }
            // если предоставлены пользовательские данные, записываем их
            if(storage)
            {
              if(!storage.store(h)) Print("External info wasn't saved");
            }
            
            FileClose(h);
            return true;
         }
         ...
      };
         
      static string MatrixNetStore::signature = "BPNNMS/1.0";
    

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

      class Storage
      {
      public:
         virtual bool store(const int h) = 0;
         virtual bool restore(const int h) = 0;
      };
    

    "Зеркальным" образом сеть можно восстановить из файла.

      class MatrixNetStore
      {
         ...
         template<typename M> // M is a MatrixNet
         static M *load(const string filename, Storage *storage = NULL, const int flags = 0)
         {
            int h = FileOpen(filename, FILE_READ | FILE_BIN | FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_ANSI | flags);
            if(h == INVALID_HANDLE) return NULL;
            // проверяем формат по сигнатуре
            const string header = FileReadString(h, StringLen(signature));
            if(header != signature)
            {
               FileClose(h);
               Print("Incorrect file header");
               return NULL;
            }
            // читаем стандартные набор мета-данных сети
            const ENUM_ACTIVATION_FUNCTION f1 = (ENUM_ACTIVATION_FUNCTION)FileReadInteger(h);
            const ENUM_ACTIVATION_FUNCTION f2 = (ENUM_ACTIVATION_FUNCTION)FileReadInteger(h);
            const int size = FileReadInteger(h);
            matrix w[];
            ArrayResize(w, size);
            // читаем весовые матрицы
            for(int i = 0; i < size; ++i)
            {
               const int rows = FileReadInteger(h);
               const int cols = FileReadInteger(h);
               double a[];
               FileReadArray(h, a, 0, rows * cols);
               w[i].Swap(a);
               w[i].Reshape(rows, cols);
            }
            // читаем пользовательские данные
            if(storage)
            {
               if(!storage.restore(h)) Print("External info wasn't read");
            }
            // создаем объект сети
            M *m = new M(w, f1, f2);
            
            FileClose(h);
            return m;
         }
    

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


    Прогнозирующий эксперт

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

    Исходными данными станут приращения цен на заданном количестве баров, причем опционально можно анализировать не только текущий символ, но и дополнительные, что теоретически позволит выявить взаимозависимости (например, если один тикер опосредованно "следует" за другим или их комбинациями). Единственный выход сети не будет интерпретироваться как целевая цена, а вместо этого, с целью упрощения системы, будет анализироваться знак: положительный — покупка, отрицательный — продажа.

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

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

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

      input string Symbols = "XAGUSD,XAUUSD,EURUSD";
      input int Depth = 5; // Vector size (bars)
      input int Reserve = 250; // Training set size (vectors)
    

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

    Среди других наиболее важных параметров — количество баров (Depth) по каждому инструменту, формирующих вектор. Например, если в строке Symbols задано 3 тикера, а в Depth стоит 5 (по умолчанию), значит общий размер входного вектора НС равен 15.

    Параметр Reserve позволяет задать длину выборки (количество векторов, формируемых из ближайшей истории котировок). Значение 250 выбрано по умолчанию, потому что в нашем тесте будет использоваться дневной таймфрейм, и 250 составляет примерно 1 год. Соответственно, Depth, равный 5 — это неделя.

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

    Важно также напомнить, что при запуске в тестере заранее подгружается примерно 1 год котировок, поэтому увеличение объема запрашиваемых обучающих данных на D1+ потребует пропускать некоторое количество начальных баров, ожидая накопления их достаточного количества.

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

      input int Epochs = 1000;
      input double Accuracy = 0.0001; // Accuracy (and training speed)
    

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

      input double HiddenLayerFactor = 2.0; // Hidden Layers Factor (to vector size)
      input int DropOutPercentage = 0; // DropOut Percentage
    

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

    Для загрузки сети из файла предназначен параметр NetBinFileName. Файлы всегда ищутся относительно общей папки терминалов, поскольку иначе для тестирования эксперта в тестере потребовалось бы указывать заранее имена всех нужных сетей в исходном коде, в директиве #property tester_file — только так они отправились бы на агент.

    Когда параметр NetBinFileName пуст, эксперт обучает новую сеть и сохраняет её в файле с уникальным временным именем. Это делается даже в процессе оптимизации, что позволяет генерировать большое количество конфигураций сети (для разных размеров векторов, слоев, "dropout"-а, глубины истории).

      input string NetBinFileName = "";
      input int Randomizer = 0;
    

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

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

    Хранение ценовой информации по символам организовано с помощью структуры Closes и массива таких структур CC: в результате получим что-то вроде массива массивов.

      struct Closes
      {
         double C[];
      };
         
      Closes CC[];
    

    Под рабочие инструменты и их количество зарезервированы глобальный массив S и переменная Q: они заполняются в OnInit.

      string S[];
      int Q;
         
      int OnInit()
      {
         Q = StringSplit(StringLen(Symbols) ? Symbols : _Symbol, ',', S);
         ArrayResize(CC, Q);
         MathSrand(Randomizer);
         ...
         return INIT_SUCCEEDED;
      }
    

    Запросить котировки на заданную глубину Depth от конкретного бара offset позволяет функция Calc: именно в ней заполняется массив CC. Как эта функция вызывается, мы увидим чуть дальше.

      bool Calc(const int offset)
      {
         const datetime dt = iTime(_Symbol, _Period, offset);
         for(int i = 0; i < Q; ++i)
         {
            const int bar = iBarShift(S[i], PERIOD_CURRENT, dt);
            // +1 для разностей, +1 для модели
            const int n = CopyClose(S[i], PERIOD_CURRENT, bar, Depth + 2, CC[i].C);
            
            for(int j = 0; j < n - 1; ++j)
            {
               CC[i].C[j] = (CC[i].C[j + 1] - CC[i].C[j]) /
                  SymbolInfoDouble(S[i], SYMBOL_TRADE_TICK_SIZE) * SymbolInfoDouble(S[i], SYMBOL_TRADE_TICK_VALUE);
            }
            
            ArrayResize(CC[i].C, n - 1);
         }
         
         return true;
      }
    

    Затем для конкретного массива CC[i].C специальная функция Diff сможет рассчитать приращения цен, которые и попадут во входные векторы для сети. Особенностью функции является то, что все приращения, кроме последнего, она записывает в переданный по ссылке массив d, а последнее приращение, которое будет выступать целевым значением прогноза, возвращается напрямую.

      double Diff(const double &a[], double &d[])
      {
         const int n = ArraySize(a);
         ArrayResize(d, n - 1); // -1 за вычетом "будущей" модели
         double overall = 0;
         for(int j = 0; j < n - 1; ++j) // слева (от старого) направо (к новому)
         {
            int k = n - 2 - j;
            overall += a[k];
            d[j] = overall / sqrt(j + 1);
         }
         ... // дополнительная нормализация
         return a[n - 1];
      }
    

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

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

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

      void OnTick()
      {
         ...
         static datetime last = 0;
         if(last == iTime(_Symbol, _Period, 0)) return;
         ...
    

    Экземпляр сети хранится в переменной run типа авто-указателя (заголовочный файл AutoPtr.mqh), что избавляет нас от необходимости контролировать освобождение памяти. Переменная std предназначена для хранения дисперсии, рассчитываемой на наборе данных, полученном из рассмотренных выше функций Calc и Diff. Дисперсия потребуется для нормализации данных.

         static AutoPtr<MatrixNet> run;
         static double std;
    

    Если пользователь задал в NetBinFileName имя файла для загрузки, программа попытается загрузить сеть с помощью LoadNet (см. далее). Эта функция в случае успеха возвращает указатель на объект сети.

         if(NetBinFileName != "")
         {
            if(!run[])
            {
               run = LoadNet(NetBinFileName, std);
               if(!run[])
               {
                  ExpertRemove();
                  return;
               }
            }
         }
    

    При наличии сети выполняем прогнозирование и осуществляем торговлю: за всё это отвечает TradeTest (см. далее).

         if(run[])
         {
            TradeTest(run[], std);
         }
         else
         {
            run = TrainNet(std);      
         }
         
         last = iTime(_Symbol, _Period, 0);
      }
    

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

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

    Проверка на достаточное количество баров вставлена в начало OnTick, но в статье не приводится (см. полные исходные коды).

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

    А вот и сама функция TrainNet (обратите внимание на вызовы Calc и Diff).

      MatrixNet *TrainNet(double &std)
      {
         double coefs[];
         matrix sys(Reserve, Q * Depth);
         vector model(Reserve);
         vector t;
         datetime start = 0;
        
         for(int j = Reserve - 1; j >= 0; --j) // цикл по историческим барам
         {
            // поскольку используются цены close, делаем +1 к индексу баров
            if(!Calc(j + 1)) // собрать данные для всех символов, начиня с бара j на Depth баров
            {
               return NULL; // вероятно другие символы не имеют достаточно истории (ждем)
            }
            // запоминаем дату/время начала обучающей выборки
            if(start == 0) start = iTime(_Symbol, _Period, j);
          
            ArrayResize(coefs, 0);
          
            // считаем разницы цен для всех символов на Depth баров
            for(int i = 0; i < Q; ++i)
            {
               double temp[];
               double m = Diff(CC[i].C, temp);
               if(i == 0)
               {
                  model[j] = m;
               }
               int dest = ArraySize(coefs);
               ArrayCopy(coefs, temp, dest, 0);
            }
          
            t.Assign(coefs);
            sys.Row(t, j);
         }
         
         // проводим нормализацию
         std = sys.Std() * 3;
         Print("Normalization by 3 std: ", std);
         sys /= std;
         matrix target = {};
         target.Col(model, 0);
         target /= std;
        
         // размер слоев 0, 1, 2, 3 выводится из данных, выход всегда один
         int layers[] = {0, 0, 0, 0, 1};
         layers[0] = (int)sys.Cols();
         layers[1] = (int)(sys.Cols() * HiddenLayerFactor);
         layers[2] = (int)(sys.Cols() * HiddenLayerFactor);
         layers[3] = (int)fmax(sqrt(sys.Rows()), fmax(sqrt(layers[1] * layers[3]), sys.Cols() * sqrt(HiddenLayerFactor)));
         
         // создаем и настраиваем сеть указанной конфигурации
         ArrayPrint(layers);
         MatrixNetVisual *net = new MatrixNetVisual(layers);
         net.setupSpeedAdjustment(SpeedUp, SpeedDown, SpeedHigh, SpeedLow);
         net.enableDropOut(DropOutPercentage);
    
         // обучаем сеть и выводим результат (ошибку)
         Print("Training result: ", net.train(sys, target, Epochs, Accuracy));
         ...
    

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

    Далее требуется прочитать из сети лучшие весовые матрицы. Дополнительно мы проверяем возможность успешно воссоздать сеть по этим весам и тестируем её производительность на тех же данных.

         matrix w[];
         if(net.getBestWeights(w))
         {
            MatrixNet net2(w);
            if(net2.isReady())
            {
               Print("Best result: ", net2.test(sys, target));
               ...
            }
         }
         return net;
      }
    

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

            // наиболее важные или все настройки эксперта можно добавить в файл сети
            const string context = StringFormat("\r\n%s %s %s-%s", _Symbol, EnumToString(_Period),
               TimeToString(start), TimeToString(iTime(_Symbol, _Period, 0))) + "\r\n" +
               Symbols + "\r\n" + (string)Depth + "/" + (string)Reserve + "\r\n" +
               (string)Epochs + "/" + (string)Accuracy + "\r\n" +
               (string)HiddenLayerFactor + "/" + (string)DropOutPercentage + "\r\n";
               
            // готовим временное имя файла
            const string tempfile = "bpnnmtmp" + (string)GetTickCount64() + ".bpn";
            
            // сохраняем сеть и пользовательские данные в файл
            MatrixNetStore store;                                   // основной класс выгрузки/загрузки сетей
            BinFileNetStorage writer(context, net.getStats(), std); // опциональный класс с нашей информацией
            store.save(tempfile, *net, &writer);
            ...
    

    Упомянутый здесь класс BinFileNetStorage является специфическим для нашего эксперта и с помощью переопределенных методов store/restore (родительский интерфейс Storage) обрабатывает наше дополнительное описание, величину нормализации (она потребуется для штатной работы на новых данных), а также статистику обучения в виде структуры MatrixNet::Stats (приводилась выше).

    Далее поведение эксперта зависит от того, работает ли он в режиме оптимизации или нет. При оптимизации мы отправим файл сети с агента в терминал с помощью механизма фреймов (см. исходный код). Такие файлы складируются в локальной папке MQL5/Files/, в подпапке с именем эксперта.

            if(!MQLInfoInteger(MQL_OPTIMIZATION))
            {
               // переименовываем в более понятном формате времени и в общей папке
               string filename = "bpnnm" + TimeStamp((datetime)FileGetInteger(tempfile, FILE_MODIFY_DATE))
                  + StringFormat("(%7g)", net.getStats().bestLoss) + ".bpn";
               if(!FileMove(tempfile, 0, filename, FILE_COMMON))
               {
                  PrintFormat("Can't rename temp-file: %s [%d]", tempfile, _LastError);
               }
            }
            else
            {
               ... // файл будет отправлен из агента в терминал в виде фрейма
            }
    

    В остальных случаях (простое тестирование или работа онлайн) файл перемещается в общую папку терминалов. Так сделано, чтобы облегчить последующую загрузку через параметр NetBinFileName. Дело в том, что для работы в тестере нам потребовалось бы в исходном коде указывать директиву #property tester_file с конкретным именем файла, который планируется вводить в параметр NetBinFileName, и перекомпилировать эксперт. Без этих манипуляций файл сети не будет скопирован на агент. Поэтому более практичным является использование общей папки, доступной из всех локальных агентов.

    Функция LoadNet реализована следующим образом:

      MatrixNet *LoadNet(const string filename, double &std, const int flags = FILE_COMMON)
      {
         BinFileNetStorage reader; // опциональные пользовательские данные
         MatrixNetStore store;     // общие мета-данные
         MatrixNet *net;
         std = 1.0;
         Print("Loading ", filename);
         ResetLastError();
         net = store.load<MatrixNet>(filename, &reader, flags);
         if(net == NULL)
         {
            Print("Failed: ", _LastError);
            return NULL;
         }
         MatrixNet::Stats s[1];
         s[0] = reader.getStats();
         ArrayPrint(s);
         std = reader.getScale();
         Print(std);
         Print(reader.getDescription());
         return net;
      }
    

    Функция TradeTest вызывает Calc(0), чтобы затем получить вектор из актуальных приращений цен.

      bool TradeTest(MatrixNet *net, const double std)
      {
         if(!Calc(0)) return false;
         double coefs[];
         for(int i = 0; i < Q; ++i)
         {
            double temp[];
            // разница на 0-м баре игнорируется - она будет прогнозироваться
            /* double m = */Diff(CC[i].C, temp, true);
            ArrayCopy(coefs, temp, ArraySize(coefs), 0);
         }
          
         vector t;
         t.Assign(coefs);
          
         matrix data = {};
         data.Row(t, 0);
         data /= std;
         ...
    

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

         ClosePosition();
         
         if(net.feedForward(data))
         {
            matrix y = net.getResults();
            Print("Prediction: ", y[0][0] * std);
            
            OpenPosition((y[0][0] > 0) ? ORDER_TYPE_BUY : ORDER_TYPE_SELL);
            return true;
         }
         return false;
      }
    

    Функции OpenPosition и ClosePosition реализованы схожим образом. Приведем только ClosePosition.

      bool ClosePosition()
      {
         // определяем пустую структуру
         MqlTradeRequest request = {};
         
         if(!PositionSelect(_Symbol)) return false;
         const string pl = StringFormat("%+.2f", PositionGetDouble(POSITION_PROFIT));
         
         // заполяем требуемые поля
         request.action = TRADE_ACTION_DEAL;
         request.position = PositionGetInteger(POSITION_TICKET);
         const ENUM_ORDER_TYPE type = (ENUM_ORDER_TYPE)(PositionGetInteger(POSITION_TYPE) ^ 1);
         request.type = type;
         request.price = SymbolInfoDouble(_Symbol, type == ORDER_TYPE_BUY ? SYMBOL_ASK : SYMBOL_BID);
         request.volume = PositionGetDouble(POSITION_VOLUME);
         request.deviation = 5;
         request.comment = pl;
         
         // отправляем запрос
         ResetLastError();
         MqlTradeResult result[1];
         const bool ok = OrderSend(request, result[0]);
         
         Print("Status: ", _LastError, ", P/L: ", pl);
         ArrayPrint(result);
         
         if(ok && (result[0].retcode == TRADE_RETCODE_DONE
                || result[0].retcode == TRADE_RETCODE_PLACED))
         {
            return true;
         }
         
         return false;
      }
    

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

    В журнале появятся записи следующего вида, связанные с обучением НС.

      Sufficient bars at: 2022.01.04 00:00:00
      Normalization by 3 std: 1.3415995381755823
      15 30 30 21  1
      EMA for early stopping: 31 (0.062500)
      Epoch 0 of 1000, loss 2.04525 ma(2.04525)
      Epoch 121 of 1000, loss 0.31818 ma(0.36230)
      Epoch 243 of 1000, loss 0.16857 ma(0.18029)
      Epoch 367 of 1000, loss 0.09157 ma(0.09709)
      Epoch 479 of 1000, loss 0.06454 ma(0.06888)
      Epoch 590 of 1000, loss 0.04875 ma(0.05092)
      Epoch 706 of 1000, loss 0.03659 ma(0.03806)
      Epoch 821 of 1000, loss 0.03043 ma(0.03138)
      Epoch 935 of 1000, loss 0.02721 ma(0.02697)
      Done by epoch limit 1000 with accuracy 0.024416
      Training result: 0.024416206367547762
      Best result: 0.024416206367547762
      Check-up of saved and restored copy: bpnnm202302121707(0.0244162).bpn
      Loading bpnnm202302121707(0.0244162).bpn
          [bestLoss] [bestEpoch] [trainingSet] [validationSet] [epochsDone]
      [0]      0.024         999           250               0         1000
      1.3415995381755823
         
      XAGUSD PERIOD_D1 2021.01.18 00:00-2022.01.04 00:00
      XAGUSD,XAUUSD,EURUSD
      5/250
      1000/0.0001
      2.0/0
         
      Best result restored: 0.024416206367547762
    

    Пока обратите внимание на величину финальной ошибки. Позднее мы повторим тест с включенным режимом "dropout" разной интенсивности и сравним результаты.

    Торговый отчет выглядит таким образом.

    Пример отчета торговли по прогнозу

    Пример отчета торговли по прогнозу

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

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

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

    Кривая баланса при торговле на расширенном интервале, включая обучающую выборку

    Кривая баланса при торговле на расширенном интервале, включая обучающую выборку

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

    Давайте повторим обучение сети дважды: с "dropout"-ом в размере 25% и 50% (параметр DropOutPercentage должен быть последовательно выставлен в 25, а потом — в 50). Для инициирования обучения новых сетей очистим параметр NetBinFileName, а начало теста вернем на дату 2022.01.01.

    При "dropout" 25% получим ошибку заметно больше, чем в первом случае. Но это ожидаемо, так как мы пытаемся за счет огрубления модели расширить её применимость на данные вне выборки (out-of-sample).

      Epoch 0 of 1000, loss 2.04525 ma(2.04525)
      Epoch 125 of 1000, loss 0.46777 ma(0.48644)
      Epoch 251 of 1000, loss 0.36113 ma(0.36982)
      Epoch 381 of 1000, loss 0.30045 ma(0.30557)
      Epoch 503 of 1000, loss 0.27245 ma(0.27566)
      Epoch 624 of 1000, loss 0.24399 ma(0.24698)
      Epoch 744 of 1000, loss 0.22291 ma(0.22590)
      Epoch 840 of 1000, loss 0.19507 ma(0.20062)
      Epoch 930 of 1000, loss 0.18931 ma(0.19018)
      Done by epoch limit 1000 with accuracy 0.182581
      Training result: 0.18258059873803228
    

    При "dropout" 50% ошибка еще больше увеличивается.

      Epoch 0 of 1000, loss 2.04525 ma(2.04525)
      Epoch 118 of 1000, loss 0.54929 ma(0.55782)
      Epoch 242 of 1000, loss 0.43541 ma(0.45008)
      Epoch 367 of 1000, loss 0.38081 ma(0.38477)
      Epoch 491 of 1000, loss 0.34920 ma(0.35316)
      Epoch 611 of 1000, loss 0.30940 ma(0.31467)
      Epoch 729 of 1000, loss 0.29559 ma(0.29751)
      Epoch 842 of 1000, loss 0.27465 ma(0.27760)
      Epoch 956 of 1000, loss 0.25901 ma(0.26199)
      Done by epoch limit 1000 with accuracy 0.251914
      Training result: 0.25191436104184456
    

    На следующем изображении совмещены графики обучения в 3-х вариантах.

    Динамика обучения при различной величине dropout

    Динамика обучения при различной величине dropout

    А вот каковы кривые балансов (желтым подсвечена обучающая выборка).

    Кривые балансов торговли по прогнозам сетей с разным dropout

    Кривые балансов торговли по прогнозам сетей с разным dropout

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

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

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

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

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

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

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

    set-файл с настройками прилагается. Интервал дат от 2022.01.01 до 2023.02.15.

    Критерием оптимизации выберем, например, Profit Factor, хотя с учетом малого количества сочетаний (6000) и их полного перебора (в отличие от генетики), это не важно.

    Анализ результатов оптимизации можно проводить, экспортировав в файл XML или непосредственно из opt-файла, например, как это предлагается в OLAP-программе из статьи Количественный и визуальный анализ отчетов тестера или другими скриптами (opt-формат открыт).

    Статистический анализ отчета оптимизации

    Статистический анализ отчета оптимизации

    Для этого скриншота агрегирование показателей в запрошенных разбивках (Reserve по X (горизонтальная ось) относительно HiddenLayerFactor по Y (помечено цветом) при DropOutPercentage 25% по Z) осуществлялось с помощью специфического расчета профит-фактора (по ячейкам в осях X/Y/Z) от фактора восстановления (из каждого прохода тестера в рамках оптимизации). Такая искусственная мера добротности не идеальна, но зато доступна "из коробки".

    Аналогичные или более привычные статистики можно рассчитать в Excel.

    Статистически более выигрышным является фактор скрытых слоев, равный 1 (а не 2, как было по умолчанию), а размер вектора — равный 4 (вместо 5). При этом рекомендуемая величина "dropout" — 25% или 50%, но не 0%.

    Также ожидаемо, более глубокая история предпочтительнее (350 или 400 отсчетов и, вероятно, дальнейшее увеличение оправдано).

    Подытожим найденные рабочие настройки:

    Поскольку в оптимизации был задействован параметр Randomizer, у нас есть 30 экземпляров сетей, обученных в данной конфигурации, — по 10 сетей для каждого уровня "dropout" (0%, 25%, 50%). Нам нужны 25% и 50%. Выгрузив отчет оптимизации в XML, мы можем отфильтровать нужные записи и получим таблицу (сортировка по прибыльности с фильтром больше 1):

    Pass    Result  Profit  Expected Profit  Recovery Sharpe Custom  Equity Trades Depth  Reserve Hidden  DropOut Randomizer
    			Payoff	 Factor	 Factor	 Ratio	 	 DD %			      LayerF	Perc
    3838    1.35    336.02  2.41741  1.34991 1.98582 1.20187 1       1.61    139     4       400     1       25      6
    838     1.23    234.40  1.68633  1.23117 0.81474 0.86474 1       2.77    139     4       400     1       25      1
    3438    1.20    209.34  1.50604  1.20481 0.81329 0.78140 1       2.47    139     4       400     1       50      5
    5838    1.17    173.88  1.25094  1.16758 0.61594 0.62326 1       2.76    139     4       400     1       50      9
    5038    1.16    167.98  1.20849  1.16070 0.51542 0.60483 1       3.18    139     4       400     1       25      8
    3238    1.13    141.35  1.01691  1.13314 0.46758 0.48160 1       2.95    139     4       400     1       25      5
    2038    1.11    118.49  0.85245  1.11088 0.38826 0.41380 1       2.96    139     4       400     1       25      3
    4038    1.10    107.46  0.77309  1.09951 0.49377 0.38716 1       2.12    139     4       400     1       50      6
    1438    1.10    104.52  0.75194  1.09700 0.51681 0.37404 1       1.99    139     4       400     1       25      2
    238     1.07    73.33   0.52755  1.06721 0.19040 0.26499 1       3.69    139     4       400     1       25      0
    2838    1.03    34.62   0.24907  1.03111 0.10290 0.13053 1       3.29    139     4       400     1       50      4
    2238    1.02    21.62   0.15554  1.01927 0.05130 0.07578 1       4.12    139     4       400     1       50      3
    

    Возьмем лучшую, первую строку.

    Напомним, что в ходе оптимизации все обученные сети сохраняются в папке MQL5/Files/<имя эксперта>/<дата оптимизации>. В принципе, этого можно не делать, учитывая, что по значению Randomizer можно вновь обучить аналогичную сеть, но только при условии полного соответствия входных данных. Если же история котировок изменится (например, другой брокер), воспроизвести сеть именно с такими характеристиками не получится.

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

    Depth=4-Reserve=400-HiddenLayerFactor=1-DropOutPercentage=25-Randomizer=6

    Допустим, файл имеет имя:

    Depth=4-Reserve=400-HiddenLayerFactor=1-DropOutPercentage=25-Randomizer=6-3838(0.428079).bpn

    где число в круглых скобках — ошибка сети, число перед скобками — номер прохода.

    Заглянем внутрь файла: несмотря на то, что файл бинарный, в его концовке сохранены в виде текста наши мета-данные обучения, в частности указано, что интервал обучения составил 2021.01.12 00:00-2022.07.28 00:00 (400 баров D1).

    Скопируем файл под более кратким именем, например, test3838.bpn, в общую папку терминалов.

    Введем имя test3838.bpn в параметр NetBinFileName, а в параметр Vector size (Depth) — 4 (все остальные параметры неважны, если работа ведется исключительно в режиме прогнозирования).

    Проверим торговлю эксперта на еще более увеличенном периоде: поскольку 2022—2023 год выступил в роли валидационного форвард-теста, захватим 2020 в качестве неизвестного периода.

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

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

    Чуда не случилось — система убыточна на "новых" данных. Нетрудно убедиться, что подобная картина характерна и для других настроек.

    Итак, у нас две новости: хорошая и плохая.

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

    А хорошая новость — в том, что предложенный нейросетевой инструментарий позволяет оценивать идеи и выдает ожидаемые (с технической точки зрения) результаты.


    Заключение

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

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

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