ML - реализация нейронной сети на C ++ с нуля

Опубликовано: 4 Января, 2022

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

Зачем использовать C ++
Теперь, если вы уже реализовали модель нейронной сети на каком-то другом языке программирования, то вы могли заметить (если у вас недорогой ПК), что ваши модели работают довольно медленно даже с небольшими наборами данных. Когда вы начали изучать нейронные сети, вы, возможно, уже искали в Google Какой язык лучше всего подходит для машинного обучения? и очевидный ответ, который вы получите: Python или R лучше всего подходят для машинного обучения, другие языки сложны, поэтому вы не должны тратить на них свое время! . Теперь, если пользователь начинает программировать, он сталкивается с проблемой потребления времени и ресурсов. Итак, в этой статье показано, как создать сверхбыструю нейронную сеть.


Предпосылки:

  • Базовые знания о том, что такое классы и как они работают.
  • Используйте библиотеку линейной алгебры под названием Eigen
  • Некоторые базовые операции чтения / записи в C ++
  • Некоторые базовые знания о линейной алгебре, поскольку мы используем для этого библиотеку

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

  • Начиная!
  • Класс Eigen Matrix

Изучая Eigen, вы столкнетесь с одной из самых мощных функций C ++ - метапрограммированием шаблонов. Рекомендуется прямо сейчас не отклоняться от курса (если вы новичок в C ++) и принимать их в качестве основных параметров функции! Однако, если вы действительно одержимы изучением новых и мощных вещей, то вот хорошая статья и видео для этого.
Написание класса нейронной сети
Прежде чем идти дальше, я предполагаю, что вы знаете, что такое нейронная сеть и как она обучается. Если нет, то я рекомендую вам взглянуть на следующие страницы!

  • Основы нейронных сетей
  • Прямое и обратное распространение в нейронных сетях

Код: класс нейронной сети

// NeuralNetwork.hpp
#include <eigen3/Eigen/Eigen>
#include <iostream>
#include <vector>
// use typedefs for future ease for changing data types like : float to double
typedef float Scalar;
typedef Eigen::MatrixXf Matrix;
typedef Eigen::RowVectorXf RowVector;
typedef Eigen::VectorXf ColVector;
// neural network implementation class!
class NeuralNetwork {
public :
// constructor
NeuralNetwork(std::vector<uint> topology, Scalar learningRate = Scalar(0.005));
// function for forward propagation of data
void propagateForward(RowVector& input);
// function for backward propagation of errors made by neurons
void propagateBackward(RowVector& output);
// function to calculate errors made by neurons in each layer
void calcErrors(RowVector& output);
// function to update the weights of connections
void updateWeights();
// function to train the neural network give an array of data points
void train(std::vector<RowVector*> data);
// storage objects for working of neural network
/*
use pointers when using std::vector<Class> as std::vector<Class> calls destructor of
Class as soon as it is pushed back! when we use pointers it can't do that, besides
it also makes our neural network class less heavy!! It would be nice if you can use
smart pointers instead of usual ones like this
*/
std::vector<RowVector*> neuronLayers; // stores the different layers of out network
std::vector<RowVector*> cacheLayers; // stores the unactivated (activation fn not yet applied) values of layers
std::vector<RowVector*> deltas; // stores the error contribution of each neurons
std::vector<Matrix*> weights; // the connection weights itself
Scalar learningRate;
};

Затем мы продвигаемся вперед, реализуя каждую функцию одну за другой… Но сначала создайте два файла (NeuralNetwork.cpp и NeuralNetwork.hpp) и сами напишите приведенный выше код класса NeuralNetwork в «NeuralNetwork.hpp». Следующую строку кода необходимо скопировать в файл «NeuralNetwork.cpp».

Код: конструктор класса нейронной сети

// constructor of neural network class
NeuralNetwork::NeuralNetwork(std::vector<uint> topology, Scalar learningRate)
{
this ->topology = topology;
this ->learningRate = learningRate;
for (uint i = 0; i < topology.size(); i++) {
// initialze neuron layers
if (i == topology.size() - 1)
neuronLayers.push_back( new RowVector(topology[i]));
else
neuronLayers.push_back( new RowVector(topology[i] + 1));
// initialize cache and delta vectors
cacheLayers.push_back( new RowVector(neuronLayers.size()));
deltas.push_back( new RowVector(neuronLayers.size()));
// vector.back() gives the handle to recently added element
// coeffRef gives the reference of value at that place
// (using this as we are using pointers here)
if (i != topology.size() - 1) {
neuronLayers.back()->coeffRef(topology[i]) = 1.0;
cacheLayers.back()->coeffRef(topology[i]) = 1.0;
}
// initialze weights matrix
if (i > 0) {
if (i != topology.size() - 1) {
weights.push_back( new Matrix(topology[i - 1] + 1, topology[i] + 1));
weights.back()->setRandom();
weights.back()->col(topology[i]).setZero();
weights.back()->coeffRef(topology[i - 1], topology[i]) = 1.0;
}
else {
weights.push_back( new Matrix(topology[i - 1] + 1, topology[i]));
weights.back()->setRandom();
}
}
}
};

Объяснение функции конструктора - Инициализация нейронов, кеша и дельт
Вектор топологии описывает, сколько нейронов у нас в каждом слое, а размер этого вектора равен количеству слоев в нейронной сети. Каждый слой в нейронной сети представляет собой массив нейронов, мы храним каждый из этих слоев как вектор, так что каждый элемент в этом векторе хранит значение активации нейрона в этом слое (обратите внимание, что массив этих слоев - это сама нейронная сеть. . Теперь в строке 8 мы добавляем дополнительный нейрон смещения к каждому слою, кроме выходного слоя (строка 7). Кэш и вектор дельты имеют те же размеры, что и вектор нейронного слоя. Здесь мы используем векторы в качестве слоев и не 2D-матрица, как мы делаем SGD, а не пакетный или мини-пакетный градиентный спуск.Теперь кеш - это просто другое название суммы взвешенных входных данных из предыдущего слоя.
Обозначение, которое мы будем использовать для размеров матрицы: [mn] обозначает матрицу, имеющую m строк и n столбцов.

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

  • CURRENT_LAYER представляет слой, который принимает входные данные, а PREV_LAYER и FWD_LAYER представляют слой позади и перед слоем CURRENT_LAYER.
  • c-й столбец в матрице весов представляет соединение c-го нейрона в CURRENT_LAYER со всеми нейронами в PREV_LAYER.
  • r-й элемент c-го столбца в матрице весов представляет соединение c-го нейрона в CURRENT_LAYER с r-м нейроном в PREV_LAYER.
  • r-я строка в матрице весов представляет соединение всех нейронов в PREV_LAYER с r-м нейроном в CURRENT_LAYER.
  • c-й элемент r-й строки в матрице весов представляет соединение c-го нейрона в PREV_LAYER с r-м нейроном в CURRENT_LAYER.
  • Точки 1 и 2 будут использоваться, когда мы используем матрицу весов в обычном смысле, но точки 3 и 4 будут использоваться, когда мы будем использовать матрицу весов в транспонированном смысле (a (i, j) = a (j, I))

Теперь помните, что у нас есть дополнительный нейрон смещения в предыдущем слое. Если мы сделаем простое матричное произведение вектора нейронов слоя PREV_LAYER и матрицы весов CURRENT_LAYER, мы получим новый вектор слоя нейронов CURRENT_LAYER. Теперь нам нужно изменить нашу матрицу весов таким образом, чтобы нейрон смещения CURRENT_LAYER не подвергался влиянию умножения матрицы! Для этого мы устанавливаем для всех элементов последнего столбца матрицы весов значение 0 (строка 26), за исключением последнего элемента (строка 27).

Код: алгоритм прямой связи

void NeuralNetwork::propagateForward(RowVector& input)
{
// set the input to input layer
// block returns a part of the given vector or matrix
// block takes 4 arguments : startRow, startCol, blockRows, blockCols
neuronLayers.front()->block(0, 0, 1, neuronLayers.front()->size() - 1) = input;
// propagate the data forawrd
for (uint i = 1; i < topology.size(); i++) {
// already explained above
(*neuronLayers[i]) = (*neuronLayers[i - 1]) * (*weights[i - 1]);
}
// apply the activation function to your network
// unaryExpr applies the given function to all elements of CURRENT_LAYER
for (uint i = 1; i < topology.size() - 1; i++) {
neuronLayers[i]->block(0, 0, 1, topology[i]).unaryExpr(std::ptr_fun(activationFunction));
}
}

Объяснение алгоритма прямой связи:
C-й элемент (нейрон) CURRENT_LAYER принимает свои входные данные, беря скалярное произведение между вектором NeuronLayers PREV_LAYER и C-м столбцом. Таким образом, входные данные умножаются на вес, и это также автоматически добавляет член смещения. Последний столбец матрицы весов инициализируется установкой всех элементов в 0, кроме последнего элемента (установлен в 1), это означает, что нейрон смещения CURRENT_LAYER принимает входные данные только от нейрона смещения PREV_LAYER.

Расчет ошибок:

void NeuralNetwork::calcErrors(RowVector& output)
{
// calculate the errors made by neurons of last layer
(*deltas.back()) = output - (*neuronLayers.back());
// error calculation of hidden layers is different
// we will begin by the last hidden layer
// and we will continue till the first hidden layer
for (uint i = topology.size() - 2; i > 0; i--) {
(*deltas[i]) = (*deltas[i + 1]) * (weights[i]->transpose());
}
}

Код: Обновление весов

void NeuralNetwork::updateWeights()
{
// topology.size()-1 = weights.size()
for (uint i = 0; i < topology.size() - 1; i++) {
// in this loop we are iterating over the different layers (from first hidden to output layer)
// if this layer is the output layer, there is no bias neuron there, number of neurons specified = number of cols
// if this layer not the output layer, there is a bias neuron and number of neurons specified = number of cols -1
if (i != topology.size() - 2) {
for (uint c = 0; c < weights[i]->cols() - 1; c++) {
for (uint r = 0; r < weights[i]->rows(); r++) {
weights[i]->coeffRef(r, c) += learningRate * deltas[i + 1]->coeffRef(c) * activationFunctionDerivative(cacheLayers[i + 1]->coeffRef(c)) * neuronLayers[i]->coeffRef(r);
}
}
}
else {
for (uint c = 0; c < weights[i]->cols(); c++) {
for (uint r = 0; r < weights[i]->rows(); r++) {
weights[i]->coeffRef(r, c) += learningRate * deltas[i + 1]->coeffRef(c) * activationFunctionDerivative(cacheLayers[i + 1]->coeffRef(c)) * neuronLayers[i]->coeffRef(r);
}
}
}
}
}

Алгоритм обратного распространения ошибки:

void NeuralNetwork::propagateBackward(RowVector& output)
{
calcErrors(output);
updateWeights();
}

Код: функция активации

Scalar activationFunction(Scalar x)
{
return tanhf(x);
}
Scalar activationFunctionDerivative(Scalar x)
{
return 1 - tanhf(x) * tanhf(x);
}
// you can use your own code here!

Код: обучающая нейронная сеть

void NeuralNetwork::train(std::vector<RowVector*> input_data, std::vector<RowVector*> output_data)
{
for (uint i = 0; i < input_data.size(); i++) {
std::cout << "Input to neural network is : " << *input_data[i] << std::endl;
propagateForward(*input_data[i]);
std::cout << "Expected output is : " << *output_data[i] << std::endl;
std::cout << "Output produced is : " << *neuronLayers.back() << std::endl;
propagateBackward(*output_data[i]);
std::cout << "MSE : " << std:: sqrt ((*deltas.back()).dot((*deltas.back())) / deltas.back()->size()) << std::endl;
}
}

Код: загрузка данных

void ReadCSV(std::string filename, std::vector<RowVector*>& data)
{
data.clear();
std::ifstream file(filename);
std::string line, word;
// determine number of columns in file
getline(file, line, ' ' );
std::stringstream ss(line);
std::vector<Scalar> parsed_vec;
while (getline(ss, word, ', ' )) {
parsed_vec.push_back(Scalar(std::stof(&word[0])));
}
uint cols = parsed_vec.size();
data.push_back( new RowVector(cols));
for (uint i = 0; i < cols; i++) {
data.back()->coeffRef(1, i) = parsed_vec[i];
}
// read the file
if (file.is_open()) {
while (getline(file, line, ' ' )) {
std::stringstream ss(line);
data.push_back( new RowVector(1, cols));
uint i = 0;
while (getline(ss, word, ', ' )) {
data.back()->coeffRef(i) = Scalar(std::stof(&word[0]));
i++;
}
}
}
}

Пользователь может читать файлы csv с помощью этого кода и вставлять их в класс нейронной сети, но будьте осторожны, объявления и определения должны храниться в отдельных файлах (NeuralNetwork.cpp и NeuralNetwork.h). Сохраните все файлы и побывайте со мной несколько минут!

Код: генерировать некоторый шум, т.е. данные обучения

void genData(std::string filename)
{
std::ofstream file1(filename + "-in" );
std::ofstream file2(filename + "-out" );
for (uint r = 0; r < 1000; r++) {
Scalar x = rand () / Scalar(RAND_MAX);
Scalar y = rand () / Scalar(RAND_MAX);
file1 << x << ", " << y << std::endl;
file2 << 2 * x + 10 + y << std::endl;
}
file1.close();
file2.close();
}

Код: Реализация нейронной сети.

// main.cpp
// don't forget to include out neural network
#include "NeuralNetwork.hpp"
//... data generator code here
typedef std::vector<RowVector*> data;
int main()
{
NeuralNetwork n({ 2, 3, 1 });
data in_dat, out_dat;
genData( "test" );
ReadCSV( "test-in" , in_dat);
ReadCSV( "test-out" , out_dat);
n.train(in_dat, out_dat);
return 0;
}

Чтобы скомпилировать программу, откройте свой Linux-терминал и введите:
g ++ main.cpp NeuralNetwork.cpp -o main && ./main

Запустите эту команду. Попробуйте поэкспериментировать с количеством точек данных в этой функции genData.