English 中文 Español Deutsch 日本語 Português
preview
Нейросети — это просто (Часть 21): Вариационные автоэнкодеры (VAE)

Нейросети — это просто (Часть 21): Вариационные автоэнкодеры (VAE)

MetaTrader 5Торговые системы | 18 июля 2022, 14:14
1 941 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Содержание


    Введение

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


    1. Архитектура Вариационного автоэнкодера

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

    • Автоэнкодер — это нейронная сеть, обучаемая методом обратного распространения ошибки.
    • Любой автоэнкодер состоит из двух блоков Энкодера и Декодера.
    • Размер слоя исходных данных энкодера равен размеру слоя результатов декодера.
    • Между собой энкодер и декодер объединяются "узким горлышком" латентного состояния, в котором сжата информация об исходном состоянии.

    Автоэнкодер

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

    И здесь мы подходим к проблеме, которая была выявлена при использовании автоэнкодеров для генерации изображений. Пусть наши исходные данные представлены неким облаком. И в процессе обучения наша модель научилась идеально восстанавливать 2 случайно выбранных объекта A и B. Если немного утрировать, то энкодер и декодер договорились между собой для объекта A в латентном состоянии указывать 1 и 5 для объекта B. Для решения задачи сжатия данных в этом нет ничего плохого. Напротив, объекты хорошо разделимы и модель их может восстановить.

    Облоко данных

    Но когда исследователи попытались использовать автоэнкодеры для генерации изображений, то разрыв значений латентного состояния между 2-мя объектами оказался проблемой. Эксперименты показали, что при изменениях значений латентного состояния от объекта A до объекта B в зонах близких к объектам декодер восстанавливал указанные объекты с некоторыми искажениями. А вот в средине промежутка декодер генерировал нечто не свойственное исходным данным.

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

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

    Решение проблемы не так тривиально, как может показаться на первый взгляд. Увеличение обучающей выборки и использование различных методов регуляризации латентного состояния ведет лишь к масштабированию проблемы. К примеру, применив регуляризацию мы уменьшаем расстояние между векторами латентного состояния объектов. Скажем, для утрированного примера выше это будут числа 1 и 2. Но всегда может появиться объект, который энкодер зашифрует как 1.5. И мы опять "ставим в тупик" наш декодер. Более сильное сближение с перекрытием может усложнить разделение объектов.

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

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

    Облоко данных

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

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

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

    Вариационный автоэнкодер

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

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

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

    Таким образом, чтобы получить отдельно взятое значение из нормального распределения с заданными параметрами, мы можем сгенерировать значение для стандартного нормального распределения с математическим ожиданием равным "0" и среднеквадратичным отклонением "1". А затем полученное значение умножить на заданное среднеквадратичное отклонение и сложить с заданным математическим ожиданием. Данный подход принято называть reparameterization trick.

    Reparameterization trick

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

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

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

    Дивергенция Кульбака-Лейблера для стандартного распределения

    Таким образом, каждый раз штрафуя модель за отклонение параметров признаков от эталонного в данном случае стандартного распределения, мы заставим модель приблизить параметры распределения каждого признака к параметрам стандартного распределения (математическое ожидание равно "0", а среднеквадратичное отклонение — "1").

    Здесь надо сказать, что подобное притягивание признаков на выходе энкодера будет идти в разрез с основной задачей выделения признаков отдельных объектов. Ведь добавленная регуляризация будет с одинаковой силой "тянуть" все признаки к эталонным значениям. Т.е. она будет стремиться сделать их одинаковыми. В то же время, градиент ошибки от декодера будет стремиться максимально разделить признаки различных объектов. Явно прослеживается конфликт интересов между 2-мя выполняемыми задачами. И модель должна найти баланс в решении поставленных задач. И этот баланс не всегда будет соответствовать нашим ожиданиям. Для контроля над этой точкой равновесия в модель вводится дополнительный гиперпараметр, который управляет влиянием дивергенции Кульбака—Лейблера на общий результат.


    2. Реализация

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

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

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

    И мы снова приходим к тому, что для модели то абсолютно все равно как мы называем тот или иной показатель. Она просто выполняет заложенные математические формулы. Это важно только для нас. Чтобы мы могли правильно построить нашу модель. Давайте обратим внимание на приведенную выше формулу дивергенции Кульбака—Лейблера. В ней используется дисперсия и её логарифм. Как вы знаете, дисперсия распределения равна квадрату среднеквадратического отклонения и может быть строго не отрицательной. В тоже время, её логарифм может принимать как положительные, так и отрицательные значения. А если посмотреть на график натурального логарифма от квадрата аргумента, то обращает внимание точка пересечения графиком функции линии абсцисс строго в значении "1". Именно это значение является целевым для среднеквадратичного отклонения. Более того, для интервала значений функции от -1 до 1 аргумент функции принимает значения от 0.6 до 1.6, что вполне удовлетворяет нашим ожиданиям для среднеквадратического отклонения.

    Натуральный логарифм x^2

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

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

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

    В теле кернела нам остаётся лишь реализовать reparameterization trick. Но не будем забывать, что вместо среднеквадратического отклонения энкодер передал нам логарифм дисперсии. Поэтому перед проведением трюка изменения параметров распределения нам необходимо получить значение среднеквадратичного отклонения.

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

    Свойство степеней

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

    __kernel void VAE_FeedForward(__global float* inputs,
                                  __global float* random,
                                  __global float* outputs
                                 )
      {
       uint i = (uint)get_global_id(0);
       uint total = (uint)get_global_size(0);
       outputs[i] = inputs[i] + exp(0.5f * inputs[i + total]) * random[i];
      }
    

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

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

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

    Производная до логарифма дисперсии

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

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

    • inputs — результаты прямого прохода энкодера. Буфер содержит значения математических ожиданий и логарифмов дисперсии признаков;
    • random — значения элементов стандартного распределения, используемых при прямом проходе;
    • gradient — градиенты ошибки, полученные от декодера;
    • inp_grad — буфер результатов для записи градиентов ошибки, передаваемых энкодеру;
    • kld_mult — дискретное значение коэффициента влияния дивергенции Кульбака—Лейблера на общий результат.

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

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

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

    __kernel void VAE_CalcHiddenGradient(__global float* inputs,
                                         __global float* inp_grad,
                                         __global float* random,
                                         __global float* gradient,
                                         const float kld_mult
                                        )
      {
       uint i = (uint)get_global_id(0);
       uint total = (uint)get_global_size(0);
       float kld = kld_mult * 0.5f * (inputs[i + total] - exp(inputs[i + total]) - pow(inputs[i], 2.0f) + 1);
       inp_grad[i] = gradient[i] + kld * inputs[i];
       inp_grad[i + total] = 0.5f * (gradient[i] * random[i] * exp(0.5f * inputs[i + total]) -
                                     kld * (1 - exp(inputs[i + total]))) ;
      }
    

    На этом работа с OpenCL программой завершена, и мы переходим к реализации функционала на стороне основной программы. Здесь мы сначала создадим новый класс нейронного слоя CVAE наследником от базового класса нейронных слоёв CNeuronBaseOCL.

    В данном классе мы добавляем одну переменную m_fKLD_Mult для хранения коэффициента влияния дивергенции Кульбака—Лейблера на общий результат и метод SetKLDMult для её указания. А также мы создадим дополнительный буфер m_cRandom для записи случайных значений стандартного распределения. Непосредственно семплить значения мы будем с использованием методов стандартной библиотеки статистики и математических операций "Math\Stat\Normal.mqh".

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

    class CVAE : public CNeuronBaseOCL
      {
    protected:
       float             m_fKLD_Mult;
       CBufferDouble*    m_cRandom;
    
       virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL); 
       virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) { return true; } 
    
    public:
                         CVAE();
                        ~CVAE();
       virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                              uint numNeurons, ENUM_OPTIMIZATION optimization_type, uint batch);
       //---
       virtual void      SetKLDMult(float value) { m_fKLD_Mult = value;}
       virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);      
       //---
       virtual bool      Save(int const file_handle);
       virtual bool      Load(int const file_handle);
       //---
       virtual int       Type(void)        const                      {  return defNeuronVAEOCL; }
      };
    

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

    CVAE::CVAE()   : m_fKLD_Mult(0.01f)
      {
       m_cRandom = new CBufferDouble();
      }
    

    В деструкторе класса мы только удаляем объект созданного в конструкторе буфера.

    CVAE::~CVAE()
      {
       if(!!m_cRandom)
          delete m_cRandom;
      }
    

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

    bool CVAE::Init(uint numOutputs,
    
                    uint myIndex,
                    COpenCLMy *open_cl,
    
                    uint numNeurons,
                    ENUM_OPTIMIZATION optimization_type,
    
                    uint batch)
      {
       if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, numNeurons, optimization_type, batch))
          return false;
    //---
       if(!m_cRandom)
         {
          m_cRandom = new CBufferDouble();
          if(!m_cRandom)
             return false;
         }
       if(!m_cRandom.BufferInit(numNeurons, 0.0))
          return false;
       if(!m_cRandom.BufferCreate(OpenCL))
          return false;
    //---
       return true;
      }
    

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

    bool CVAE::feedForward(CNeuronBaseOCL *NeuronOCL)
      {
       if(!OpenCL || !NeuronOCL || !m_cRandom)
          return false;
       if(NeuronOCL.Neurons() % 2 != 0 ||
          NeuronOCL.Neurons() / 2 != Neurons())
          return false;
    

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

       double random[];
       if(!MathRandomNormal(0, 1, m_cRandom.Total(), random))
          return false;
       if(!m_cRandom.AssignArray(random))
          return false;
       if(!m_cRandom.BufferWrite())
          return false;
    

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

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

       if(!OpenCL.SetArgumentBuffer(def_k_VAEFeedForward, def_k_vaeff_inputs, NeuronOCL.getOutput().GetIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_VAEFeedForward, def_k_vaeff_random, m_cRandom.GetIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_VAEFeedForward, def_k_vaeff_outputd, Output.GetIndex()))
          return false;
    

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

       uint off_set[] = {0};
       uint NDrange[] = {Neurons()};
       if(!OpenCL.Execute(def_k_VAEFeedForward, 1, off_set, NDrange))
          return false;
    //---
       return true;
      }
    

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

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

    За прямым проходом следует обратный проход. Как вы знаете, обычно обратный проход мы реализовываем несколькими методами. Сначала методами calcOutputGradientscalcHiddenGradients и calcInputGradients мы организовываем рассчет и передачу градиента ошибки последовательно через всю нашу модель от нейронного слоя результатов до слоя исходных данных. А затем с помощью метода updateInputWeights мы изменяем обучаемые параметры в сторону антиградиента.

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

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

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

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

    bool CVAE::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
      {
       if(!OpenCL || !NeuronOCL)
          return false;
    //---
       if(!OpenCL.SetArgumentBuffer(def_k_VAECalcHiddenGradient, def_k_vaehg_input,
                                                            NeuronOCL.getOutput().GetIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_VAECalcHiddenGradient, def_k_vaehg_inp_grad,
                                                            NeuronOCL.getGradient().GetIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_VAECalcHiddenGradient, def_k_vaehg_random, Weights.GetIndex()))
          return false;
       if(!OpenCL.SetArgumentBuffer(def_k_VAECalcHiddenGradient, def_k_vaehg_gradient, Gradient.GetIndex()))
          return false;
       if(!OpenCL.SetArgument(def_k_VAECalcHiddenGradient, def_k_vaehg_kld_mult, m_fKLD_Mult))
          return false;
       int off_set[] = {0};
       int NDrange[] = {Neurons()};
       if(!OpenCL.Execute(def_k_VAECalcHiddenGradient, 1, off_set, NDrange))
          return false;
    //---
       return true;
      }
    

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

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

    Таким образом, наш метод сохранения объекта будет содержать только 2 операции:

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

    bool CVAE::Save(const int file_handle)
      {
    //---
       if(!CNeuronBaseOCL::Save(file_handle))
          return false;
       if(FileWriteFloat(file_handle, m_fKLD_Mult) < sizeof(m_fKLD_Mult))
          return false;
    //---
       return true;
      }
    

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

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

    bool CVAE::Load(const int file_handle)
      {
       if(!CNeuronBaseOCL::Load(file_handle))
          return false;
       m_fKLD_Mult=FileReadFloat(file_handle);
    

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

       if(!m_cRandom)
         {
          m_cRandom = new CBufferDouble();
          if(!m_cRandom)
             return false;
         }
       if(!m_cRandom.BufferInit(Neurons(), 0.0))
          return false;
       if(!m_cRandom.BufferCreate(OpenCL))
          return false;
    //---
       return true;
      }
    

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

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

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

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

    CNet::CNet(CArrayObj *Description)  :  recentAverageError(0),
                                           backPropCount(0)
      {
      .................
      .................
    //---
       for(int i = 0; i < total; i++)
         {
      .................
      .................
          if(CheckPointer(opencl) != POINTER_INVALID)
            {
             CNeuronBaseOCL *neuron_ocl = NULL;
             CNeuronConvOCL *neuron_conv_ocl = NULL;
             CNeuronProofOCL *neuron_proof_ocl = NULL;
             CNeuronAttentionOCL *neuron_attention_ocl = NULL;
             CNeuronMLMHAttentionOCL *neuron_mlattention_ocl = NULL;
             CNeuronDropoutOCL *dropout = NULL;
             CNeuronBatchNormOCL *batch = NULL;
             CVAE *vae = NULL;
             switch(desc.type)
               {
      .................
      .................
                //---
                case defNeuronVAEOCL:
                   vae = new CVAE();
                   if(!vae)
                     {
                      delete temp;
                      return;
                     }
                   if(!vae.Init(outputs, 0, opencl, desc.count, desc.optimization, desc.batch))
                     {
                      delete vae;
                      delete temp;
                      return;
                     }
                   if(!temp.Add(vae))
                     {
                      delete vae;
                      delete temp;
                      return;
                     }
                   vae = NULL;
                   break;
                default:
                   return;
                   break;
               }
            }
          else
             for(int n = 0; n < neurons; n++)
               {
      .................
      .................
               }
          if(!layers.Add(temp))
            {
             delete temp;
             delete layers;
             return;
            }
         }
    //---
       if(CheckPointer(opencl) == POINTER_INVALID)
          return;
    //--- create kernels
       opencl.SetKernelsCount(32);
      .................
      .................
       opencl.KernelCreate(def_k_VAEFeedForward, "VAE_FeedForward");
       opencl.KernelCreate(def_k_VAECalcHiddenGradient, "VAE_CalcHiddenGradient");
    //---
       return;
      }
    

    Аналогичные изменения вносим в метод загрузки модели CNet::Load. Позвольте не дублировать код в данной статье. Вы можете ознакомиться с кодом метода самостоятельно во вложении.

    Далее мы добавляем указатели на новый класс в методах CLayer::CreateElement и CLayer::Load.

    И в заключении добавляем указатели нового класса в диспетчерские методы базового нейронного слоя CNeuronBaseOCL FeedForward, calcHiddenGradients и UpdateInputWeights.

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


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

    Для проверки работы нашего вариационного автоэнкодера я взял модель из предыдущей статьи и пересохранил её в новом файле "vae.mq5". В той модели энкодер возвращал 2 значения на 5 нейронном слое. С целью правильной организации работы вариационного автоэнкодера я увеличил размер слоя на выходе энкодера до 4 нейронов. И вставил 6-м наш новый нейронный слой работы с латентным состоянием вариационного автоэнкодера. Обучение модели проводилось на инструменте EURUSD и таймфрейме H1 без изменения параметров. Временной отрезок для обучения модели был выбран в размере последних 15 лет. Сравнительный график динамики обучения многослойного и вариационного автоэнкодеров представлен на рисунке ниже.

    Сравнительные результаты обучения 

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

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


    Заключение

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


    Ссылки

    1. Нейросети — это просто (Часть 14): Кластеризация данных
    2. Нейросети — это просто (Часть 15): Кластеризации данных средствами MQL5
    3. Нейросети — это просто (Часть 16): Практическое использование кластеризации
    4. Нейросети — это просто (Часть 17): Понижение размерности
    5. Нейросети — это просто (Часть 18): Ассоциативные правила
    6. Нейросети — это просто (Часть 19): Ассоциативные правила средствами MQL5
    7. Нейросети — это просто (Часть 20): Автоэнкодеры
    8. Tutorial on Variational Autoencoders
    9. Intuitively Understanding Variational Autoencoders
    10. Tutorial - What is a variational autoencoder?



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

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


    Прикрепленные файлы |
    MQL5.zip (68.61 KB)
    Разработка торгового советника с нуля (Часть 19): Новая система ордеров (II) Разработка торгового советника с нуля (Часть 19): Новая система ордеров (II)
    В данной статье мы будем разрабатывать графическую систему ордеров вида «посмотрите, что происходит». Следует сказать, что мы не начнем с нуля, а модифицируем существующую систему, добавив еще больше объектов и событий на график торгуемого нами актива.
    Разработка торговой системы на основе индикатора Parabolic SAR Разработка торговой системы на основе индикатора Parabolic SAR
    Это продолжение серии статей, в которых мы учимся строить торговые системы с использованием самых популярных индикаторов. В этой статье мы будем изучать индикатор Parabolic SAR. Также мы разработаем торговую систему для работы в платформе MetaTrader 5, используя несколько простых стратегий.
    Разработка торгового советника с нуля (Часть 20): Новая система ордеров (III) Разработка торгового советника с нуля (Часть 20): Новая система ордеров (III)
    Продолжим внедрение новой системы ордеров. Создание такой системы требует хорошего владения MQL5, а также понимания того, как на самом деле работает платформа MetaTrader 5 и какие ресурсы она нам предоставляет.
    Разработка торговой системы на основе индикатора ATR Разработка торговой системы на основе индикатора ATR
    В этой статье мы изучим новый технический инструмент, который можно использовать в торговле. Это продолжение серии, в которой мы учимся проектировать простые торговые системы. В этот раз мы будем работать с еще одним популярным техническим индикатором — Средний истинный диапазон (Average True Range, ATR).