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

от автора

Предисловие

Всем привет!

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

Со всеми корректными замечаниями по поводу кода жду в комментариях.

Решаемая задача

Решаемая задача звучит примерно следующим образом: На вход подается картинка размером 7х7, необходимо определить, что на ней нарисовано — круг, квадрат или треугольник.

Примеры корректных входных данных

Примеры корректных входных данных

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

Пример входных данных с шумом

Пример входных данных с шумом

Теоретическая составляющая

Для начала определимся с основными условными обозначениями.

y^k_i — выход нейрона i слоя k
n(k - 1) — количество нейронов в слое с номером k — 1
t_i — ожидаемое значение выхода i сети
N — номер последнего слоя сети
w^i_{ij} — вес нейрона в соответствующей связи, k — номер слоя, i — номер нейрона, j — номер входа из предыдущего слоя

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

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

Логика работы сети предельно простая — нам надо рассчитать значения всех нейронов.

Считаются значения нейронов по следующей формуле: f(\sum^{n(k-1)}_{j=1}{y^{k-1}_jw^k_{ij}})

Объясняю эту формулу — мы берем все нейроны на предыдущем уровне и умножаем их на соответствующие веса, которые выходят из нейронов на прошлом уровне и доходят до нашего нейрона. Затем мы полученную сумму загоняем в функцию активации, это нужно для того, чтобы значения нейронов не превышали 1. В нашем случае, в качестве функции активации будем использовать сигмоиду:

f(x) = \frac{1}{1 + e^{-x}}

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

Для подстройки весов используем следующую формулу: w^k_{ij} = w^k_{ij} + \alpha*\delta^k_i*y^{k-1}_j

Альфа — коэффициент обучения, чем ближе фактические значения к ожидаемым, тем меньше нужно делать альфа.

Как вы могли заметить тут также появилось новое, ещё не известное нам обозначение дельта, его расчёт осуществляется по следующему правилу:

Для выходного слоя формула: \delta^N_i = y^N_i(1 - y^N_i)(t_i - y^N_i)

Для всех остальных слоев формула основывается на значениях предыдущего слоя:

\delta^k_i = y^k_i(1 - y^k_i)\sum^{n(k+1)}_{j=1}(\delta^{k+1}_jw^{k+1}_{ji})

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

Практическая составляющая

Наконец-то перейдем к написанию кода!

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

class NeuralNet { public:     void set_input(vector<vector<double>> input); // Передаем в сеть картинку     void set_expected(vector<double> input); // Передаем в сеть ожидаемые значения выхода     void train(void); // Запуск итерации обучения     size_t apply(void); // Запуск подсчёта сети без обучения (для валидации результатов)  private:     double activation(double x); // Функция активации     void count_neural_net(void); // Подсчёт значений нейронов     void clear_neural_net(void); // Обнуление значений нейронов     void recal_alpha(void); // Обновляем коэф обучения в зависимости от ошибки     void adj_weight(void); // Подстройка весов     size_t output_size; // Количество выходных нейронов = 3     size_t input_size; // Размер стороны входной картинки = 7     size_t neuron_size; // Количество нейронов в промежуточных слоях     size_t layer_count; // Количество промежуточных слоёв     double alpha; // Коэффициент обучения     vector<double> expected; // Для хранения ожидаемых значений     vector<vector<double>> layers; // Слои нейронов     vector<vector<double>> delta; // Значения дельта для соответствующих слоёв     vector<vector<vector<double>>> weight; // Веса сети };

Начнем с простого и будем двигаться к более сложному, напишем функции для обучения и валидации:

void NeuralNet::train(void) {     clear_neural_net(); // Обнуляем веса     count_neural_net(); // Счиатем веса     recal_alpha(); // В зависимости от результатов подстраиваем коэф обучение     if (err() > MAX_ERR) // Если ошибка достаточно большая, то подстраиваем веса         adj_weight(); } 
size_t NeuralNet::apply(void) {     clear_neural_net();     count_neural_net();     // То же, что и в обучении, только без подстройки весов      // и возвращаем номер наиболее вероятной фигуры     return distance(layers[layer_count + 1].begin(),        max_element(         layers[layer_count + 1].begin(),          layers[layer_count + 1].end())); }

Разберем пересчёт коэффициента обучения:

void NeuralNet::recal_alpha(void) {     double e = err(); // получаем ошибку     double rel_e = 2 * abs(e) / output_size; // Получаем среднее значение ошибки     // Подстраиваем коэф обучения под диапазон возможных значений alpha     alpha = rel_e * (MAX_ALPHA - MIN_ALPHA) + MIN_ALPHA;  }

Далее посмотрим на подсчёт значений нейронов:

void NeuralNet::count_neural_net(void) {     // Перебираем по очереди все слои     for (size_t layer = 0; layer <= layer_count; layer++)     {         // В каждом слое перебираем все нейроны         for (size_t neuron = 0; neuron < weight[layer].size(); neuron++)         {             // Для каждого нейрона перебираем все нейроны предыдущего уровня             for (size_t input = 0; input < weight[layer][neuron].size(); input++)             {                 // Сначала считаем сумму произведений нейронов прошлого уровня на их веса                 layers[layer + 1][neuron] += layers[layer][input] * weight[layer][neuron][input];             }             // А теперь применяем к сумме функцию активации             layers[layer + 1][neuron] = activation(layers[layer + 1][neuron]);         }     } }

Ну и самое сложное — подстройка весов:

void NeuralNet::adj_weight(void) {     // Сначала рассчитываем все дельты для выходного слоя, чтобы было от чего отталкиваться     for (size_t exp = 0; exp < output_size; exp++)     {         double t = expected[exp], y = layers[layer_count + 1][exp];         delta[layer_count][exp] = y * (1 - y) * (t - y);     }     // Теперь перебираем остальные слои (кроме входного) и считаем дельту для них     for (int layer = layer_count - 1; layer >= 0; layer--)     {         // Перебираем все нейроны в слое         for (size_t input = 0; input < layers[layer + 1].size(); input++)         {             double next_sum = 0;             // Для каждого нейрона перебираем все дельты на следующем уровне             for (size_t next_neuron = 0; next_neuron < layers[layer + 2].size(); next_neuron++)             {                 // Суммируем все взвешенные значения дельт на следующем уровне                 next_sum += delta[layer + 1][next_neuron] * weight[layer + 1][next_neuron][input];             }             // Домножаем на коэффициент y * (1 - y)             delta[layer][input] = layers[layer + 1][input] * (1 - layers[layer + 1][input]) * next_sum;         }     }     // Наконец можно подсчитать все новые веса     for (size_t layer = 0; layer < layer_count + 1; layer++)     {         for (size_t output = 0; output < weight[layer].size(); output++)         {             for (size_t input = 0; input < weight[layer][output].size(); input++)             {                 // Домножаем дельты на коэф обучения и значение самого нейрона                 weight[layer][output][input] += alpha * delta[layer][output] * layers[layer][input];             }         }     } }

Заключение

Не так страшна задача, когда декомпозируешь её на более мелкие задачи.

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

Далее приведу ссылку на исходный код, в котором количество нейронов в сети, количество слоёв и количество эпох являются параметризуемыми величинами, для того, чтобы вы смогли подобрать наиболее эффективные их значения. У меня получилось так, что наиболее эффективным оказалась сеть всего с одним скрытым слоем из 14 нейронов.

Если кому-то помог разобраться в теме, лайки и подписки приветствуются!

Исходный код


ссылка на оригинал статьи https://habr.com/ru/articles/846088/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *