English 中文 Español Deutsch 日本語 Português
preview
Нейросети — это просто (Часть 15): Кластеризации данных средствами MQL5

Нейросети — это просто (Часть 15): Кластеризации данных средствами MQL5

MetaTrader 5Торговые системы | 24 мая 2022, 08:45
1 203 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Содержание

Введение

В предыдущей статье мы познакомились с методом кластеризации k-средних и посмотрели на его реализацию средствами языка Python. Но часто использование интеграции накладывает определенные ограничения и дополнительные затраты. В частности, текущее состояние интеграции не позволяет использовать данные встроенных программ, таких как индикаторы или обработка событий терминала. И если большое количество классических индикаторов реализовано в различных библиотеках, то алгоритм пользовательских индикаторов нужно будет повторять в своём скрипте. А что делать, если нет исходного кода индикатора и понимания алгоритма его действия. Или же Вы планируете использовать результаты кластеризации в других MQL5 программах. В таких случаях нам поможет реализация метода кластеризации средствами MQL5.

1. Принципы построения модели

Напомню алгоритм метода кластеризации k-средних:

  1. Определяем k случайных точек из обучающей выборки в качестве центров кластеров.
  2. Организовываем цикл операций:
    • Определяем расстояние от каждой точки до каждого центра;
    • По ближайшему центу определяем принадлежность точки к кластеру;
    • Арифметическим средним определяем новый центр для каждого кластера.
  3. Операции в цикле повторяем до "остановки" центров кластера.

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

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

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

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

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

2. Создание программы OpenCL

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

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

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

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

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

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

__kernel void KmeansCulcDistance(__global double *data,
                                 __global double *means,
                                 __global double *distance,
                                 int vector_size
                                )
  {
   int m = get_global_id(0);
   int k = get_global_id(1);
   int total_k = get_global_size(1);
   double sum = 0.0;
   int shift_m = m * vector_size;
   int shift_k = k * vector_size;
   for(int i = 0; i < vector_size; i++)
      sum += pow(data[shift_m + i] - means[shift_k + i], 2);
   distance[m * total_k + k] = sum;
  }

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

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

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

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

Здесь же мы подготовим 2 частные переменные. В одной value мы будем записывать дистанцию до центра. А во второй result порядковый номер кластера. На начальном этапе мы сохраним в них значения кластера с идентификатором "0".

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

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

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

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

__kernel void KmeansClustering(__global double *distance,
                               __global double *clusters,
                               __global double *flags,
                               int total_k
                              )
  {
   int i = get_global_id(0);
   int shift = i * total_k;
   double value = distance[shift];
   int result = 0;
   for(int k = 1; k < total_k; k++)
     {
      if(value <= distance[shift + k])
         continue;
      value =  distance[shift + k];
      result = k;
     }
   flags[i] = (double)(clusters[i] != (double)result);
   clusters[i] = (double)result;
  }

И в заключении алгоритма кластеризации нам необходимо обновить значения центральных векторов всех кластеров, которые собраны в матрицу means. Для реализации этой задачи мы создадим ещё один кернел KmeansUpdating. Как и рассмотренные выше кернелы, рассматриваемый в параметрах получит указатели на 3 буфера данных и одну константу. Два буфера содержат исходные данные и один буфер результатов. Как уже было сказано выше, этот кернел мы будем запускать на выполнение в 2-х мерном пространстве задач. Но в отличие от кернела KmeansCulcDistance в первом измерении пространства задач мы будем перебирать элементы вектора описания одного состояния системы, а в константе total_m мы укажем количество элементов в обучающей выборке.

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

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

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

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

__kernel void KmeansUpdating(__global double *data,
                             __global double *clusters,
                             __global double *means,
                             int total_m
                            )
  {
   int i = get_global_id(0);
   int vector_size = get_global_size(0);
   int k = get_global_id(1);
   double sum = 0;
   int count = 0;
   for(int m = 0; m < total_m; m++)
     {
      if(clusters[m] != k)
         continue;
      sum += data[m * vector_size + i];
      count++;
     }
   if(count > 0)
      means[k * vector_size + i] = sum / count;
  }

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

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

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

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

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

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

__kernel void KmeansLoss(__global double *data,
                         __global double *clusters,
                         __global double *means,
                         __global double *loss,
                         int vector_size
                        )
  {
   int m = get_global_id(0);
   int c = clusters[m];
   int shift_c = c * vector_size;
   int shift_m = m * vector_size;
   double sum = 0;
   for(int i = 0; i < vector_size; i++)
      sum += pow(data[shift_m + i] - means[shift_c + i], 2);
   loss[m] = sum;
  }

Ну вот мы и рассмотрели алгоритмы построения всех процессов на стороне контекста OpenCL. Теперь мы можем перейти к организации процессов на стороне основной программы.

3. Подготовительная работа на стороне основной программы

На стороне основной программы нам предстоит создать новый класс CKmeans. Код данного класса мы сохраним в файле "kmeans.mqh". Но перед тем как приступить непосредственно к работе над новым классом мы проведем небольшую подготовительную работу. Прежде всего, для передачи данных в контекст OpenCL мы будем использовать уже известный нам из данной серии статей объект класса CBufferDouble. Мы не будем переписывать код указанного класса, а просто подключим созданную ранее библиотеку.

#include "..\NeuroNet_DNG\NeuroNet.mqh"

Затем загрузим в качестве ресурса код созданной выше OpenCL программы.

#resource "unsupervised.cl" as string cl_unsupervised

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

Во-первых, нам потребуется константа для идентификации нового класса.

#define defUnsupervisedKmeans    0x7901

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

#define def_k_kmeans_distance    0
#define def_k_kmd_data           0
#define def_k_kmd_means          1
#define def_k_kmd_distance       2
#define def_k_kmd_vector_size    3
#define def_k_kmeans_clustering  1
#define def_k_kmc_distance       0
#define def_k_kmc_clusters       1
#define def_k_kmc_flags          2
#define def_k_kmc_total_k        3
#define def_k_kmeans_updates     2
#define def_k_kmu_data           0
#define def_k_kmu_clusters       1
#define def_k_kmu_means          2
#define def_k_kmu_total_m        3
#define def_k_kmeans_loss        3
#define def_k_kml_data           0
#define def_k_kml_clusters       1
#define def_k_kml_means          2
#define def_k_kml_loss           3
#define def_k_kml_vector_size    4

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

Посмотрим на алгоритм функции OpenCLCreate. Она построена таким образом, что в параметрах получает тест программы OpenCL, а возвращает указатель на экземпляр инициализированного объекта. В теле функции мы сначала создадим новый экземпляр класса COpenCLMy. И сразу проверяем результат операции создания нового объекта.

COpenCLMy *OpenCLCreate(string programm)
  {
   COpenCL *result = new COpenCLMy();
   if(CheckPointer(result) == POINTER_INVALID)
      return NULL;

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

   if(!result.Initialize(programm, true))
     {
      delete result;
      return NULL;
     }

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

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

   if(!result.SetKernelsCount(4))
     {
      delete result;
      return NULL;
     }
//---
   if(!result.KernelCreate(def_k_kmeans_distance, "KmeansCulcDistance"))
     {
      delete result;
      return NULL;
     }
//---
...........
//---
   return result;
  }

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

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


4. Построение класса организации алгоритма k-средних

Начиная работу над новым классом кластеризации данных CKmeans давайте обсуди его наполнение. Каким функционалом он должен обладать? И какие методы и переменные нам понадобятся для выполнения этого функционала. Все переменные мы будем объявлять в блоке protected.

В первую очередь нам нужны будут переменные для хранения гиперпараметров модели: количества создаваемых кластеров (m_iClusters) и размера вектора описания одного отдельно взятого состояния системы (m_iVectorSize).

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

Кроме того, для понимания состояния модели (обучена она или нет) нам понадобится флаг m_bTrained.

Мне кажется, этого списка переменных будет достаточно для реализации необходимого функционала. Далее мы переходим к объявлению используемых объектов. Здесь мы не будем долго задерживаться и объявим один экземпляр класса для работы с контекстом OpenCL (c_OpenCL). А также нам понадобятся буфера данных для хранения информации и обмена ей с контекстом OpenCL. Их названия мы сделаем созвучными с используемыми выше при разработке программы OpenCL:

  • c_aDistance;
  • c_aMeans;
  • c_aClasters;
  • c_aFlags;
  • c_aLoss.

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

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

void CKmeans::CKmeans(void)   :  m_iClusters(2),
                                 m_iVectorSize(1),
                                 m_dLoss(-1),
                                 m_bTrained(false)
  {
   c_aMeans = new CBufferDouble();
   if(CheckPointer(c_aMeans) != POINTER_INVALID)
      c_aMeans.BufferInit(m_iClusters * m_iVectorSize, 0);
   c_OpenCL = NULL;
  }

А в деструкторе класса мы осуществляем очистку памяти и удаляем все созданные в классе объекты.

void CKmeans::~CKmeans(void)
  {
   if(CheckPointer(c_aMeans) == POINTER_DYNAMIC)
      delete c_aMeans;
   if(CheckPointer(c_aDistance) == POINTER_DYNAMIC)
      delete c_aDistance;
   if(CheckPointer(c_aClasters) == POINTER_DYNAMIC)
      delete c_aClasters;
   if(CheckPointer(c_aFlags) == POINTER_DYNAMIC)
      delete c_aFlags;
   if(CheckPointer(c_aLoss) == POINTER_DYNAMIC)
      delete c_aLoss;
  }

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

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

bool CKmeans::Init(COpenCLMy *context, int clusters, int vector_size)
  {
   if(CheckPointer(context) == POINTER_INVALID || clusters < 2 || vector_size < 1)
      return false;
//---
   c_OpenCL = context;
   m_iClusters = clusters;
   m_iVectorSize = vector_size;
   if(CheckPointer(c_aMeans) == POINTER_INVALID)
     {
      c_aMeans = new CBufferDouble();
      if(CheckPointer(c_aMeans) == POINTER_INVALID)
         return false;
     }
   c_aMeans.BufferFree();
   if(!c_aMeans.BufferInit(m_iClusters * m_iVectorSize, 0))
      return false;
   m_bTrained = false;
   m_dLoss = -1;
//---
   return true;
  }

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

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

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

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

bool CKmeans::Study(CBufferDouble *data, bool init_means = true)
  {
   if(CheckPointer(data) == POINTER_INVALID || CheckPointer(c_OpenCL) == POINTER_INVALID)
      return false;
//---
   int total = data.Total();
   if(total <= 0 || m_iClusters < 2 || (total % m_iVectorSize) != 0)
      return false;
//---
   int rows = total / m_iVectorSize;
   if(rows <= (10 * m_iClusters))
      return false;

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

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

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

   bool flags[];
   if(ArrayResize(flags, rows) <= 0 || !ArrayInitialize(flags, false))
      return false;
//---
   for(int i = 0; (i < m_iClusters && init_means); i++)
     {
      Comment(StringFormat("Cluster initialization %d of %d", i, m_iClusters));
      int row = (int)((double)MathRand() * MathRand() / MathPow(32767, 2) * (rows - 1));
      if(flags[row])
        {
         i--;
         continue;
        }
      int start = row * m_iVectorSize;
      int start_c = i * m_iVectorSize;
      for(int c = 0; c < m_iVectorSize; c++)
        {
         if(!c_aMeans.Update(start_c + c, data.At(start + c)))
            return false;
        }
      flags[row] = true;
     }

После инициализации матрицы центров мы переходим к проверке действительности указателей и, при необходимости, создания новых экземпляров объектов буферов для записи матрицы дистанций (c_aDistance), вектора идентификации кластеров для каждого состояния системы (c_aClasters) и вектора флагов изменения кластеров для отдельных состояний системы (c_aFlags). При этом не забываем контролировать выполнение операций.

   if(CheckPointer(c_aDistance) == POINTER_INVALID)
     {
      c_aDistance = new CBufferDouble();
      if(CheckPointer(c_aDistance) == POINTER_INVALID)
         return false;
     }
   c_aDistance.BufferFree();
   if(!c_aDistance.BufferInit(rows * m_iClusters, 0))
      return false;
   if(CheckPointer(c_aClasters) == POINTER_INVALID)
     {
      c_aClasters = new CBufferDouble();
      if(CheckPointer(c_aClasters) == POINTER_INVALID)
         return false;
     }
   c_aClasters.BufferFree();
   if(!c_aClasters.BufferInit(rows, 0))
      return false;
   if(CheckPointer(c_aFlags) == POINTER_INVALID)
     {
      c_aFlags = new CBufferDouble();
      if(CheckPointer(c_aFlags) == POINTER_INVALID)
         return false;
     }
   c_aFlags.BufferFree();
   if(!c_aFlags.BufferInit(rows, 0))
      return false;

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

   if(!data.BufferCreate(c_OpenCL) ||
      !c_aMeans.BufferCreate(c_OpenCL) ||
      !c_aDistance.BufferCreate(c_OpenCL) ||
      !c_aClasters.BufferCreate(c_OpenCL) ||
      !c_aFlags.BufferCreate(c_OpenCL))
      return false;

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

  • Определение расстояний от каждого элемента обучающей выборки до каждого центра кластера;
  • Распределение состояний системы по кластерам (по минимальному расстоянию);
  • Обновление центров кластеров.

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

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

   int count = 0;
   do
     {
      if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_distance, def_k_kmd_data, data.GetIndex()))
         return false;
      if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_distance, def_k_kmd_means, c_aMeans.GetIndex()))
         return false;
      if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_distance, def_k_kmd_distance, c_aDistance.GetIndex()))
         return false;
      if(!c_OpenCL.SetArgument(def_k_kmeans_distance, def_k_kmd_vector_size, m_iVectorSize))
         return false;

Далее нам надо указать размерность пространства задач и смещение в каждом из них. Данный кернел мы планировали запускать в 2-мерном пространстве задач. Создадим 2 статических массива с количеством элементов, равным пространству задач:

  • global_work_size — для указания размерности пространства задач;
  • global_work_offset — для указания смещения в каждом измерении.

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

      uint global_work_offset[2] = {0, 0};
      uint global_work_size[2];
      global_work_size[0] = rows;
      global_work_size[1] = m_iClusters;

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

      if(!c_OpenCL.Execute(def_k_kmeans_distance, 2, global_work_offset, global_work_size))
         return false;
      if(!c_aDistance.BufferRead())
         return false;

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

      if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_clustering, def_k_kmc_flags, c_aFlags.GetIndex()))
         return false;
      if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_clustering, def_k_kmc_clusters, c_aClasters.GetIndex()))
         return false;
      if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_clustering, def_k_kmc_distance, c_aDistance.GetIndex()))
         return false;
      if(!c_OpenCL.SetArgument(def_k_kmeans_clustering, def_k_kmc_total_k, m_iClusters))
         return false;
      uint global_work_offset1[1] = {0};
      uint global_work_size1[1];
      global_work_size1[0] = rows;
      if(!c_OpenCL.Execute(def_k_kmeans_clustering, 1, global_work_offset1, global_work_size1))
         return false;
      if(!c_aFlags.BufferRead())
         return false;

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

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

      m_bTrained = (c_aFlags.Maximum() == 0);
      if(m_bTrained)
        {
         if(!c_aClasters.BufferRead())
            return false;
         break;
        }

Если же процесс обучения ещё не завершен, мы переходим к организации вызова 3-го кернела — обновления центральных векторов кластеров. Этот кернел мы так же, как и первый, будем запускать в 2-мерном пространстве задач. Поэтому воспользуемся массивами, созданными при вызове первого кернела. Изменим лишь размерность первого измерения.

      if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_updates, def_k_kmu_data, data.GetIndex()))
         return false;
      if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_updates, def_k_kmu_means, c_aMeans.GetIndex()))
         return false;
      if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_updates, def_k_kmu_clusters, c_aClasters.GetIndex()))
         return false;
      if(!c_OpenCL.SetArgument(def_k_kmeans_updates, def_k_kmu_total_m, rows))
         return false;
      global_work_size[0] = m_iVectorSize;
      if(!c_OpenCL.Execute(def_k_kmeans_updates, 2, global_work_offset, global_work_size))
         return false;
      if(!c_aMeans.BufferRead())
         return false;
      count++;
      Comment(StringFormat("Study iterations %d", count));
     }
   while(!m_bTrained && !IsStopped());

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

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

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

   data.BufferFree();
   c_aDistance.BufferFree();
   c_aFlags.BufferFree();
//---
   return true;
  }

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

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

double CKmeans::GetLoss(CBufferDouble *data)
  {
   if(!Clustering(data))
      return -1;

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

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

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

   int total = data.Total();
   int rows = total / m_iVectorSize;
//---
   if(CheckPointer(c_aLoss) == POINTER_INVALID)
     {
      c_aLoss = new CBufferDouble();
      if(CheckPointer(c_aLoss) == POINTER_INVALID)
         return -1;
     }
   if(!c_aLoss.BufferInit(rows, 0))
      return -1;

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

   if(!data.BufferCreate(c_OpenCL) ||
      !c_aLoss.BufferCreate(c_OpenCL))
      return -1;

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

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

   m_dLoss = 0;
   for(int i = 0; i < rows; i++)
      m_dLoss += c_aLoss.At(i);
   m_dLoss /= rows;

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

   data.BufferFree();
   c_aLoss.BufferFree();
   return m_dLoss;
  }

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

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

class CKmeans  : public CObject
  {
protected:
   int               m_iClusters;
   int               m_iVectorSize;
   double            m_dLoss;
   bool              m_bTrained;

   COpenCLMy         *c_OpenCL;       
   //---
   CBufferDouble     *c_aDistance;
   CBufferDouble     *c_aMeans;
   CBufferDouble     *c_aClasters;
   CBufferDouble     *c_aFlags;
   CBufferDouble     *c_aLoss;

public:
                     CKmeans(void);
                    ~CKmeans(void);
   //---
   bool              SetOpenCL(COpenCLMy *context);
   bool              Init(COpenCLMy *context, int clusters, int vector_size);
   bool              Study(CBufferDouble *data, bool init_means = true);
   bool              Clustering(CBufferDouble *data);
   double            GetLoss(CBufferDouble *data);
   //---
   virtual bool      Save(const int file_handle);
   virtual bool      Load(const int file_handle);
   //---
   virtual int       Type(void)  { return defUnsupervisedKmeans; }
  };

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

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

Внешние параметры советника полностью перейдут из используемых ранее. Мы лишь увеличим период обучения до 15 лет. Ведь изюминка обучения без учителя именно в возможности использования большого набора не размеченных данных. Я не стал выводить в параметрах количество кластеров в модели, так как процесс обучения я организовал в цикле с довольно широким спектром кластеров. Для поиска оптимального количества кластеров мы перебрали несколько вариантов в диапазоне от 50 до 1000 кластеров. А точнее мы использовали шаг в 50 кластеров. Помните, именно такие параметры кластеризацию мы использовали в предыдущей статье при тестировании скрипта Python. Параметры тестирования мы также взяли из предыдущих экспериментов:

  • Инструмент EURUSD;
  • Таймфрейм H1.

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

График зависимости значений функции потерь от количества кластеров

Как видно на графике излом оказался довольно растянут в диапазоне от 100 до 500 кластеров. При этом надо сказать, что было проанализировано более 92 тыс. состояний системы. А сама форма графика полностью идентична построенному скриптом Python в предыдущей статье. Это косвенно подтверждает корректность работы построенного нами класса.

Заключение

В данной статье мы создали новый класс CKmeans для его реализации одного из наиболее распространённых методов кластеризации k-средних. И даже успели повести обучение модели с различным количеством кластеров. По результатам тестирования модель смогла выделить около 500 паттернов. Аналогичный результат был получен и аналогичном тестировании в Python. А значит, мы корректно повторили алгоритм метода. В следующей статье мы обсудим возможные методы практического использования результатов кластеризации.


Ссылки

  1. Нейросети  — это просто
  2. Нейросети  — это просто (Часть 2): обучение и тестирование сети
  3. Нейросети  — это просто (Часть 3): сверточные сети
  4. Нейросети  — это просто (Часть 4): рекуррентные сети
  5. Нейросети  — это просто (Часть 5): многопоточные вычисления в OpenCL
  6. Нейросети — это просто (Часть 6): эксперименты с коэффициентом обучения нейронной сети
  7. Нейросети — это просто (Часть 7): Адаптивные методы оптимизации
  8. Нейросети — это просто (Часть 8): Механизмы внимания
  9. Нейросети — это просто (Часть 9): Документируем проделанную работу
  10. Нейросети — это просто (Часть 10): Multi-Head Attention (многоголовое внимание)
  11. Нейросети — это просто (Часть 11): Вариации на тему GPT
  12. Нейросети — это просто (Часть 12): Dropout
  13. Нейросети — это просто (Часть 13): Пакетная нормализация (Batch Normalization)
  14. Нейросети — это просто (Часть 14): Кластеризация данных

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

# Имя Тип Описание
1 kmeans.mq5 Советник   Советник для обучения модели 
2 kmeans.mqh  Библиотека класса Библиотека для организации метода k-средних 
3 unsupervised.cl Библиотека
Библиотека кода программы OpenCL  для организации метода k-средних
4 NeuroNet.mqh Библиотека класса Библиотека классов для создания нейронной сети
5 NeuroNet.cl Библиотека Библиотека кода программы OpenCL


Прикрепленные файлы |
MQL5.zip (63.7 KB)
Машинное обучение и Data Science (Часть 01): Линейная регрессия Машинное обучение и Data Science (Часть 01): Линейная регрессия
Пришло время нам, трейдерам, обучить наши системы и научиться самим принимать решения, основываясь на том, что показывают цифры. Не визуальным и не интуитивным путем, которым движется весь мир. Мы пойдем перпендикулярно общему направлению.
DoEasy. Элементы управления (Часть 5): Базовый WinForms-объект, элемент управления "Панель", параметр AutoSize DoEasy. Элементы управления (Часть 5): Базовый WinForms-объект, элемент управления "Панель", параметр AutoSize
В статье создадим базовый объект всех WinForms-объектов библиотеки и приступим к реализации свойства AutoSize WinForms-объекта "Панель" — автоизменение размера под его внутреннее содержимое.
Разработка торгового советника с нуля (Часть 8): Концептуальный скачок (I) Разработка торгового советника с нуля (Часть 8): Концептуальный скачок (I)
Как максимально просто реализовать новый функционал? В данной статье мы сделаем шаг назад, а затем два шага вперед.
Разработка торгового советника с нуля (Часть 7): Добавляем Volume At Price (I) Разработка торгового советника с нуля (Часть 7): Добавляем Volume At Price (I)
Это один из самых мощных индикаторов из существующих. Те, кто торгует и старается иметь определенную степень уверенности, не могут не иметь этот индикатор на своем графике. Хотя чаще всего его используют те, кто торгует, наблюдая за лентой сделок («tape reading»). Также этот индикатор могут использовать и те, кто использует только Price Action.