Машинное обучение, Программирование

Реализуйте нейронную сеть с нуля с помощью NumPy

… Это не так сложно, как вы думаете

Я думаю, что лучший способ понять, как работает нейронная сеть, - это реализовать ее с нуля. Именно этим я и собираюсь заняться в этой статье. Я создам класс нейронной сети и хочу сделать его более гибким. Я не хочу жестко закодировать в нем конкретные функции активации или потери или оптимизаторы (то есть SGD, Adam или другие методы на основе градиента). Я спроектирую его так, чтобы получать их извне класса, чтобы можно было просто взять код класса и передать ему любую активацию / потерю / оптимизатор, который он захочет. Итак, я буду реализовывать функции активации и потери, а также класс оптимизатора, который мы хотим использовать здесь, отдельно от класса NeuralNetwork. И нам нужны как функции активации / потерь, так и их производные.

Чтобы разрешить размер пакета больше 1, наши функции активации и потерь должны обрабатывать матричный ввод. Строки в этих матрицах будут представлять разные точки данных, а столбцы - функциями. Наша сеть допускает 2 вида функций активации: для скрытых слоев и для выходного слоя. Активации скрытого слоя должны работать со своими входными векторами поэлементно, и, следовательно, их производные также будут поэлементными, возвращая один вектор для каждой точки данных. Но активация вывода должна позволять вычислять каждый элемент в векторе вывода на основе всех элементов вектора ввода. То есть, чтобы иметь возможность использовать активацию softmax. Из-за этого их производные должны возвращать матрицу Якоби (матрицу, состоящую из частных производных каждой выходной функции по каждому входному компоненту; вы можете прочитать больше в Википедии) для каждой точки данных.

Здесь мы будем использовать только ReLU в качестве скрытой активации; identity и softmax будут использоваться в качестве выходных активаций.

Мы использовали переменную EPS, которая является наименьшим положительным представимым числом типа float64, чтобы избежать деления на 0. Чтобы избежать ошибок переполнения в функции softmax, мы вычли максимум каждой точки данных из ввода. Нам разрешено это делать, потому что это не меняет вывод функции, так как имеет тот же эффект, что и деление обоих членов этой дроби на одинаковую величину.

Функции потерь должны принимать в качестве входных данных 2 матрицы: прогнозируемое y и истинное y, причем обе они имеют ту же форму, что и в функциях активации. Эти функции потерь должны выводить одно число для каждой точки данных. Их производные должны выводить вектор-строку для каждой точки данных, все они укладываются в массив размерности 3. Эта форма вывода требуется, чтобы иметь возможность использовать функцию matmul() NumPy для умножения на производную активации вывода. Обратите внимание на использование функции expand_dims() ниже, которая используется для возврата требуемой формы.

Здесь мы будем использовать только стохастический градиентный спуск с импульсом в качестве метода оптимизации, но есть и другие методы, основанные на градиенте. Некоторые популярные варианты: Adam, RMSprop, Adagrad. Чтобы позволить нашему классу нейронной сети работать со всем этим, мы реализуем оптимизатор как отдельный класс с методом .update(old_params, gradient), который возвращает обновленные параметры. Класс нейронной сети получит в качестве параметра оптимизатор. Итак, кто-то, кто хочет использовать другие методы оптимизации, может создать класс с требуемым интерфейсом и передать его нашему классу нейронной сети при создании экземпляра.

Ниже представлен оптимизатор импульса SGD +:

Чтобы преобразовать метки классов в задачах классификации в горячую кодировку, мы будем использовать служебную функцию to_categorical():

Теперь давайте начнем с кода класса NeuralNetwork. Метод создания экземпляра ожидает следующие параметры:

  • слои: список, состоящий из количества узлов в каждом слое (включая уровни ввода и вывода)
    например: [5, 10, 2] означает 5 входов, 10 узлов в скрытом слое. , и 2 выходных узла
  • hidden_activation: активация скрытых слоев; кортеж формы (функция_активирования, его_производная)
    Эта функция активации и ее производная должны выполнять свою задачу поэлементно для входного массива
    например: (relu, d_relu)
  • output_activation: активация выходного слоя; кортеж формы (Activ_function, its_derivative)
    Эта функция активации принимает в качестве входных данных массив shape (n, m); n выборок, m нейронов в выходном слое; и возвращает массив формы (n, m); каждый элемент в строке в выходном массиве является выходом функции всех элементов в этой строке во входном массиве.
    Его производная принимает в качестве входных данных массив, аналогичный массиву, взятому при активации, но возвращает массив формы (n, m, m), который представляет собой стек якобианских матриц, по одной для каждого образца.
  • потеря: кортеж формы (loss_function, its_derivative)
    Функция потерь принимает в качестве входных данных два массива (предсказанный y и истинный y) формы (n, m); n выборок, m нейронов в выходном слое; и возвращает массив формы (n, 1), элементами которого являются потери для каждой выборки.
    Его производная принимает в качестве входных данных массив формы (n, m) и возвращает одну из форм (n, 1, m ), который представляет собой стек векторов-строк, состоящий из производных по каждая из m входных переменных.
    например: (category_crossentropy, d_categorical_crossentropy)
  • оптимизатор: объект с методом .update (old_params, gradient), который возвращает новые параметры
    например: SGD ()

Затем он инициализирует свои веса и смещения, используя вариант метода инициализации Xavier. То есть мы выводим веса и смещения из нормального распределения со средним значением 0 и стандартным отклонением:

где fan_in и fan_out - количество узлов в предыдущем слое, соответственно количество нейронов в следующем слое. Количество строк в матрицах весов соответствует количеству узлов в предыдущем слое, а количество столбцов соответствует количеству узлов в следующем слое. Смещения представляют собой векторы-строки с количеством элементов, совпадающим с количеством узлов в следующем слое.

Чтобы упростить процедуру обновления параметров, мы создадим метод .__flatten_params(weights, biases), который преобразует список весовых матриц и векторы смещения, полученные в качестве входных данных, в сглаженный вектор. Нам также понадобится метод .__restore_params(params), который превращает плоский вектор параметров обратно в списки весов и смещений. Обратите внимание, что два подчеркивания перед именем метода просто означают, что метод является частным с точки зрения ООП. Это просто означает, что метод следует использовать только изнутри класса.

Метод .__forward(x) передает входной массив x по сети и при этом отслеживает входные и выходные массивы на каждый уровень и из него. Затем он возвращает это как список, i-й элемент которого является списком формы [вход в слой i, выход уровня i]. Эти массивы понадобятся нам для вычисления производных при обратном проходе.

Метод .__backward(io_arrays, y_true) вычисляет градиент. Он принимает в качестве входных данных список формы, возвращаемой методом .__forward(x), и массив с исходной истинностью y. Он вычисляет градиент весов и смещений, используя алгоритм обратного распространения ошибки. Затем он возвращает кортеж (d_weights, d_biases).

Все обучение осуществляется методом .fit(x, y, batch_size, epochs, categorical), где:

  • x - входные данные
  • y - это чистая правда
  • batch_size - размер пакета данных
  • epochs - количество итераций по всем входным данным
  • categorical - необязательный параметр, который при значении true преобразует y в одноразовую кодировку.

Для каждого пакета данных он использует методы .__forward() и .__backward() для вычисления градиента, а затем выравнивает текущие параметры сети и градиент с помощью .__flatten_params(). После этого вычисляет новые параметры с помощью self.optimizer.update(), затем восстанавливает возвращенный вектор в правильный формат с помощью .__restore_params() и присваивает его self.weights, self.biases. В конце каждой партии печатается прогресс и средняя потеря. Список всех значений потерь в конце каждой эпохи сохраняется и возвращается.

По умолчанию метод .predict() возвращает точные значения, которые находятся в выходных узлах после того, как вход x передается по сети. Если для параметра labels установлено значение true, то возвращаются предсказанные метки; это, вероятно, то, что вам нужно в задаче классификации.

Метод .score() по умолчанию возвращает средний убыток. Если для параметра «Точность» установлено значение «Истина», будет возвращена точность. Обратите внимание, что в проблеме классификации, если вы хотите потерять, тогда y должен быть предоставлен в формате однократного кодирования, в противном случае, если вы хотите, чтобы была возвращена точность, тогда y должны быть обычными метками класса.

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

Ниже приведен полный код:

Надеюсь, эта информация была для вас полезной, и спасибо за внимание!

Эта статья также размещена на моем собственном сайте здесь. Не стесняйтесь смотреть!