С нуля до работающей реализации

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

Нейронные сети обычно представляются визуально в виде графоподобной структуры, подобной приведенной ниже:

Выше представлена ​​нейронная сеть с 3-мя слоями: входным, скрытым и выходным, состоящим из 3, 4 и 2 нейронов.
Входной слой имеет столько узлов, сколько функций вашего набора данных. Для скрытого слоя вы можете выбрать, сколько узлов вы хотите, и вы можете использовать более одного скрытого слоя. Сети с более чем одним скрытым слоем называются глубокими нейронными сетями и являются основными персонажами области глубокого обучения. Сети с одним скрытым слоем обычно называются мелкими нейронными сетями. В выходном слое должно быть столько нейронов, сколько переменных вы хотите предсказать.

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

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

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

Давайте посмотрим на следующий пример:

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

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

Чтобы последний нейрон правильно выполнял классификацию, необходимо, чтобы выходы n1 и n2 скрытых нейронов были линейно разделенными, если мы построим их на 2-й плоскости. Две приведенные выше линии соответствуют уравнениям:

Это означает, что 2 скрытых нейрона вычисляют следующие линейные комбинации входных x1 и x2:

Давайте построим графики n1 и n2 и посмотрим, помогли ли они нам.

И мы разочарованы нашей маленькой нейронной сетью. Выходы n1 и n2 по-прежнему нельзя разделить линейно, и поэтому выходной нейрон не может правильно выполнить классификацию.
Итак, в чем проблема? Дело в том, что любая линейная комбинация линейных функций остается линейной, и нетрудно убедить себя на бумаге, что это правда. Итак, независимо от того, сколько слоев или сколько нейронов мы используем, наша нейронная сеть по-прежнему будет просто линейным классификатором.
Нам нужно нечто большее. Нам нужно взять взвешенную сумму, вычисленную каждым нейроном, и передать ее через нелинейную функцию, а затем рассмотреть выход этой функции как выход этого нейрона. Эти функции называются функциями активации, и, как вы можете видеть в нашем примере, они очень важны, позволяя нейронной сети изучать сложные закономерности в данных. Было доказано [1], что нейронная сеть с двумя слоями (кроме входного) и нелинейными функциями активации способна аппроксимировать любую функцию, при условии, что она имеет достаточно большое количество нейронов в этих слоях. Итак, если достаточно всего двух слоев, почему в наши дни люди используют гораздо более глубокие сети? Что ж, то, что эти двухуровневые сети «способны» чему-либо учиться, не означает, что их легко оптимизировать. На практике, если мы дадим нашей сети избыточную мощность, они предоставят нам достаточно хорошие решения, даже если они не будут оптимизированы так хорошо, как могли бы.

Есть и другие виды функций активации, две из которых мы хотим использовать в приведенном выше примере. Это ReLU (ReL ctified L inear U nit) и tanh (гиперболический тангенс), которые показаны ниже.

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

Теперь продолжим наш пример. Что произойдет, если мы воспользуемся активацией ReLU в нашем примере? Ниже показаны выходы нейронов n1 и n2 после применения активации ReLU.

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

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

Опять же, выходной нейрон может правильно классифицировать точки.

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

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

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

Мы видели, как нейронная сеть получает интересующие нас выходные данные, она просто передает свой входной вектор через последовательность функций. Но эти функции зависят от некоторых параметров: весов и смещений.

Как мы на самом деле узнаем эти параметры, чтобы делать хорошие прогнозы?

Что ж, давайте вспомним, что на самом деле представляет собой нейронная сеть: это просто функция, большая функция, состоящая из более мелких, которые применяются последовательно. Эта функция имеет набор параметров, которые, поскольку сначала мы понятия не имеем, какими они должны быть, мы просто инициализируем их случайным образом. Итак, сначала наша сеть будет выдавать нам просто случайные значения. Как мы можем их улучшить? Прежде чем пытаться их улучшить, нам сначала нужен способ оценки производительности нашей сети. Как мы должны улучшить производительность нашей модели, если у нас нет способа измерить, насколько хорошо или плохо она работает? Для этого нам нужно придумать функцию, которая принимает в качестве параметров прогнозы нашей сети и истинные метки в нашем наборе данных и дает нам число, которое представляет производительность нашей сети. Затем мы можем превратить задачу обучения в задачу оптимизации поиска минимума или максимума этой функции. В сообществе машинного обучения эта функция обычно измеряет, насколько плохи наши прогнозы, поэтому она называется функцией потерь. И наша задача - найти параметры нашей сети, которые минимизируют эту функцию потерь.

Стохастический градиентный спуск

Возможно, вы знакомы с проблемой поиска минимума функции из вашего класса исчисления. Там вы обычно берете градиент вашей функции, устанавливаете его равным 0, находите все решения (также называемые критическими точками), а затем выбираете среди них то, которое дает вашей функции наименьшее значение. И это глобальный минимум. Можем ли мы сделать то же самое, минимизируя нашу функцию потерь? Не совсем. Проблема в том, что функция потерь нейронной сети не так хороша и компактна, как те, которые вы обычно найдете в учебниках по математическому анализу. Это очень сложная функция с тысячами, сотнями тысяч или даже миллионами параметров. Может быть даже невозможно найти решение этой проблемы в закрытом виде. К этой проблеме обычно подходят итерационные методы, методы, которые не пытаются найти прямое решение, а вместо этого начинают со случайного решения и пытаются немного улучшить его на каждой итерации. В конце концов, после большого количества итераций мы получим довольно хорошее решение.

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

Где тета представляет собой вектор, содержащий все параметры сети.

В стандартном методе градиентного спуска градиент вычисляется с учетом всего набора данных. Обычно это нежелательно, поскольку может потребовать больших вычислительных ресурсов. На практике набор данных случайным образом делится на несколько блоков, называемых пакетами, и для каждого из этих пакетов выполняется обновление. Это называется стохастический градиентный спуск.

Приведенное выше правило обновления учитывает на каждом шаге только градиент, оцененный в текущей позиции. Таким образом, траектория точки, которая движется по поверхности функции потерь, чувствительна к любым возмущениям. Иногда мы можем захотеть сделать эту траекторию более устойчивой. Для этого мы используем концепцию, вдохновленную физикой: импульс. Идея состоит в том, что, когда мы делаем обновление, чтобы также учитывать предыдущие обновления, это накапливается в переменной Δθ. Если в том же направлении будет сделано больше обновлений, мы пойдем «быстрее» в этом направлении и не изменим нашу траекторию из-за какого-либо небольшого возмущения. Думайте об этом как о скорости.

Где α - неотрицательный фактор, определяющий вклад прошлых градиентов. Когда он равен 0, мы просто не используем импульс.

Обратное распространение

Как мы на самом деле вычисляем градиент? Напомним, что нейронная сеть и, следовательно, функция потерь - это просто набор функций. Как мы можем вычислять частные производные сложных функций? Использование цепного правила. Давайте посмотрим на следующее изображение:

Если мы хотим вычислить частные производные убытка относительно (относительно) весов первого слоя: берем производную от первой линейной комбинации по весу. веса, затем мы умножаем его на производную следующей функции (функции активации) относительно результат предыдущей функции, и так далее, пока мы не умножим на производную потерь относительно последняя функция активации. Что, если мы хотим вычислить производную по веса второго слоя? Мы должны проделать тот же процесс, но на этот раз мы начнем с производной второй линейной комбинированной функции по весу. его веса, а после этого остальные члены, на которые мы должны умножить, также присутствовали, когда мы вычисляли производную весов первого слоя. Поэтому вместо того, чтобы вычислять эти термины снова и снова, мы будем идти назад, отсюда и название обратное распространение.

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

Итак, при обратном распространении, когда мы сталкиваемся с функциями, у которых нет обучаемых параметров (например, функций активации), мы берем производные только первого типа, просто чтобы распространить ошибки в обратном направлении. Но когда мы сталкиваемся с функциями, у которых действительно есть обучаемые параметры (например, линейные комбинации, у нас есть веса и смещения, которые мы хотим изучить), мы берем производные обоих видов: первый - с. его вход для распространения ошибки, а второй - w.r.t. его веса и смещения и сохраните их как часть градиента. Мы выполняем этот процесс, начиная с функции потерь и до тех пор, пока не дойдем до первого слоя, где у нас нет никаких обучаемых параметров, которые мы хотим добавить к градиенту. Это алгоритм обратного распространения ошибки.

Избыточные мощности

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

Как видите, минимальное количество нейронов в скрытом слое, необходимое для решения этой задачи классификации, равно 2, по одному на каждую из 2 строк выше. В нейронных сетях рекомендуется добавить некоторую избыточную мощность, то есть добавить больше нейронов и / или слоев, чем минимально необходимо для решения этой конкретной проблемы. Это потому, что добавление дополнительных параметров облегчит задачу оптимизации. В приведенном выше примере, если мы используем только 2 скрытых нейрона, нам понадобится каждый из них, чтобы выучить «почти идеальную» линию, чтобы конечный результат был хорошим. Но если мы дадим больше свободы нашей сети и добавим больше нейронов в скрытый слой, они не обязательно будут идеальными. Мы сможем получить хорошие результаты, если большинство из них будет делать хоть что-то полезное для нашей задачи классификации. И затем мы можем думать о последнем нейроне как об усреднении границы принятия решения для каждого из них.

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

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

Активация Softmax и потеря кросс-энтропии

Часто используемой функцией активации для последнего уровня в задаче классификации является функция softmax.

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

Когда softmax используется как активация выходного слоя, мы обычно используем в качестве функции потерь кросс-энтропийную потерю. Потери перекрестной энтропии измеряют, насколько похожи 2 распределения вероятностей. Мы можем представить истинную метку нашего входного x как распределение вероятностей: такое, в котором у нас есть вероятность 1 для истинной метки класса и 0 для других меток класса. Такое представление меток также называется горячим кодированием. Затем мы используем кросс-энтропию, чтобы измерить, насколько близко предсказанное распределение вероятностей нашей сети к истинному.

Где y - быстрое кодирование истинной метки, y hat - предсказанное распределение вероятностей, а yi, yi hat - элементы этих векторов.

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

Среднеквадратичная потеря ошибки

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

Реализация этого в Python

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

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

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

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

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

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

Функции потерь должны принимать в качестве входных данных 2 матрицы: прогнозируемое y и истинное y, причем обе имеют ту же форму, что и в функциях активации. Эти функции потерь должны выводить одно число для каждой выборки. Их производные должны выводить вектор-строку для каждой выборки, все они укладываются в массив размерности 3. Эта форма вывода требуется для того, чтобы иметь возможность использовать функцию numpy matmul() для умножения на производную активации вывода. Обратите внимание на использование функции 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 - размер пакета данных
  • эпохи - это количество итераций по всем входным данным
  • категориальный - необязательный параметр, который, если задано значение true, преобразует y в одноразовую кодировку.

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

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

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

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

Примеры

Ниже мы покажем 2 примера, в которых мы используем наш только что реализованный класс NeuralNetwork.

Первый пример состоит из классификации изображений рукописных цифр из базы данных MNIST. Этот набор данных состоит из 60 000 обучающих и 10 000 тестовых изображений в оттенках серого размером 28 x 28 пикселей.

Мы используем пакет mnist (pip install mnist) для загрузки этого набора данных.

Затем мы строим нейронную сеть и обучаем ее 100 эпох.

Построим график потери тренировки.

Давайте посмотрим на точность, которую мы получили как на обучающем, так и на тестовом наборе:

И мы получили точность теста 99,8% и 95,9%. Это очень хорошо для нашей самодельной нейронной сети.

Теперь мы переходим ко второму примеру, в котором мы используем нашу нейронную сеть для решения задачи регрессии.
На этот раз мы будем использовать набор данных о жилье Калифорнии, который поставляется с пакетом sklearn. Набор данных состоит из 20640 выборок 8 прогнозных атрибутов и целевой переменной, которая является ln (медианная стоимость дома). Этот набор данных был получен из переписи населения США 1990 года с использованием одной строки на каждую блочную группу переписи. Блочная группа - это наименьшая географическая единица, для которой Бюро переписи населения США публикует выборочные данные.

На этот раз мы будем использовать среднеквадратичную ошибку как функцию потерь.

Построим график потерь во время тренировки.

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

И для тренировки, и для теста мы получили потерю около 0,36. Обратите внимание, что целевая переменная имеет логарифмическую шкалу. Итак, интерпретация среднеквадратичной ошибки здесь немного не интуитивна. Обычно мы говорим, что прогнозируемые значения отклоняются в среднем на +/- квадратный корень из MSE. Теперь, в нашем случае, средние значения стоимости домов, прогнозируемые нашей сетью, в среднем отклоняются на коэффициент (вместо +/- у нас есть умножение / деление) от е до квадратного корня из MSE (в нашем случае этот коэффициент составляет примерно 1,83).

использованная литература

[1] Цибенко, Г.В. (2006). «Аппроксимация суперпозициями сигмоидальной функции». В ван Шуппен, Ян Х. (ред.). Математика управления, сигналов и систем. Springer International. С. 303–314.

Вы можете найти файлы записной книжки и python на Github здесь.

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

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