Описание архитектуры и принципов реализации полносвязного слоя

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

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

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

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

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

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

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

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

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

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

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

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

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

Прямой проход будет организован в методе FeedForward. Единственным параметром данного метода будет указатель на объект CNeuronBase предыдущего слоя нейронной сети. От предыдущего слоя нам потребуются состояния на выходе нейронов, которые составят входящий поток данных. Для доступа к ним создадим метод GetOutputs.

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

  • CalcOutputGradient — расчет градиента ошибки на выходном слое нейронной сети по эталонным значениям.
  • CalcHiddenGradient — пропуск градиента ошибки через скрытый слой от выхода ко входу. В результате передадим градиенты ошибки на предыдущий слой. Для доступа к массиву градиентов предшествующего слоя нам потребуется метод для доступа к ним — GetGradients.
  • CalcDeltaWeights — расчет необходимого изменения весовых коэффициентов по результатам анализа последней итерации.
  • UpdateWeights — метод непосредственного обновления весовых коэффициентов.

Ну и конечно, не забудем общие для всех объектов методы работы с файлами и идентификации Save, Load и Type.

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

  • Использование функции активации Softmax предполагает работу со всем нейронным слоем.
  • Использование методов Dropout и Layer Normalization требует обработку данных всего нейронного слоя.
  • Такой подход позволяет на базе матричных операций максимально эффективно организовать многопоточные вычисления.

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

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

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

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

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

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

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

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