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

Эта статья изначально была разработана как блокнот IPython. Исходный код доступен здесь в Google Colab, откуда его можно скопировать, запустить и отредактировать самостоятельно. Требования для локального запуска доступны в конце этой статьи.

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

Построение нейронной сети

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

Чтобы выполнить это моделирование, мы импортируем типичные пакеты обработки научных данных в Python — numpy и pandas. Мы также импортируем инструменты построения графиков matplotlib и seaborn, чтобы собрать весь импорт, который нам нужен, в одном месте.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
sns.set();

Проход вперед

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

Если кто-то знаком с обозначениями суммирования и матричной алгеброй, математика и реализация прямого прохода на удивление просты для такой мощной модели. Формула для прогнозирования приведена ниже, где w, обозначает вес от узла iдо узла j. w₀ обозначает смещение в соответствующем слое, output обозначает выходной узел, диапазон i по входные данные и диапазон j по узлам скрытого слоя.

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

Ввод раз веса, плюс смещение, активировать!

Говоря математическим языком, возьмите матричное произведение вашего вектора входных данных и матрицы весов для рассматриваемого слоя, добавьте смещение и передайте результат через выбранную вами функцию активации. Матричная форма прямого прохода представлена ​​ниже, где Wₐ обозначает матрицу весов для слоя a, а bₐ обозначает вектор смещения для каждого слоя. При написании этого уравнения мы предполагаем, что функция активации (обозначаемая как σ) поэлементно применяется к векторным аргументам (т. е. векторизуется).

Обратите внимание, что для не входных слоев входные данные являются выходными данными предыдущего слоя, и что в практических реализациях добавление смещения обычно выполняется путем добавления 1 к началу вашего входного вектора и добавления другой строки (смещения) в верхнюю часть матрицы весов. Затем это касается добавления смещения посредством матричного умножения. Элемент (i, j) любой данной весовой матрицы (обычно обозначается w,) — это вес входного элемента iᵗʰ в сумме для вычисления значения узла j (на последующем уровне).

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

Функции активации

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

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

Сигмовидная функция

Бинарный классификатор может стремиться определить вероятность того, что данный вход принадлежит данному классу. Это будет означать, что выходы должны, будучи вероятностями, попадать в единичный интервал (o [0,1]). Сигмовидная функция всегда будет давать выходные данные в диапазоне от 0 до 1, и поэтому ее можно рассматривать как производящую достоверные вероятности. В других случаях можно использовать сигмовидную функцию, чтобы добавить некоторую нелинейность к расчетам, а также содержать значения в известной и стандартной шкале. Наш пример нейронной сети здесь является случаем последнего, когда нам не нужны вероятности, но мы реализуем нейронную сеть в ее простом первом воплощении. Сегодня другие функции активации, такие как ReLU, более популярны, но интуиция и математика сохраняются.

Функциональная форма сигмовидной функции приведена ниже.

Обратите внимание, что эта функция строго возрастает по x и изменяется от 0, когда x равно -, и от 1, когда x равно ∞. Оно гладкое, определенное для всех действительных чисел и всюду дифференцируемое.

На самом деле дифференциал принимает довольно красивую форму, что является одной из причин популярности функции. Это поможет нам при обучении модели позже.

Давайте построим сигмовидную функцию, чтобы мы могли видеть, с чем имеем дело.

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

@np.vectorize
def sigmoid(x):
    return 1/(1+np.exp(-x))

Наконец, давайте создадим несколько точек и создадим график.

x = np.linspace(-10, 10, 1000)
y = sigmoid(x)
plt.plot(x,y, figure=plt.figure(figsize=(9,4.5)))
plt.axvline(0, color='k', lw=0.5)
plt.xlabel('$x$', size=15)
plt.ylabel('$\sigma(x)$', size=15);

Теперь обратимся к производной сигмовидной функции.

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

Это приводит нас к следующей производной сигмовидной функции.

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

y = sigmoid(x) * (1 - sigmoid(x))
plt.plot(x,y, figure=plt.figure(figsize=(9,4.5)).set_dpi(400)) plt.axvline(0, color='k', lw=0.5)
plt.xlabel('$x$', size=15)
plt.ylabel("$\sigma'(x)$", size=15);

Мы можем видеть, что для входных данных большой величины производная сигмовидной функции очень мала. Это видно из его функциональной формы. Член σ(x) стремится к нулю при увеличении x, а член 1 - σ(x) стремится к нулю при x становится все более негативным. Максимальное значение градиента — 0,25, если x равно нулю. Как мы увидим позже, обучение нейронной сети требует следующих градиентов. Поэтому небольшие градиенты могут мешать тренировкам. Случай, когда изменения в аргументах функции активации очень мало влияют на вывод функции активации, называется насыщением функции активации. Это происходит для очень высоких и очень низких значений аргумента в случае сигмовидной функции. Избегание таких точек насыщения, препятствующих обучению, является одной из причин, по которой мы инициализируем веса нейронной сети случайными небольшими значениями в начале обучения.

Создание прогнозов

В сети, которую мы строим, мы выбираем сигмовидную функцию в качестве нашей функции активации. Это ограничивает выходы данного слоя в единичном интервале. Однако из приведенной выше работы по генерации данных мы знаем, что выходные данные не обязательно будут находиться в этом интервале. Поэтому в финальном слое, чтобы не ограничивать диапазон выходов, вообще не будем применять активацию. Это изменяет приведенную выше формулу, удаляя сигмовидное преобразование (σ(·)), это также можно рассмотреть, просто изменив функцию активации в последнем слое на функцию идентичности (identity(x) = x).

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

Первоначально в первую позицию входного вектора x вставляется 1 для учета смещения (включенного здесь как дополнительная строка весов в соответствующих весовых матрицах). Затем для каждого промежуточного (то есть «скрытого») слоя проводится матричное умножение и к этому выходу применяется функция активации. Чтобы снова учесть аддитивное смещение, к выходному вектору скрытого слоя добавляется 1. Обратите внимание, что последний слой, не имеющий функции активации, рассматривается отдельно в предпоследней строке функции, написанной ниже. Умножение матриц выполняется как обычно, но активация не применяется.

def predict(x, weights):
    x = np.insert(x, 0, 1)
    output = x
    for layer in weights[:-1]:
        output = sigmoid(np.matmul(output, layer))
        output = np.insert(output, 0, 1)
    output = np.matmul(output, weights[-1])
    return output

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

Проход через передний перевал

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

Веса для каждого слоя будут храниться в матрице n × m, где n на единицу больше, чем количество узлов в предыдущем слое ( на единицу больше, чтобы учесть добавляемое смещение), а m — это количество узлов в последующем слое (на этот раз без учета любого смещения «узел»).

w = [
    np.random.normal(0, 0.5, size=(5,5)),
    np.random.normal(0, 0.5, size=(6,1))
]

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

x = np.array([0.003, 0.19, 0.1, 0.39])

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

predict(x, w) 
>>> array([-0.3672341])

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

print(w) 
>>> [
    array([
        [-0.35488, -0.23659, -0.33867, 0.36387, 0.32611],
        [-0.26065, -1.05467, 0.18559, 0.25842, -0.40552],
        [-0.08049, -0.62635, 0.52101, -0.08069, -0.83872],
        [-0.72111, -0.29579, -0.27537, 0.66396, 0.20578],
        [ 0.52081, -0.68363, 0.01350, -0.11467, 0.26864]
    ]),
    array([
        [ 0.03878],
        [ 0.70394],
        [-0.84874],
        [ 0.50273],
        [-0.8350 ],
        [-0.26286]
    ])
]

Постановка проблемы для решения

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

Во-первых, мы должны рассмотреть отношения, которые мы хотим построить. Рассмотрим функцию четырех переменных. Затем мы построим сеть с одним скрытым слоем из 5 единиц, чтобы аппроксимировать истинную функцию.

Мы позволяем нашим входным переменным быть случайными нормальными значениями (a, b, c, d ~ N (0,1)) так, чтобы все значения были в стандартизированной шкале. Это помогает в обучении сети. Хотя это масштабирование не обязательно будет таким же в реальных наборах данных, данные обычно нормализуются (получая так называемые z-значения) перед обучением на них нейронной сети. Причина нормализации заключается в достижении согласованного порядка величины входных данных, что предотвращает реализацию очень больших по величине значений, которые могли бы насытить функции активации. Кроме того, согласованная шкала значений для разных задач делает выбор и настройку гиперпараметров более общими для разных задач. Мы можем сохранить значения, использованные при исходной нормализации (среднее значение и стандартное отклонение обучающих данных), чтобы другие данные (например, тестовый набор) можно было стандартизировать точно таким же образом.

Начнем с генерации некоторых данных.

a = np.random.uniform(size=10000)
b = np.random.uniform(size=10000)
c = np.random.uniform(size=10000)
d = np.random.uniform(size=10000)

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

@np.vectorize
def hyperplane(a,b,c,d):
    value = (
        sigmoid(0.1*a)
        + 0.4*sigmoid(b)
        - 0.2*sigmoid(c)
        + sigmoid(d)
    )
    return value

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

true_values = hyperplane(a,b,c,d)

Наконец, мы помещаем все наши данные в одно место. Мы создаем фрейм данных с входными данными, имеющими свои собственные столбцы, и выходными данными, добавленными справа. Это похоже на то, как мы можем ожидать, что реальный набор данных из эксперимента будет выглядеть. Работа с pandas должна помочь перейти к любой другой работе, которую вы, возможно, захотите продолжить с нейронными сетями (особенно при использовании стандартного вводного пакета машинного обучения scikit-learn).

dataset = pd.DataFrame(
    data={ 'a':a, 'b':b, 'c':c, 'd':d, 'value': true_values }
)

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

dataset.head()

Обучение нейронной сети

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

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

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

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

Мы обсудим математику обратного распространения ниже.

Обратный проход: обратное распространение

Теперь начинается более сложная часть с математической точки зрения. Обучение модели.

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

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

Подход, который мы выберем, — градиентный спуск. Интуиция такова: представьте, что есть только два веса, при которых вы хотите минимизировать потери. Таким образом, потери определяются для всех возможных пар значений веса. Учитывая, что веса являются непрерывными переменными, пространство весов и связанных с ними потерь можно изобразить в трехмерном пространстве, где «пол» представляет собой координаты в w₁, w ₂ пространство и высота в любой точке задаются как потеря = L(w₁, w₂). Мы можем представить, что это напоминает горный пейзаж. Веса инициализируются в случайной точке (w₁⁰, w₂⁰), из которой мы хотим достичь оптимума (w₁*, w₂*), которая будет самой низкой точкой на поверхности (т. е. точкой, где потери минимальны). Математически мы ищем argmin L(w₁, w₂). Мы доберемся туда шаг за шагом, двигаясь «вниз». Это делается путем вычисления градиента в нашем текущем положении, направления наискорейшего спуска, а затем шага заданного размера в этом направлении. После этого шага мы повторно оцениваем градиент, а затем переходим к следующему шагу. Этот спуск будет снижать нас до тех пор, пока мы не достигнем минимума или у нас не закончатся шаги или время вычислений.

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

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

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

Где прогноз модели задается ŷ, а целевое значение задается yᵗʳᵘᵉ.

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

Во-первых, напомним себе формулу прямого прохода.

На высоком уровне производная функции потерь по определенному весу w, будет даваться следующим образом.

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

Нетривиальная часть этой производной — член производной от ŷ. Давайте сосредоточимся на этом термине и перестроим полную производную, как только у нас будут все части.

Рассмотрим более простой случай элемента конечного слоя (то есть этой производной по весам между скрытым слоем и выходом).

Где hⱼ обозначает значение единицы jᵗʰ в скрытом слое, а wⱼ, обозначает вес между узлом jᵗʰ скрытого слоя и выходным узлом. Чтобы уточнить:

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

Где (применяя тот факт, что σ'(x) = σ(x) (1 - σ(x))):

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

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

Теперь мы можем видеть, что

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

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

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

Чтобы добиться этих результатов на практике, нам нужно будет получить значения узлов на промежуточных этапах нашей сети (т.е. значения hⱼ). Напишем функцию, которая лишь частично завершает вычисления нашей нейронной сети.

def get_intermediate_outputs(x, weights, layer):
    x = np.insert(x, 0, 1)
    output = x
    full_network = False
    if layer == len(weights):
        layer -= 1
        full_network = True
    for i in range(layer):
        output = sigmoid(np.matmul(output, weights[i]))
        output = np.insert(output, 0, 1)
    if full_network:
        output = np.matmul(output, weights[-1])
    return output

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

Комментарии в коде содержат подробное объяснение вычислений и обоснование их.

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

def calculate_gradients(inputs, weights):
    # Finding the number of layers in our network
    no_layers = len(weights)
    # Attaining predictions to ultimately attain errors.
    predictions = predict(inputs, weights)
    # Since we know the final layer derivatives are simply equal to
    # the values of the nodes in the hidden layer we can get these 
    # values directly using the function written previously. 
    final_layer_derivatives = get_intermediate_outputs(
        inputs, weights, no_layers - 1
    ).reshape(weights[-1].shape)
    # Attaining the values output from the first layer to help in
    # constructing the gradients from here.
    # These are the net values (again)
    first_layer_outputs = get_intermediate_outputs(
        inputs, weights, 1
    )

    # Initialising an array to hold the gradients 
    previous_layer_derivatives = np.array([])
    # Looping over hidden layer nodes (j) and inputs(i)
    for j in range(weights[-2].shape[1]):
        for i in range(weights[-2].shape[0]):
            # Applying the formula for the gradient as given in the
            # text above.
            # If we are handling a bias the gradient is the same 
            # calculation but forcing the input to be 0
            if i == 0:
                gradient = (
                    1 * weights[-1][j] * first_layer_outputs[j+1] *
                    (1 - first_layer_outputs[j+1]
                )
            else:
                gradient = (
                    inputs[i-1] * weights[-1][j] * 
                    first_layer_outputs[j+1]
                    * (1 - first_layer_outputs[j+1])
                )
            # Adding our newfound gradient to the array  
            previous_layer_derivatives = np.append(
                previous_layer_derivatives, gradient
            )
    # Reshaping our gradients array to align with the weights matrix 
    # to which it applies
    previous_layer_derivatives = (
        np.reshape(previous_layer_derivatives, weights[-2].shape)
    )
    # Returning the gradients in a two arrays, one for each layer
    return previous_layer_derivatives, final_layer_derivatives

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

calculate_gradients(x, w)
>>> (array([
        [ 9.5570e-3, 2.8671e-5, 1.8158e-3, 9.5570e-4, 3.7272e-3],
        [ 1.5838e-1, 4.7515e-4, 3.0093e-2, 1.5838e-2, 6.1770e-2],
        [-2.0860e-1, -6.2580-4, -3.9634e-2, -2.0860e-2, -8.1354e-2],
        [ 1.2145e-1, 3.6437e-4, 2.3076e-2, 1.2145e-2, 4.7368e-2],
        [-2.0440e-1, -6.1320e-4, -3.8836e-2, -2.0440e-2, -7.9717e-2]
    ]),
     array([
            [1. ],
            [0.44029649],
            [0.34187132],
            [0.43502067],
            [0.59169838],
            [0.57221471]
]))

Наконец-то мы готовы объединить всю нашу работу для реализации полного алгоритма обратного распространения. Мы перебираем набор данных, чтобы получить наши прогнозы, а затем вычисляем наши ошибки. Затем достигаются градиенты, и в этом направлении делается шаг размером eta (η).

Каждый проход по набору данных известен как эпоха, размер шага также известен как скорость обучения и часто обозначается греческой буквой эта (η).

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

def backprop(inputs, weights, true_values, epochs, eta):
    w = np.copy(weights)
    for _ in range(epochs):
        predictions = np.array([])
        errors = np.array([])
        gradients_l1 = np.array([w[0]])
        gradients_l2 = np.array([w[1]])
        for i, t in zip(inputs, true_values):
            prediction = predict(i, w)
            predictions = np.append(predictions, prediction)
            errors = np.append(errors, prediction - t)
            g1, g2 = calculate_gradients(i, w)
            gradients_l1 = np.vstack((gradients_l1, [g1])) 
            gradients_l2 = np.vstack((gradients_l2, [g2]))
    grads = [
        errors.mean() * gradients_l1.mean(axis=0),
        errors.mean() * gradients_l2.mean(axis=0)
    ]
    predictions = np.array(predictions)
    rmse = np.sqrt(((predictions - true_values)**2).mean())
    print(rmse)
    for l in range(len(w)):
        w[l] -= eta * grads[l]
    return w

Наконец, мы собираем наши данные в массивы numpy и применяем обратное распространение, используя 100 эпох и начальную скорость обучения 0,2.

all_x = dataset[['a','b','c','d']].values
y = dataset['value'].values
weights_learned = backprop(all_x, w, y, 25, 0.2)
>>> 1.578374146804774
>>> 0.9312349021539885
>>> 0.5504270612951493
>>> 0.3250227156553526
>>> 0.19379625178844223
>>> 0.11996587444471339
>>> 0.08115042336692907
>>> 0.0629904988895241
>>> 0.05559431277355389
>>> 0.05287504823997944
>>> 0.05191990388661968
>>> 0.05158625746082776
>>> 0.05146744701966655
>>> 0.05142347747905161
>>> 0.05140626381138705
>>> 0.05139903472974495
>>> 0.0513957602600725
>>> 0.05139416917360941
>>> 0.05139335085545238 
>>> 0.05139291233823147
>>> 0.05139267084067152
>>> 0.051392535541941925 
>>> 0.051392458948078694
>>> 0.051392415319202835
>>> 0.05139239037771956

Мы видим, что среднеквадратическая ошибка (RMSE) становится меньше и выглядит так, как будто прогресс выравнивается при RMSE около 0,05. Увеличьте количество эпох и перезапустите код, чтобы увидеть, насколько хорошую модель вы можете создать.

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

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

weights_learned = backprop(all_x, weights_learned, y, 15, 0.1)
>>> 0.05139237608939999
>>> 0.051392371990503886
>>> 0.05139236876535353 
>>> 0.05139236622740657
>>> 0.05139236423005673
>>> 0.0513923626580425 
>>> 0.051392361420719686
>>> 0.05139236044678768
>>> 0.05139235968015157
>>> 0.05139235907667318
>>> 0.05139235860161863
>>> 0.0513923582276522 
>>> 0.05139235793325921
>>> 0.0513923577015054
>>> 0.05139235751906127

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

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

y_hat = [y_hat.append(predict(x, weights_learned)[0]) for i in x]
dataset['y hat'] = y_hat
dataset.head(10)

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

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

Как мы могли бы улучшить модель?

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

Способом улучшения модели как таковой может быть постепенное обучение или стохастический градиентный спуск. Инкрементный случай включает обновление весов после того, как каждая точка данных или небольшое подмножество (мини-пакет) прошли через алгоритм обратного распространения, а не агрегирование и обновление со средними значениями по всем данным. Стохастический градиентный спуск включает в себя обновление после выборки точек данных и вычисление градиентов для выборки только перед усреднением и обновлением. Это позволяет использовать некоторую выборку шума в обучении, что может помочь исследовать пространство весов в поисках более низкого конечного оптимума по пути, более надежному для неглубоких локальных оптимумов и седловых точек.

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

Требования к коду

При написании кода для этой статьи мы использовали Python 3.8.6. Соответствующие версии пакетов приведены ниже.

matplotlib==3.3.2
numpy==1.18.5
pandas==1.2.0
seaborn==0.11.1

Первоначально опубликовано на https://www.solvesmart.co.uk.