May 10, 2020

Простенькая нейронная сеть на C++

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


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

У каждой нейронной сети есть слои, в нашем случае три слоя: входной (input layer), скрытый (hidden layer) и выходной (output layer). Как понятно из названия, на первый слой подаются некоторые данные, выходной отдаёт обработанные. Но что же делает скрытый слой и почему он так называется? Дело в том, что этот слой недоступен пользователю, но очень важен, потому что без него наша сеть будет глупой. Каждый слой имеет по несколько нейронов (а может и довольно много, к примеру, нейросеть, обрабатываются изображения, обязана иметь очень много входных нейронов, та же картинка 1920x1080 содержит 2,073,600 пикселей), связанных с нейронами следующего слоя. По этим связам «передаётся» сигнал, при том эти связи ещё и имеют некоторый коэффициент. Рассмотрим поближе:

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

Суть в том, что каждая связь имеет некоторый коэффициент, называемый «весом» (weight), он, грубо говоря, определяет важность такого-то нейрона и именно он подбирается при обучении. При том этот коэффициент принадлежит каждому из связей, как показано ниже:

Выглядит непонятно? Да, тут скорее моя вина, но сейчас разъясню: первое число — номер данного нейрона, второе — нейрон, к которому идёт. А в случае связей между скрытым и выходным, думаю, понятно. Итак, как же мы обрабатываем сигналы? Сигналы со всех нейронов перемножаются на собственные веса и складываются. Пример:

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

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

А так она сама (формула прямиком из википедии):

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

На этом моменте я буду приводить примеры кода на C++, используя библиотеку boost, код на гитхабе

Подключаем заголовные файлы матриц, векторов и их вывода:

#include <boost/numeric/ublas/matrix.hpp>
#include <boost/numeric/ublas/vector.hpp>
#include <boost/numeric/ublas/io.hpp>

Затем для удобства мы воспользуемся директивой using:

using boost::numeric::ublas::matrix;
using boost::numeric::ublas::vector;
using boost::numeric::ublas::prod;

boost::numeric::ublas::prod — умножение матриц.

Это матрица весов входных нейронов к нейронам скрытого слоя. Каждая строка ведёт к каждому нейрону скрытого слоя от конкретного нейрона входного слоя. Теперь вектор входных сигналов:

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

Что здесь примечательного, кроме неаккуратных скобочек? Это же вектор входных сигналов на скрытый слой. Заинтересовало? Тогда уж точно надо прочесть про умножение матриц и понять, почему получился такой результат. Теперь, пожалуй, перейдём к коду.

matrix<double> weights_itoh(2, 3, 0);
vector<double> inputs(3, 0);

Матрица весов от входных нейронов к скрытым. Что не так с размерностью? Ну, дело в том, что матрицы считают по столбцам и строкам, а не наоборот. В нашей матрице 2 столбца и 3 строки и заполняем её нулями (для эстетики). Вектор же имеет три компоненты и тоже заполняется нулями.

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

Сразу же за этим я создаю матрицу весов скрытого слоя к выходному и инициализирую вектор сигналов:

matrix<double> weights_htoo(1, 2, 0);
vector<double> outputs_hidden = sigmoid(prod(weights_itoh, inputs));

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

vector<double> output = sigmoid(prod(weights_htoo, outputs_hidden));

Можем его вывести через std::cout, потому что мы подключили заголовочный файл вывода матриц и векторов.


И что, всё? Ну да, я здесь не обучаю её, потому что статья и так получилось массивной (по моим ощущениям, ведь писал целый день) и у меня ещё мало материала, потому оставлю ниже ссылки на полезные ресурсы для новичков.

Литература:

Тарик Рашид — «Создаём нейронную сеть» (правда, книга про питон)

https://www.ozon.ru/context/detail/id/141796497/

Статьи: