Создание обучающей и тестовой выборки

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

Выделение отдельной выборки для обучения — это общепринятая практика. В процессе обучения нейронной сети подбираются такие весовые коэффициенты, чтобы нейронная сеть максимально точно описывала обучающую выборку. При использовании достаточно большого количества весовых коэффициентов нейронная сеть способна выучить обучающую выборку до мельчайших деталей. Но при этом нейронная сеть теряет способность обобщения данных. В таком состояние нейронную сеть называют «переобученной». Выявить это на обучающей выборке невозможно. Но если сравнить результаты работы нейронной сети на обучающей выборке и на данных, не входящих в обучающую выборку, то различие результатов об этом четко скажет. Допускается небольшое ухудшение результатов на тестовой выборке, но оно не должно быть кардинальным. Конечно, данные в выборках должны быть сопоставимы. Чаще всего для этого берут общую совокупность доступных данных и случайным образом делят на две выборки в соотношении 70-80% для обучающей выборки и 20-30% для тестовой. В большинстве случаев нужно будет разделить генеральную совокупность на три подвыборки:

  • обучающая 60%
  • валидационная 20%
  • тестовая 20%

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

Для генерации выборок создадим скрипт create_initial_data.mq5. В параметрах скрипта укажем:

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

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

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

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

//+------------------------------------------------------------------+
//| Внешние параметры для работы скрипта                             |
//+------------------------------------------------------------------+
// Начало периода генеральной совокупности
input datetime Start = D'2015.01.01 00:00:00';  
// Окончание периода генеральной совокупности
input datetime End = D'2020.12.31 23:59:00';    
// Таймфрейм для загрузки данных
input ENUM_TIMEFRAMES TimeFrame = PERIOD_M5;    
// Количество исторических баров в одном паттерне
input int      BarsToLine = 40;                 
// Имя файла для записи обучающей выборки
input string   StudyFileName = "study_data.csv";
// Имя файла для записи тестовой выборки
input string   TestFileName  = "test_data.csv"
// Флаг нормализации данных
input bool     NormalizeData = true;            
//+------------------------------------------------------------------+
//| Начало программы скрипта                                         |
//+------------------------------------------------------------------+
void OnStart(void)
  {
//--- Подключение индикаторов к графику
   int h_ZZ = iCustom(_SymbolTimeFrame"Examples\\ZigZag.ex5"48147);
   int h_RSI = iRSI(_SymbolTimeFrame12PRICE_TYPICAL);
   int h_MACD = iMACD(_SymbolTimeFrame124812PRICE_TYPICAL);
   double close[];
   if(CopyClose(_SymbolTimeFrameStartEndclose) <= 0)
      return;

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

//--- Загружаем данные индикаторов в динамические массивы
   double zz[], macd_main[], macd_signal[], rsi[];
   datetime end_zz = End + PeriodSeconds(TimeFrame) * 500;
   if(h_ZZ == INVALID_HANDLE || 
      CopyBuffer(h_ZZ0Startend_zzzz) <= 0)
     {
      PrintFormat("Error loading indicator %s data""ZigZag");
      return;
     }
   if(h_RSI == INVALID_HANDLE || 
      CopyBuffer(h_RSI0StartEndrsi) <= 0)
     {
      PrintFormat("Error loading indicator %s data""RSI");
      return;
     }
   if(h_MACD == INVALID_HANDLE || 
      CopyBuffer(h_MACDMAIN_LINEStartEndmacd_main) <= 0 ||
      CopyBuffer(h_MACDSIGNAL_LINEStartEndmacd_signal) <= 0)
     {
      PrintFormat("Error loading indicator %s data""MACD");
      return;
     }

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

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

   int total = ArraySize(close);
   double target1[], target2[], macd_delta[], test[];
   if(ArrayResize(target1total) <= 0 || 
      ArrayResize(target2total) <= 0 ||
      ArrayResize(testtotal) <= 0 || 
      ArrayResize(macd_deltatotal) <= 0)
      return;
//--- Рассчитываем цели: направление и расстояние 
//--- до ближайшего экстремума
   double extremum = -1;
   for(int i = ArraySize(zz) - 2i >= 0i--)
     {
      if(zz[i + 1] > 0 && zz[i + 1] != EMPTY_VALUE)
         extremum = zz[i + 1];
      if(i >= total)
         continue;
      target2[i] = extremum - close[i];
      target1[i] = (target2[i] >= 0 ? 1 : -1);
      macd_delta[i] = macd_main[i] - macd_signal[i];
     }

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

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

После расчета целевых показателей и расстояния между линиями индикатора MACD проведем нормализацию данных. Конечно, нормализацию мы будем проводить только в том случае, когда это требование указано пользователем в параметрах скрипта. Цель нормализации — преобразовать исходные данные таким образом, чтобы их значения были в диапазоне от −1 до 1 с центром в точке 0. Следует обратить внимание на ряд вводных, истекающих из особенностей самих индикаторов.

Индикатор RSI построен таким образом, что его значения нормализованы в диапазоне от 0 до 100. Следовательно, нам нет необходимости определять максимальное и минимальное значение данных этого индикатора для его нормализации. Поэтому алгоритм нормализации показаний данного индикатора ограничивается константой 50 — середина диапазона возможных значений индикатора. Формула нормализации значений будет следующей.

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

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

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

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

//--- Нормализация данных
   if(NormalizeData)
     {
      double main_norm = MathMax(MathAbs(macd_main[ArrayMinimum(macd_main)]),
                                         macd_main[ArrayMaximum(macd_main)]);
      double sign_norm = MathMax(MathAbs(macd_signal[ArrayMinimum(macd_signal)]),
                                         macd_signal[ArrayMaximum(macd_signal)]);
      double delt_norm = MathMax(MathAbs(macd_delta[ArrayMinimum(macd_delta)]),
                                         macd_delta[ArrayMaximum(macd_delta)]);
      for(int i = 0i < totali++)
        {
         rsi[i] = (rsi[i] - 50.0) / 50.0;
         macd_main[i] /= main_norm;
         macd_signal[i] /= sign_norm;
         macd_delta[i] /= delt_norm;
        }
     }

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

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

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

  • 0 — обучающая выборка
  • 1 — тестовая выборка

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

Наш массив флагов изначально будет инициализирован нулевыми значениями. Иными словами, мы определяем, что по умолчанию паттерн относится к обучающей выборке. Затем мы определяем количество паттернов для тестовой совокупности. И потом организовываем цикл по числу элементов для тестовой выборки с генерацией случайных значений внутри этого цикла. Генератор случайных значений должен возвращать целочисленное число в диапазоне от 0 до размера генеральной совокупности. В своем решении я воспользовался встроенной функцией MQL5 MathRand для генерации псевдослучайных чисел. Данная функция возвращает целочисленное значение в диапазоне от 0 до 32767. Но так как размер генеральной совокупности ожидается более 33 тыс. элементов я перемножил два случайных числа. Такой вариант способен генерировать более 1 млрд случайных значений. Чтобы привести полученное случайное число к размеру нашей генеральной совокупности мы сначала разделим полученное случайное число на квадрат от 32767, тем самым нормализуем случайное число в диапазоне от 0 до 1. Затем умножим на число элементов в нашей генеральной совокупности. Полученное число нам укажет на порядковый номер паттерна для тестовой выборки.

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

//--- Генерируем случайным образом индексы данных для тестовой выборки
   ArrayInitialize(test0);
   int for_test = (int)((total - BarsToLine) * 0.2);
   for(int i = 0i < for_testi++)
     {
      int t = (int)((double)(MathRand() * MathRand()) / MathPow(32767.02) * 
                    (total - 1 - BarsToLine)) + BarsToLine;
      if(test[t] == 1)
        {
         i--;
         continue;
        }
      test[t] = 1;
     }

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

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

//--- Открываем для записи файл обучающей выборки
   int Study = FileOpen(StudyFileNameFILE_WRITE | 
                                       FILE_CSV | 
                                       FILE_ANSI","CP_UTF8);
   if(Study == INVALID_HANDLE)
     {
      PrintFormat("Error opening file %s: %d"StudyFileNameGetLastError());
      return;
     }
//--- Открываем для записи файл тестовой выборки
   int Test = FileOpen(TestFileNameFILE_WRITE | 
                                     FILE_CSV | 
                                     FILE_ANSI","CP_UTF8);
   if(Test == INVALID_HANDLE)
     {
      PrintFormat("Error opening file %s: %d"TestFileNameGetLastError());
      return;
     }

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

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

//--- Запись выборок в файлы
   for(int i = BarsToLine - 1i < totali++)
     {
      Comment(StringFormat("%.2f%%"i * 100.0 / (double)(total - BarsToLine)));
      if(!WriteData(target1target2rsimacd_mainmacd_signalmacd_deltai,
                                      BarsToLine, (test[i] == 1 ? Test : Study)))
        {
         PrintFormat("Error to write data: %d"GetLastError());
         break;
        }
     }
//--- Закрываем файлы
   Comment("");
   FileFlush(Study);
   FileClose(Study);
   FileFlush(Test);
   FileClose(Test);
   PrintFormat("Study data saved to file %s\\MQL5\\Files\\%s",
               TerminalInfoString(TERMINAL_DATA_PATH), StudyFileName);
   PrintFormat("Test data saved to file %s\\MQL5\\Files\\%s",
               TerminalInfoString(TERMINAL_DATA_PATH), TestFileName);
  }

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

//+------------------------------------------------------------------+
//| Функция записи паттерна в файл                                   |
//+------------------------------------------------------------------+
bool WriteData(double &target1[], // Буфер 1 целевых значений
               double &target2[], // Буфер 2 целевых значений
               double &data1[],   // Буфер 1 исторических данных
               double &data2[],   // Буфер 2 исторических данных
               double &data3[],   // Буфер 2 исторических данных
               double &data4[],   // Буфер 2 исторических данных
               int cur_bar,       // Текущий бар окончания паттерна
               int bars,          // Количество исторических баров 
                                  // в одном паттерне
               int handle)        // Хендл файла для записи
  {

Информацию по паттерну сначала соберем в строковую переменную типа string. При этом не забываем между значениями элементов вставлять разделительный знак. Разделительный знак должен соответствовать разделительному знаку, указанному при открытии CSV-файла. Сбор данных в строковую переменную — вынужденный компромисс. Дело в том, что функция записи в текстовый файл FileWrite имеет ограничение в 63 параметра для записи, а запись каждого вызова завершается символом конца строки. Итак, пред нами возникли две проблемы:

  1. Указывая все данные о паттерне в рамках одного вызова функции WriteData, при использовании 4 показателей на 1 бар мы сможем описать не более 15 свечей.
  2. Мы должны собрать информацию обо всех барах одномоментно.

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

//--- Проверка хендла файла
   if(handle == INVALID_HANDLE)
     {
      Print("Invalid Handle");
      return false;
     }
//--- Определяем индекс первой записи исторических данных паттерна
   int start = cur_bar - bars + 1;
   if(start < 0)
     {
      Print("Too small current bar");
      return false;
     }

//--- Проверяем корректность индекса данных и записываемых в файл 
   int size1 = ArraySize(data1);
   int size2 = ArraySize(data2);
   int size3 = ArraySize(data3);
   int size4 = ArraySize(data4);
   int sizet1 = ArraySize(target1);
   int sizet2 = ArraySize(target2);
   string pattern = (string)(start < size1 ? data1[start] : 0.0) + "," +
                    (string)(start < size2 ? data2[start] : 0.0) + "," +
                    (string)(start < size3 ? data3[start] : 0.0) + "," +
                    (string)(start < size4 ? data4[start] : 0.0);
   for(int i = start + 1i <= cur_bari++)
     {
      pattern = pattern + "," + (string)(i < size1 ? data1[i] : 0.0) + "," +
                                (string)(i < size2 ? data2[i] : 0.0) + "," +
                                (string)(i < size3 ? data3[i] : 0.0) + "," +
                                (string)(i < size4 ? data4[i] : 0.0);
     }
   return (FileWrite(handlepattern
                    (double)(cur_bar < sizet1 ? target1[cur_bar] : 0),
                    (double)(cur_bar < sizet2 ? target2[cur_bar] : 0)) > 0);
  }

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

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

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

  • обучающие выборки с ненормированными исходными данными мы запишем в файлы study_data_not_norm.csv и test_data_not_norm.csv;
  • обучающие выборки с нормированными исходными данными мы запишем в файлы study_data.csv и test_data.csv.

Для создания указанных выборок данных для бучения мы воспользуемся вышеописанным скриптом из файла create_initial_data.mq5. Его мы запустим два раза для сбора одних и тех же исторических данных, но при этом изменим названия файлов для записи данных и «Флаг нормализации данных».