
Зачем очередная статья про то, как писать нейронные сети с нуля? Увы, я не смог найти статьи, где были бы описаны теория и код с нуля до полностью работающей модели. Сразу предупреждаю, что тут будет много математики. Я предполагаю, что читатель знаком с основами линейной алгебры, частными производными и хотя бы частично, с теорией вероятностей, а также Python и Numpy. Будем разбираться с полносвязной нейронной сетью и MNIST.
Математика. Часть 1 (простая)
Что такое полносвязный слой (fully connected layer, FC layer)? Обычно говорят что-то в духе «Полносвязный слой — это слой, каждый нейрон которого соединён со всеми нейронами предыдущего слоя». Вот только непонятно что такое нейроны, как они соединены, особенно в коде. Сейчас я попробую разобрать это на примере. Вот пусть есть слой из 100 нейронов. Я знаю, что пока не объяснил, что это, но давайте просто представим, что есть 100 нейронов и у них есть вход, куда подаются данные, и выход, откуда они выдают данные. И на вход им подаётся чёрно-белая картинка 28х28 пикселей — всего 784 значения, если растянуть её в вектор. Картинку можно назвать входным слоем. Тогда чтобы каждый из 100 нейронов соединился с каждым «нейроном» или, если угодно, значением предыдущего слоя (то есть картинкой) нужно, чтобы каждый из 100 нейронов принял 784 значения исходной картинки. Например, для каждого из 100 нейронов достаточно будет умножить 784 значения картинки на какие-то 784 числа и сложить между собой, в результате выходит одно число. То есть это и есть нейрон:
Тогда получится, что у каждого нейрона есть 784 числа, а всего этих чисел: (количество нейронов на этом слое) х (количество нейронов на предыдущем слое) =
Дальше получившиеся 100 чисел передаются дальше, на функцию активации — некоторую нелинейную функцию — которая воздействует на каждое число поотдельности. Например, сигмоида, гиперболический тангенс, ReLU и другие. Функция активации обязательно нелинейная, иначе нейронная сеть научится только простым преобразованиям.

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

Во время обучения сети необходимо чтобы мы знали, какая цифра изображена на картинке. То есть чтобы датасет был размечен. Тогда можно использовать ещё один элемент — функцию ошибки. Она смотрит на ответ нейронной сети и сравнивает с настоящим ответом. Благодаря этому нейронная сеть и учится.
Общая постановка задачи
Весь датасет — это большой тензор (тензором будем называть некоторый многомерный массив данных)
Теперь давайте подробнее рассмотрим функцию
То есть, в самую первую функцию — первый слой — подается картинка в виде некоторого тензора. Функция
Теперь, задача — обучить сеть — сделать так, чтобы ответ сети совпадал с правильным ответом. Для начала нужно измерить насколько нейронная сеть ошиблась. Измерять это будет функция ошибки
1.
2.
3.
Ограничение 2 наложим на все функции слоев
Причем, на самом деле (об этом я умолчал) часть этих функции зависят от параметров — весов нейронной сети —
Итак, на чем мы остановились? Все функции нейронной сети дифференцируемы, функция ошибки тоже дифференцируема. Вспомним одно из свойств градиента — показывать направление роста функции. Воспользуемся этим, ограничениями 1 и 3, фактом, что
и тем, что я умею считать частные производные и производные сложной функции. Теперь есть все что нужно, для того чтобы посчитать
для любого i и j. Эта частная производная показывает направление, в котором нужно изменить
Значит процесс обучения сети строится так: несколько раз в цикле проходим по всему датасету (это называется эпоха), для каждого объекта датасета считаем
Замечу, что я еще не ввел никаких конкретных функций и слоев. Если на данном этапе не совсем ясно, что со всем этим делать, предлагаю продолжить чтение — математики станет больше, но теперь она будет идти с примерами.
Математика. Часть 2 (сложная)
Функция ошибки
Я начну с конца и выведу функцию ошибки для задачи классификации. Для задачи регрессии вывод функции ошибки хорошо описан в книге «Глубокое обучение. Погружение в мир нейронных сетей».
Для простоты, есть нейронная сеть (NN), которая отделяет фотографии котиков от фотографий собак, и есть набор фотографий кошек и собак, для которых есть правильный ответ
Все что я буду делать дальше, очень похоже на метод максимального правдоподобия. Поэтому основная задача — найти функцию правдоподобия. Если опустить подробности, то такую функцию, которая сопоставляет предсказание нейронной сети и правильный ответ, и, если они совпадают, выдает большое значение, если же нет, наоборот. На ум приходит вероятность правильного ответа при заданных параметрах:
А теперь сделаем некоторый финт, который вроде бы ни от куда не следует. Пусть нейронная сеть выдает ответ в виде двумерного вектора, сумма значений которого равна 1. Первый элемент этого вектора можно назвать мерой уверенности, что на фотографии кот, а второй элемент — мерой уверенности, что на фотографии пёс. Да это же почти вероятности!
Теперь функцию правдоподобия можно переписать в виде:
Где
Однако, в любом датасете есть много объектов (например, N объектов). Хочется, чтобы на каждом или большинстве объектов нейронная сеть выдавала верный ответ. И для этого нужно перемножить результаты формулы выше для каждого объекта из датасета.
Чтобы получить хорошие результаты, эту функцию нужно максимизировать. Но, во-первых, минимизировать круче, потому что у нас есть стохастический градиентный спуск и все плюшки для него — просто припишем минус, а, во-вторых, с огромным произведением работать затруднительно — логарифмируем.
Замечательно! Получилась перекрестная энтропия или, в бинарном случае, logloss. Эту функцию легко считать и еще легче дифференцировать:
Дифференцировать нужно для алгоритма обратного распространения ошибки. Замечу, что функция ошибки не изменяет размерность вектора. Если, как в случае MNIST, на выходе получается 10-мерный вектор ответов, то и при вычислении производной получится 10-мерный вектор производных. Ещё одна интересная вещь то, что только один элемент производной не будет равен нулю, при котором
Функции активации
На выходе каждого полносвязного слоя нейронной сети обязательно должна присутствовать нелинейная функция активации. Без неё невозможно обучить содержательную нейронную сеть. Если забежать вперед, то полносвязный слой нейронной сети — это просто умножение входных данных на матрицу весов. В линейной алгебре это называется линейным отображением — некоторая линейная функция. Комбинация линейных функций — тоже линейная функция. Но это значит, что такая функция может аппроксимировать только линейные функции. Увы, это не то, зачем нужны нейронные сети.
Softmax
Обычно эта функция используется на последнем слое сети, так как она превращает вектор с последнего слоя в вектор «вероятностей»: каждый элемент вектора лежит от 0 до 1 и их сумма равна 1. Она не меняет размерность вектора.
Теперь перейдем к поиску производной. Так как
Теперь про backpropagation. От предыдущего слоя (обычно это функция ошибки) приходит вектор производных
На выходе получаем 10-мерный вектор производных
ReLU
Массово использовать ReLU стали после 2011 года, когда вышла статья «Deep Sparse Rectifier Neural Networks». Однако, такая функция была известна и ранее. К ReLU применимо такое понятие, как «сила активации» (подробнее об этом можно почитать в книге «Глубокое обучение. Погружение в мир нейронных сетей»). Но главная особенность, которая делает ReLU привлекательнее других функций активации — простое вычисление производной:
Таким образом ReLU вычислительно эффективнее других функций активации (сигмоида, гиперболический тангенс и др.).
Полносвязный слой
Теперь время обсудить полносвязный слой. Наиболее важный из всех остальных, потому что именно в этом слое находятся все веса, которые и нужно настроить для того, чтобы нейронная сеть работала хорошо. Полносвязный слой это просто-напросто матрица весов:
Новое внутреннее представление получается, когда матрица весов умножается на столбец входных данных:
Где
При обратном распространении ошибки нужно взять производную по каждому весу этой матрицы. Упростим задачу и возьмем только производную по
На выходе получается матрица 100х784.

Теперь нужно понять, что же передавать на предыдущий слой. Для этого и для большего понимания что вообще сейчас произошло, я хочу записать то, что случилось при взятии производных на этом слое чуть-чуть другим языком, уйти от конкретики «что на что умножается» к функциям (опять).
Когда я хотел настроить веса, то я хотел взять производную функции ошибки по этим весам:
Так можно сделать, потому что можно рассмотреть
Можно подставить это в формулу выше:
Где E матрица состоящая из единиц (НЕ единичная матрица).
Теперь когда нужно взять производную от предыдущего слоя (пусть для простоты выкладок это тоже будет полносвязный слой, но в общем случае это ничего не меняет), то нужно рассмотреть
Именно
Код
В первую очередь эта статья нацелена на объяснение математики нейронных сетей. Коду я уделю совсем немного времени.
Это пример реализации функции ошибки:
class CrossEntropy:
def forward(self, y_true, y_hat):
self.y_hat = y_hat
self.y_true = y_true
self.loss = -np.sum(self.y_true * np.log(y_hat))
return self.loss
def backward(self):
dz = -self.y_true / self.y_hat
return dz
Класс имеет методы для прямого и обратного прохода. В момент прямого прохода экземпляр класса сохраняет данные внутри слоя, а в момент обратного прохода использует их для расчета градиента. Таким же образом построены и остальные слои. Благодаря этому становится возможным написать полносвязную нейронную в таком стиле:
class MnistNet:
def __init__(self):
self.d1_layer = Dense(784, 100)
self.a1_layer = ReLu()
self.drop1_layer = Dropout(0.5)
self.d2_layer = Dense(100, 50)
self.a2_layer = ReLu()
self.drop2_layer = Dropout(0.25)
self.d3_layer = Dense(50, 10)
self.a3_layer = Softmax()
def forward(self, x, train=True):
...
def backward(self,
dz,
learning_rate=0.01,
mini_batch=True,
update=False,
len_mini_batch=None):
...
Полный код можно посмотреть тут.
Также советую изучить эту статью на Хабре.
Заключение
Надеюсь, я смог объяснить и показать, что за нейронными сетями стоит довольно простая математика и, что это совершенно не страшно. Тем не менее для более глубокого понимания стоит попробовать написать свой «велосипед». Исправления и предложения с радостью почитаю в комментариях.