Простенькая нейронная сеть на 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/
Статьи: