Обратное распространение ошибки ошибка слоя

Время на прочтение
5 мин

Количество просмотров 82K

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

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

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

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

Алгоритм обратного распространения ошибки

Для обучения описанной нейронной сети был использован алгоритм обратного распространения ошибки (backpropagation). Этот метод обучения многослойной нейронной сети называется обобщенным дельта-правилом. Метод был предложен в 1986 г. Румельхартом, Макклеландом и Вильямсом. Это ознаменовало возрождение интереса к нейронным сетям, который стал угасать в начале 70-х годов. Данный алгоритм является первым и основным практически применимым для обучения многослойных нейронных сетей.

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

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

Величина ошибки определяется по формуле 2.8 среднеквадратичная ошибка:

Неактивированное состояние каждого нейрона j для образа p записывается в виде взвешенной суммы по формуле 2.9:

Выход каждого нейрона j является значением активационной функции

, которая переводит нейрон в активированное состояние. В качестве функции активации может использоваться любая непрерывно дифференцируемая монотонная функция. Активированное состояние нейрона вычисляется по формуле 2.10:

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

Градиент функции потери представляет из себя вектор частных производных, вычисляющийся по формуле 2.11:

Производную функции ошибки по конкретному образу можно записать по правилу цепочки, формула 2.12:

Ошибка нейрона обычно записывается в виде символа δ (дельта). Для выходного слоя ошибка определена в явном виде, если взять производную от формулы 2.8, то получим t минус y, то есть разницу между желаемым и полученным выходом. Но как рассчитать ошибку для скрытых слоев? Для решения этой задачи, как раз и был придуман алгоритм обратного распространения ошибки. Суть его заключается в последовательном вычислении ошибок скрытых слоев с помощью значений ошибки выходного слоя, т.е. значения ошибки распространяются по сети в обратном направлении от выхода к входу.

Ошибка δ для скрытого слоя рассчитывается по формуле 2.13:

Алгоритм распространения ошибки сводится к следующим этапам:

  • прямое распространение сигнала по сети, вычисления состояния нейронов;
  • вычисление значения ошибки δ для выходного слоя;
  • обратное распространение: последовательно от конца к началу для всех скрытых слоев вычисляем δ по формуле 2.13;
  • обновление весов сети на вычисленную ранее δ ошибки.

Алгоритм обратного распространения ошибки в многослойном персептроне продемонстрирован ниже:

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

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

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


Рисунок 2.8 — Интерпретация операции свертки в многослойный вид, где связи с одинаковым цветом имеют один и тот же вес. Синим цветом обозначена подвыборочная карта, разноцветным – синаптическое ядро, оранжевым – получившаяся свертка

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

Рисунок 2.9 — Вычисление δ подвыборочного слоя за счет δ сверточного слоя и ядра

Обратная свертка – это тот же самый способ вычисления дельт, только немного хитрым способом, заключающийся в повороте ядра на 180 градусов и скользящем процессе сканирования сверточной карты дельт с измененными краевыми эффектами. Простыми словами, нам необходимо взять ядро сверточной карты (следующего за подвыборочным слоем) повернуть его на 180 градусов и сделать обычную свертку по вычисленным ранее дельтам сверточной карты, но так чтобы окно сканирования выходило за пределы карты. Результат операции обратной свертки в соответствии с рисунком 2.10, цикл прохода обратной свертки в соответствии с рисунком 2.11.

Рисунок 2.10 — Результат операции обратной свертки


Рисунок 2.11 — Повернутое ядро на 180 градусов сканирует сверточную карту

Расчет ошибки на сверточном слое

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

Заключение

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

Источники

Алгоритм обратного распространения ошибки для сверточной нейронной сети

Обратное распространение ошибки в сверточных слоях
раз и два

Обратное распространение ошибки в персептроне

Еще можно почитать в РГБ диссертацию Макаренко: АЛГОРИТМЫ И ПРОГРАММНАЯ СИСТЕМА КЛАССИФИКАЦИИ

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

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

      Обновление правила цепочки

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

      f(x)=A(B(C(x)))

      A, B, и C — функции активации на различных слоях. Пользуясь правилом цепочки, мы легко вычисляем производную f(x) по x:

      f′(x)=f′(A)⋅A′(B)⋅B′(C)⋅C′(x)

      Что насчёт производной относительно B? Чтобы найти производную по B, вы можете сделать вид, что B (C(x)) является константой, заменить ее переменной-заполнителем B, и продолжить поиск производной по B стандартно.

      f′(B)=f′(A)⋅A′(B)

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

      Применение правила цепочки

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

      обратное распространение ошибки

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

      Cost=C(R(Z(XW)))

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

      C′(W)=C′(R)⋅R′(Z)⋅Z′(W)=(y^−y)⋅R′(Z)⋅X

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

      обратное распространение ошибки нейронная сеть

      Какова производная от потери по Wo?

      C′(WO)=C′(y^)⋅y^′(ZO)⋅Z′O(WO)=(y^−y)⋅R′(ZO)⋅H

      А что насчет Wh? Чтобы узнать это, мы просто продолжаем возвращаться в нашу функцию, рекурсивно применяя правило цепочки, пока не доберемся до функции, которая имеет элемент Wh.

      C′(Wh)=C′(y^)⋅O′(Zo)⋅Z′o(H)⋅H′(Zh)⋅Z′h(Wh)=(y^−y)⋅R′(Zo)⋅Wo⋅R′(Zh)⋅X

      И просто забавы ради, что, если в нашей сети было бы 10 скрытых слоев. Что такое производная потери для первого веса w1?

      C(w1)=(dC/dy^)⋅(dy^/dZ11)⋅(dZ11/dH10)⋅(dH10/dZ10)⋅(dZ10/dH9)⋅(dH9/dZ9)⋅(dZ9/dH8)⋅(dH8/dZ8)⋅(dZ8/dH7)⋅(dH7/dZ7)⋅(dZ7/dH6)⋅(dH6/dZ6)⋅(dZ6/dH5)⋅(dH5/dZ5)⋅(dZ5/dH4)⋅(dH4/dZ4)⋅(dZ4/dH3)⋅(dH3/dZ3)⋅(dZ3/dH2)⋅(dH2/dZ2)⋅(dZ2/dH1)⋅(dH1/dZ1)⋅(dZ1/dW1)

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

      Сохранение работы с мемоизацией

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

      уравнение обратного распространения

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

      уравнение

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

      Примечание: термин ошибка слоя относится к производной потерь по входу в слой. Он отвечает на вопрос: как изменяется выход функции потерь при изменении входа в этот слой?

      Ошибка выходного слоя

      Для расчета ошибки выходного слоя необходимо найти производную потерь по входу выходному слою, Zo. Это отвечает на вопрос: как веса последнего слоя влияют на общую ошибку в сети?  Тогда производная такова:

      C′(Zo)=(y^−y)⋅R′(Zo)

      Чтобы упростить запись, практикующие МО обычно заменяют последовательность (y^−y)∗R'(Zo) термином Eo. Итак, наша формула для ошибки выходного слоя равна:

      Eo=(y^−y)⋅R′(Zo)

      Ошибка скрытого слоя

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

      C′(Zh)=(y^−y)⋅R′(Zo)⋅Wo⋅R′(Zh)

      Далее мы можем поменять местами элемент Eo выше, чтобы избежать дублирования и создать новое упрощенное уравнение для ошибки скрытого слоя:

      Eh=Eo⋅Wo⋅R′(Zh)

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

      Производная потерь по любому весу

      Вернемся к нашей формуле для производной потерь по весу выходного слоя Wo.

      C′(WO)=(y^−y)⋅R′(ZO)⋅H

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

      C′(Wo)=Eo⋅H

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

      C′(w)=CurrentLayerError⋅CurrentLayerInput

      Примечание: вход относится к активации с предыдущего слоя, а не к взвешенному входу, Z.

      Подводя итог

      Вот последние 3 уравнения, которые вместе образуют основу обратного распространения.

      основа обратного распространения

      Вот процесс, визуализированный с использованием нашего примера нейронной сети выше:

      _images/backprop_visually.png

      Обратное распространение: пример кода

      def relu_prime(z):
      if z > 0:
      return 1
      return 0
      
      def cost(yHat, y):
      return 0.5 * (yHat - y)**2
      
      def cost_prime(yHat, y):
      return yHat - y
      
      def backprop(x, y, Wh, Wo, lr):
      yHat = feed_forward(x, Wh, Wo)
      
      # Layer Error
      Eo = (yHat - y) * relu_prime(Zo)
      Eh = Eo * Wo * relu_prime(Zh)
      
      # Cost derivative for weights
      dWo = Eo * H
      dWh = Eh * x
      
      # Update weights
      Wh -= lr * dWh
      Wo -= lr * dWo

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

      Открытие метода обратного распространения ошибки стало одним из наиболее значимых событий в области искусственного интеллекта. В актуальном виде он был предложен в 1986 году Дэвидом Э. Румельхартом, Джеффри Э. Хинтоном и Рональдом Дж. Вильямсом и независимо и одновременно красноярскими математиками С. И. Барцевым и В. А. Охониным. С тех пор для нахождения градиентов параметров нейронной сети используется метод вычисления производной сложной функции, и оценка градиентов параметров сети стала хоть сложной инженерной задачей, но уже не искусством. Несмотря на простоту используемого математического аппарата, появление этого метода привело к значительному скачку в развитии искусственных нейронных сетей.

      Суть метода можно записать одной формулой, тривиально следующей из формулы производной сложной функции: если $f(x) = g_m(g_{m-1}(ldots (g_1(x)) ldots))$, то $frac{partial f}{partial x} = frac{partial g_m}{partial g_{m-1}}frac{partial g_{m-1}}{partial g_{m-2}}ldots frac{partial g_2}{partial g_1}frac{partial g_1}{partial x}$. Уже сейчас мы видим, что градиенты можно вычислять последовательно, в ходе одного обратного прохода, начиная с $frac{partial g_m}{partial g_{m-1}}$ и умножая каждый раз на частные производные предыдущего слоя.

      Backpropagation в одномерном случае

      В одномерном случае всё выглядит особенно просто. Пусть $w_0$ — переменная, по которой мы хотим продифференцировать, причём сложная функция имеет вид

      $$f(w_0) = g_m(g_{m-1}(ldots g_1(w_0)ldots)),$$

      где все $g_i$ скалярные. Тогда

      $$f'(w_0) = g_m'(g_{m-1}(ldots g_1(w_0)ldots))cdot g’_{m-1}(g_{m-2}(ldots g_1(w_0)ldots))cdotldots cdot g’_1(w_0)$$

      Суть этой формулы такова. Если мы уже совершили forward pass, то есть уже знаем

      $$g_1(w_0), g_2(g_1(w_0)),ldots,g_{m-1}(ldots g_1(w_0)ldots),$$

      то мы действуем следующим образом:

      • берём производную $g_m$ в точке $g_{m-1}(ldots g_1(w_0)ldots)$;

      • умножаем на производную $g_{m-1}$ в точке $g_{m-2}(ldots g_1(w_0)ldots)$;

      • и так далее, пока не дойдём до производной $g_1$ в точке $w_0$.

      Проиллюстрируем это на картинке, расписав по шагам дифференцирование по весам $w_i$ функции потерь логистической регрессии на одном объекте (то есть для батча размера 1):

      17_1.png

      Собирая все множители вместе, получаем:

      $$frac{partial f}{partial w_0} = (-y)cdot e^{-y(w_0 + w_1x_1 + w_2x_2)}cdotfrac{-1}{1 + e^{-y(w_0 + w_1x_1 + w_2x_2)}}$$

      $$frac{partial f}{partial w_1} = x_1cdot(-y)cdot e^{-y(w_0 + w_1x_1 + w_2x_2)}cdotfrac{-1}{1 + e^{-y(w_0 + w_1x_1 + w_2x_2)}}$$

      $$frac{partial f}{partial w_2} = x_2cdot(-y)cdot e^{-y(w_0 + w_1x_1 + w_2x_2)}cdotfrac{-1}{1 + e^{-y(w_0 + w_1x_1 + w_2x_2)}}$$

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

      Почему же нельзя просто пойти и начать везде вычислять производные?

      В главе, посвящённой матричным дифференцированиям, мы поднимаем вопрос о том, что вычислять частные производные по отдельности — это зло, лучше пользоваться матричными вычислениями. Но есть и ещё одна причина: даже и с матричной производной в принципе не всегда хочется иметь дело. Рассмотрим простой пример. Допустим, что $X^r$ и $X^{r+1}$ — два последовательных промежуточных представления $Ntimes M$ и $Ntimes K$, связанных функцией $X^{r+1} = f^{r+1}(X^r)$. Предположим, что мы как-то посчитали производную $frac{partialmathcal{L}}{partial X^{r+1}_{ij}}$ функции потерь $mathcal{L}$, тогда

      $$frac{partialmathcal{L}}{partial X^{r}_{st}} = sum_{i,j}frac{partial f^{r+1}_{ij}}{partial X^{r}_{st}}frac{partialmathcal{L}}{partial X^{r+1}_{ij}}$$

      И мы видим, что, хотя оба градиента $frac{partialmathcal{L}}{partial X_{ij}^{r+1}}$ и $frac{partialmathcal{L}}{partial X_{st}^{r}}$ являются просто матрицами, в ходе вычислений возникает «четырёхмерный кубик» $frac{partial f_{ij}^{r+1}}{partial X_{st}^{r}}$, даже хранить который весьма болезненно: уж больно много памяти он требует ($N^2MK$ по сравнению с безобидными $NM + NK$, требуемыми для хранения градиентов). Поэтому хочется промежуточные производные $frac{partial f^{r+1}}{partial X^{r}}$ рассматривать не как вычисляемые объекты $frac{partial f_{ij}^{r+1}}{partial X_{st}^{r}}$, а как преобразования, которые превращают $frac{partialmathcal{L}}{partial X_{ij}^{r+1}}$ в $frac{partialmathcal{L}}{partial X_{st}^{r}}$. Целью следующих глав будет именно это: понять, как преобразуется градиент в ходе error backpropagation при переходе через тот или иной слой.

        Вы спросите себя: надо ли мне сейчас пойти и прочитать главу учебника про матричное дифференцирование?

      Встречный вопрос. Найдите производную функции по вектору $x$:

      $$f(x) = x^TAx, Ain Mat_{n}{mathbb{R}}text{ — матрица размера }ntimes n$$

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

      Мы же сразу перейдём к интересующей нас вещи: к вычислению градиентов сложных функций.

      Градиент сложной функции

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

      $$left[D_{x_0} (color{#5002A7}{u} circ color{#4CB9C0}{v}) right](h) = color{#5002A7}{left[D_{v(x_0)} u right]} left( color{#4CB9C0}{left[D_{x_0} vright]} (h)right)$$

      Теперь разберёмся с градиентами. Пусть $f(x) = g(h(x))$ – скалярная функция. Тогда

      $$left[D_{x_0} f right] (x-x_0) = langlenabla_{x_0} f, x-x_0rangle.$$

      С другой стороны,

      $$left[D_{h(x_0)} g right] left(left[D_{x_0}h right] (x-x_0)right) = langlenabla_{h_{x_0}} g, left[D_{x_0} hright] (x-x_0)rangle = langleleft[D_{x_0} hright]^* nabla_{h(x_0)} g, x-x_0rangle.$$

      То есть $color{#FFC100}{nabla_{x_0} f} = color{#348FEA}{left[D_{x_0} h right]}^* color{#FFC100}{nabla_{h(x_0)}}g$ — применение сопряжённого к $D_{x_0} h$ линейного отображения к вектору $nabla_{h(x_0)} g$.

      Эта формула — сердце механизма обратного распространения ошибки. Она говорит следующее: если мы каким-то образом получили градиент функции потерь по переменным из некоторого промежуточного представления $X^k$ нейронной сети и при этом знаем, как преобразуется градиент при проходе через слой $f^k$ между $X^{k-1}$ и $X^k$ (то есть как выглядит сопряжённое к дифференциалу слоя между ними отображение), то мы сразу же находим градиент и по переменным из $X^{k-1}$:

      17_2.png

      Таким образом слой за слоем мы посчитаем градиенты по всем $X^i$ вплоть до самых первых слоёв.

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

      Градиенты для типичных слоёв

      Рассмотрим несколько важных примеров.

      Примеры

      1. $f(x) = u(v(x))$, где $x$ — вектор, а $v(x)$ – поэлементное применение $v$:

        $$vbegin{pmatrix}
        x_1 \
        vdots\
        x_N
        end{pmatrix}
        = begin{pmatrix}
        v(x_1)\
        vdots\
        v(x_N)
        end{pmatrix}$$

        Тогда, как мы знаем,

        $$left[D_{x_0} fright] (h) = langlenabla_{x_0} f, hrangle = left[nabla_{x_0} fright]^T h.$$

        Следовательно,

        $$
        left[D_{v(x_0)} uright] left( left[ D_{x_0} vright] (h)right) = left[nabla_{v(x_0)} uright]^T left(v'(x_0) odot hright) =\
        $$

        $$
        = sumlimits_i left[nabla_{v(x_0)} uright]_i v'(x_{0i})h_i
        = langleleft[nabla_{v(x_0)} uright] odot v'(x_0), hrangle.
        ,$$

        где $odot$ означает поэлементное перемножение. Окончательно получаем

        $$color{#348FEA}{nabla_{x_0} f = left[nabla_{v(x_0)}uright] odot v'(x_0) = v'(x_0) odot left[nabla_{v(x_0)} uright]}$$

        Отметим, что если $x$ и $h(x)$ — это просто векторы, то мы могли бы вычислять всё и по формуле $frac{partial f}{partial x_i} = sum_jbig(frac{partial z_j}{partial x_i}big)cdotbig(frac{partial h}{partial z_j}big)$. В этом случае матрица $big(frac{partial z_j}{partial x_i}big)$ была бы диагональной (так как $z_j$ зависит только от $x_j$: ведь $h$ берётся поэлементно), и матричное умножение приводило бы к тому же результату. Однако если $x$ и $h(x)$ — матрицы, то $big(frac{partial z_j}{partial x_i}big)$ представлялась бы уже «четырёхмерным кубиком», и работать с ним было бы ужасно неудобно.

      2. $f(X) = g(XW)$, где $X$ и $W$ — матрицы. Как мы знаем,

        $$left[D_{X_0} f right] (X-X_0) = text{tr}, left(left[nabla_{X_0} fright]^T (X-X_0)right).$$

        Тогда

        $$
        left[ D_{X_0W} g right] left(left[D_{X_0} left( ast Wright)right] (H)right) =
        left[ D_{X_0W} g right] left(HWright)=\
        $$ $$
        = text{tr}, left( left[nabla_{X_0W} g right]^T cdot (H) W right) =\
        $$ $$
        =
        text{tr} , left(W left[nabla_{X_0W} (g) right]^T cdot (H)right) = text{tr} , left( left[left[nabla_{X_0W} gright] W^Tright]^T (H)right)
        $$

        Здесь через $ast W$ мы обозначили отображение $Y hookrightarrow YW$, а в предпоследнем переходе использовалось следующее свойство следа:

        $$
        text{tr} , (A B C) = text{tr} , (C A B),
        $$

        где $A, B, C$ — произвольные матрицы подходящих размеров (то есть допускающие перемножение в обоих приведённых порядках). Следовательно, получаем

        $$color{#348FEA}{nabla_{X_0} f = left[nabla_{X_0W} (g) right] cdot W^T}$$

      3. $f(W) = g(XW)$, где $W$ и $X$ — матрицы. Для приращения $H = W — W_0$ имеем

        $$
        left[D_{W_0} f right] (H) = text{tr} , left( left[nabla_{W_0} f right]^T (H)right)
        $$

        Тогда

        $$
        left[D_{XW_0} g right] left( left[D_{W_0} left(X astright) right] (H)right) = left[D_{XW_0} g right] left( XH right) =
        $$ $$
        = text{tr} , left( left[nabla_{XW_0} g right]^T cdot X (H)right) =
        text{tr}, left(left[X^T left[nabla_{XW_0} g right] right]^T (H)right)
        $$

        Здесь через $X ast$ обозначено отображение $Y hookrightarrow XY$. Значит,

        $$color{#348FEA}{nabla_{X_0} f = X^T cdot left[nabla_{XW_0} (g)right]}$$

      4. $f(X) = g(softmax(X))$, где $X$ — матрица $Ntimes K$, а $softmax$ — функция, которая вычисляется построчно, причём для каждой строки $x$

        $$softmax(x) = left(frac{e^{x_1}}{sum_te^{x_t}},ldots,frac{e^{x_K}}{sum_te^{x_t}}right)$$

        В этом примере нам будет удобно воспользоваться формализмом с частными производными. Сначала вычислим $frac{partial s_l}{partial x_j}$ для одной строки $x$, где через $s_l$ мы для краткости обозначим $softmax(x)_l = frac{e^{x_l}} {sum_te^{x_t}}$. Нетрудно проверить, что

        $$frac{partial s_l}{partial x_j} = begin{cases}
        s_j(1 — s_j), & j = l,
        -s_ls_j, & jne l
        end{cases}$$

        Так как softmax вычисляется независимо от каждой строчки, то

        $$frac{partial s_{rl}}{partial x_{ij}} = begin{cases}
        s_{ij}(1 — s_{ij}), & r=i, j = l,
        -s_{il}s_{ij}, & r = i, jne l,
        0, & rne i
        end{cases},$$

        где через $s_{rl}$ мы обозначили для краткости $softmax(X)_{rl}$.

        Теперь пусть $nabla_{rl} = nabla g = frac{partialmathcal{L}}{partial s_{rl}}$ (пришедший со следующего слоя, уже известный градиент). Тогда

        $$frac{partialmathcal{L}}{partial x_{ij}} = sum_{r,l}frac{partial s_{rl}}{partial x_{ij}} nabla_{rl}$$

        Так как $frac{partial s_{rl}}{partial x_{ij}} = 0$ при $rne i$, мы можем убрать суммирование по $r$:

        $$ldots = sum_{l}frac{partial s_{il}}{partial x_{ij}} nabla_{il} = -s_{i1}s_{ij}nabla_{i1} — ldots + s_{ij}(1 — s_{ij})nabla_{ij}-ldots — s_{iK}s_{ij}nabla_{iK} =$$

        $$= -s_{ij}sum_t s_{it}nabla_{it} + s_{ij}nabla_{ij}$$

        Таким образом, если мы хотим продифференцировать $f$ в какой-то конкретной точке $X_0$, то, смешивая математические обозначения с нотацией Python, мы можем записать:

        $$begin{multline*}
        color{#348FEA}{nabla_{X_0}f =}\
        color{#348FEA}{= -softmax(X_0) odot text{sum}left(
        softmax(X_0)odotnabla_{softmax(X_0)}g, text{ axis = 1}
        right) +}\
        color{#348FEA}{softmax(X_0)odot nabla_{softmax(X_0)}g}
        end{multline*}
        $$

      Backpropagation в общем виде

      Подытожим предыдущее обсуждение, описав алгоритм error backpropagation (алгоритм обратного распространения ошибки). Допустим, у нас есть текущие значения весов $W^i_0$ и мы хотим совершить шаг SGD по мини-батчу $X$. Мы должны сделать следующее:

      1. Совершить forward pass, вычислив и запомнив все промежуточные представления $X = X^0, X^1, ldots, X^m = widehat{y}$.
      2. Вычислить все градиенты с помощью backward pass.
      3. С помощью полученных градиентов совершить шаг SGD.

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

      17_3.png Обучаемые параметры – матрицы $U$ и $W$. Как найти градиенты по ним в точке $U_0, W_0$?

      $$nabla_{W_0}mathcal{L} = nabla_{W_0}{left({vphantom{frac12}mathcal{L}circ hcircleft[Wmapsto g(XU_0)Wright]}right)}=$$

      $$=g(XU_0)^Tnabla_{g(XU_0)W_0}(mathcal{L}circ h) = underbrace{g(XU_0)^T}_{ktimes N}cdot
      left[vphantom{frac12}underbrace{h’left(vphantom{int_0^1}g(XU_0)W_0right)}_{Ntimes 1}odot
      underbrace{nabla_{hleft(vphantom{int_0^1}g(XU_0)W_0right)}mathcal{L}}_{Ntimes 1}right]$$

      Итого матрица $ktimes 1$, как и $W_0$

      $$nabla_{U_0}mathcal{L} = nabla_{U_0}left(vphantom{frac12}
      mathcal{L}circ hcircleft[Ymapsto YW_0right]circ gcircleft[ Umapsto XUright]
      right)=$$

      $$=X^Tcdotnabla_{XU^0}left(vphantom{frac12}mathcal{L}circ hcirc [Ymapsto YW_0]circ gright) =$$

      $$=X^Tcdotleft(vphantom{frac12}g'(XU_0)odot
      nabla_{g(XU_0)}left[vphantom{in_0^1}mathcal{L}circ hcirc[Ymapsto YW_0right]
      right)$$

      $$=ldots = underset{Dtimes N}{X^T}cdotleft(vphantom{frac12}
      underbrace{g'(XU_0)}_{Ntimes K}odot
      underbrace{left[vphantom{int_0^1}left(
      underbrace{h’left(vphantom{int_0^1}g(XU_0)W_0right)}_{Ntimes1}odotunderbrace{nabla_{h(vphantom{int_0^1}gleft(XU_0right)W_0)}mathcal{L}}_{Ntimes 1}
      right)cdot underbrace{W^T}_{1times K}right]}_{Ntimes K}
      right)$$

      Итого $Dtimes K$, как и $U_0$

      Схематически это можно представить следующим образом:

      17_4.gif

      Backpropagation для двуслойной нейронной сети

      Подробнее о предыдущих вычисленияхЕсли вы не уследили за вычислениями в предыдущем примере, давайте более подробно разберём его чуть более конкретную версию (для $g = h = sigma$).

      Рассмотрим двуслойную нейронную сеть для классификации. Мы уже встречали ее ранее при рассмотрении линейно неразделимой выборки. Предсказания получаются следующим образом:

      $$
      widehat{y} = sigma(X^1 W^2) = sigmaBig(big(sigma(X^0 W^1 )big) W^2 Big).
      $$

      Пусть $W^1_0$ и $W^2_0$ — текущее приближение матриц весов. Мы хотим совершить шаг по градиенту функции потерь, и для этого мы должны вычислить её градиенты по $W^1$ и $W^2$ в точке $(W^1_0, W^2_0)$.

      Прежде всего мы совершаем forward pass, в ходе которого мы должны запомнить все промежуточные представления: $X^1 = X^0 W^1_0$, $X^2 = sigma(X^0 W^1_0)$, $X^3 = sigma(X^0 W^1_0) W^2_0$, $X^4 = sigma(sigma(X^0 W^1_0) W^2_0) = widehat{y}$. Они понадобятся нам дальше.

      Для полученных предсказаний вычисляется значение функции потерь:

      $$
      l = mathcal{L}(y, widehat{y}) = y log(widehat{y}) + (1-y) log(1-widehat{y}).
      $$

      Дальше мы шаг за шагом будем находить производные по переменным из всё более глубоких слоёв.

      1. Градиент $mathcal{L}$ по предсказаниям имеет вид

        $$
        nabla_{widehat{y}}l = frac{y}{widehat{y}} — frac{1 — y}{1 — widehat{y}} = frac{y — widehat{y}}{widehat{y} (1 — widehat{y})},
        $$

        где, напомним, $ widehat{y} = sigma(X^3) = sigmaBig(big(sigma(X^0 W^1_0 )big) W^2_0 Big)$ (обратите внимание на то, что $W^1_0$ и $W^2_0$ тут именно те, из которых мы делаем градиентный шаг).

      2. Следующий слой — поэлементное взятие $sigma$. Как мы помним, при переходе через него градиент поэлементно умножается на производную $sigma$, в которую подставлено предыдущее промежуточное представление:

        $$
        nabla_{X^3}l = sigma'(X^3)odotnabla_{widehat{y}}l = sigma(X^3)left( 1 — sigma(X^3) right) odot frac{y — widehat{y}}{widehat{y} (1 — widehat{y})} =
        $$

        $$
        = sigma(X^3)left( 1 — sigma(X^3) right) odot frac{y — sigma(X^3)}{sigma(X^3) (1 — sigma(X^3))} =
        y — sigma(X^3)
        $$

      3. Следующий слой — умножение на $W^2_0$. В этот момент мы найдём градиент как по $W^2$, так и по $X^2$. При переходе через умножение на матрицу градиент, как мы помним, умножается с той же стороны на транспонированную матрицу, а значит:

        $$
        color{blue}{nabla_{W^2_0}l} = (X^2)^Tcdot nabla_{X^3}l = (X^2)^Tcdot(y — sigma(X^3)) =
        $$

        $$
        = color{blue}{left( sigma(X^0W^1_0) right)^T cdot (y — sigma(sigma(X^0W^1_0)W^2_0))}
        $$

        Аналогичным образом

        $$
        nabla_{X^2}l = nabla_{X^3}lcdot (W^2_0)^T = (y — sigma(X^3))cdot (W^2_0)^T =
        $$

        $$
        = (y — sigma(X^2W_0^2))cdot (W^2_0)^T
        $$

      4. Следующий слой — снова взятие $sigma$.

        $$
        nabla_{X^1}l = sigma'(X^1)odotnabla_{X^2}l = sigma(X^1)left( 1 — sigma(X^1) right) odot left( (y — sigma(X^2W_0^2))cdot (W^2_0)^T right) =
        $$

        $$
        = sigma(X^1)left( 1 — sigma(X^1) right) odotleft( (y — sigma(sigma(X^1)W_0^2))cdot (W^2_0)^T right)
        $$

      5. Наконец, последний слой — это умножение $X^0$ на $W^1_0$. Тут мы дифференцируем только по $W^1$:

        $$
        color{blue}{nabla_{W^1_0}l} = (X^0)^Tcdot nabla_{X^1}l = (X^0)^Tcdot big( sigma(X^1) left( 1 — sigma(X^1) right) odot (y — sigma(sigma(X^1)W_0^2))cdot (W^2_0)^Tbig) =
        $$

        $$
        = color{blue}{(X^0)^Tcdotbig(sigma(X^0W^1_0)left( 1 — sigma(X^0W^1_0) right) odot (y — sigma(sigma(X^0W^1_0)W_0^2))cdot (W^2_0)^Tbig) }
        $$

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

      Автоматизация и autograd

      Итак, чтобы нейросеть обучалась, достаточно для любого слоя $f^k: X^{k-1}mapsto X^k$ с параметрами $W^k$ уметь:

      • превращать $nabla_{X^k_0}mathcal{L}$ в $nabla_{X^{k-1}_0}mathcal{L}$ (градиент по выходу в градиент по входу);
      • считать градиент по его параметрам $nabla_{W^k_0}mathcal{L}$.

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

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

      Но это лишь начало

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

      Знакомимся с методом обратного распространения ошибки

      Всем привет! Новогодние праздники подошли к концу, а это значит, что мы вновь готовы делиться с вами полезным материалом. Перевод данной статьи подготовлен в преддверии запуска нового потока по курсу «Алгоритмы для разработчиков».

      Поехали!


      Метод обратного распространения ошибки – вероятно самая фундаментальная составляющая нейронной сети. Впервые он был описан в 1960-е и почти 30 лет спустя его популяризировали Румельхарт, Хинтон и Уильямс в статье под названием «Learning representations by back-propagating errors».

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

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

      Определяем модель нейронной сети

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


      Простое изображение четырехслойной нейронной сети.

      Входной слой

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


      Уравнение, описывающее входы xi.

      Первый набор активаций (а) равен входным значениям. «Активация» — это значение нейрона после применения функции активации. Подробнее смотрите ниже.

      Скрытые слои

      Конечные значения в скрытых нейронах (на рисунке зеленого цвета) вычисляются с использованием zl – взвешенных входов в слое I и aI активаций в слое L. Для слоев 2 и 3 уравнения будут следующими:

      Для l = 2:

      Для l = 3:

      W2 и W3 – это веса на слоях 2 и 3, а b2 и b3 – смещения на этих слоях.

      Активации a2 и a3 вычисляются с помощью функции активации f. Например, эта функция f является нелинейной (как сигмоид, ReLU и гиперболический тангенс) и позволяет сети изучать сложные паттерны в данных. Мы не будем подробно останавливаться на том, как работают функции активации, но, если вам интересно, я настоятельно рекомендую прочитать эту замечательную статью.

      Присмотревшись внимательно, вы увидите, что все x, z2, a2, z3, a3, W1, W2, b1 и b2 не имеют нижних индексов, представленных на рисунке четырехслойной нейронной сети. Дело в том, что мы объединили все значения параметров в матрицы, сгруппированные по слоям. Это стандартный способ работы с нейронными сетями, и он довольно комфортный. Однако я пройдусь по уравнениям, чтобы не возникло путаницы.

      Давайте возьмем слой 2 и его параметры в качестве примера. Те же самые операции можно применить к любому слою нейронной сети.
      W1 – это матрица весов размерности (n, m), где n – это количество выходных нейронов (нейронов на следующем слое), а m – число входных нейронов (нейронов в предыдущем слое). В нашем случае n = 2 и m = 4.

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

      x – входной вектор размерностью (m, 1), где m – число входных нейронов. В нашем случае m = 4.

      b1 – это вектор смещения размерности (n, 1), где n – число нейронов на текущем слое. В нашем случае n = 2.

      Следуя уравнению для z2 мы можем использовать приведенные выше определения W1, x и b1 для получения уравнения z2:

      Теперь внимательно посмотрите на иллюстрацию нейронной сети выше:

      Как видите, z2 можно выразить через z12 и z22, где z12 и z22 – суммы произведений каждого входного значения xi на соответствующий вес Wij1.

      Это приводит к тому же самому уравнению для z2 и доказывает, что матричные представления z2, a2, z3 и a3 – верны.

      Выходной слой

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

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

      Прямое распространение и оценка

      Приведенные выше уравнения формируют прямое распространение по нейронной сети. Вот краткий обзор:

      (1) – входной слой
      (2) – значение нейрона на первом скрытом слое
      (3) – значение активации на первом скрытом слое
      (4) – значение нейрона на втором скрытом слое
      (5) – значение активации на втором скрытом уровне
      (6) – выходной слой

      Заключительным шагом в прямом проходе является оценка прогнозируемого выходного значения s относительно ожидаемого выходного значения y.

      Выходные данные y являются частью обучающего набора данных (x, y), где x – входные данные (как мы помним из предыдущего раздела).

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

      Мы назовем эту функцию потерь С и обозначим ее следующим образом:

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

      Основываясь на значении С, модель «знает», насколько нужно скорректировать ее параметры, чтобы приблизиться к ожидаемому выходному значению y. Это происходит с помощью метода обратного распространения ошибки.

      Обратное распространение ошибки и вычисление градиентов

      Опираясь на статью 1989 года, метод обратного распространения ошибки:

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

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

      Возникает один вопрос: Зачем вычислять градиенты?

      Чтобы ответить на этот вопрос, нам сначала нужно пересмотреть некоторые понятия вычислений:

      Градиентом функции С(x1, x2, …, xm) в точке x называется вектор частных производных С по x.

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

      Градиент показывает, насколько необходимо изменить параметр x (в положительную или отрицательную сторону), чтобы минимизировать С.

      Вычисление этих градиентов происходит с помощью метода, называемого цепным правилом.
      Для одного веса (wjk)l градиент равен:

      (1) Цепное правило
      (2) По определению m – количество нейронов на l – 1 слое
      (3) Вычисление производной
      (4) Окончательное значение
      Аналогичный набор уравнений можно применить к (bj)l
      :

      (1) Цепное правило
      (2) Вычисление производной
      (3) Окончательное значение

      Общая часть в обоих уравнениях часто называется «локальным градиентом» и выражается следующим образом:

      «Локальный градиент» можно легко определить с помощью правила цепи. Этот процесс я не буду сейчас расписывать.

      Градиенты позволяют оптимизировать параметры модели:

      Пока не будет достигнут критерий остановки выполняется следующее:

      Алгоритм оптимизации весов и смещений (также называемый градиентным спуском)

      • Начальные значения w и b выбираются случайным образом.
      • Эпсилон (e) – это скорость обучения. Он определяет влияние градиента.
      • w и b – матричные представления весов и смещений.
      • Производная C по w или b может быть вычислена с использованием частных производных С по отдельным весам или смещениям.
      • Условие завершение выполняется, как только функция потерь минимизируется.

      Заключительную часть этого раздела я хочу посвятить простому примеру, в котором мы рассчитаем градиент С относительно одного веса (w22)2.

      Давайте увеличим масштаб нижней части вышеупомянутой нейронной сети:

      Визуальное представление обратного распространения в нейронной сети
      Вес (w22)2 соединяет (a2)2 и (z2)2, поэтому вычисление градиента требует применения цепного правила на (z2)3 и (a2)3:

      Вычисление конечного значения производной С по (a2)3 требует знания функции С. Поскольку С зависит от (a2)3, вычисление производной должно быть простым.

      Я надеюсь, что этот пример сумел пролить немного света на математику, стоящую за вычислением градиентов. Если захотите узнать больше, я настоятельно рекомендую вам посмотреть Стэндфордскую серию статей по NLP, где Ричард Сочер дает 4 замечательных объяснения обратного распространения.

      Заключительное замечание

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

      На этом все. Приглашаем всех на бесплатный вебинар по теме «Дерево отрезков: просто и быстро».

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

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

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

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

      Алгоритм обратного распространения ошибки

      Для обучения описанной нейронной сети был использован алгоритм обратного распространения ошибки (backpropagation). Этот метод обучения многослойной нейронной сети называется обобщенным дельта-правилом. Метод был предложен в 1986 г. Румельхартом, Макклеландом и Вильямсом. Это ознаменовало возрождение интереса к нейронным сетям, который стал угасать в начале 70-х годов. Данный алгоритм является первым и основным практически применимым для обучения многослойных нейронных сетей.

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

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

      Величина ошибки определяется по формуле 2.8 среднеквадратичная ошибка:

      Неактивированное состояние каждого нейрона j для образа p записывается в виде взвешенной суммы по формуле 2.9:

      Выход каждого нейрона j является значением активационной функции

      , которая переводит нейрон в активированное состояние. В качестве функции активации может использоваться любая непрерывно дифференцируемая монотонная функция. Активированное состояние нейрона вычисляется по формуле 2.10:

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

      Градиент функции потери представляет из себя вектор частных производных, вычисляющийся по формуле 2.11:

      Производную функции ошибки по конкретному образу можно записать по правилу цепочки, формула 2.12:

      Ошибка нейрона обычно записывается в виде символа δ (дельта). Для выходного слоя ошибка определена в явном виде, если взять производную от формулы 2.8, то получим t минус y, то есть разницу между желаемым и полученным выходом. Но как рассчитать ошибку для скрытых слоев? Для решения этой задачи, как раз и был придуман алгоритм обратного распространения ошибки. Суть его заключается в последовательном вычислении ошибок скрытых слоев с помощью значений ошибки выходного слоя, т.е. значения ошибки распространяются по сети в обратном направлении от выхода к входу.

      Ошибка δ для скрытого слоя рассчитывается по формуле 2.13:

      Алгоритм распространения ошибки сводится к следующим этапам:

      • прямое распространение сигнала по сети, вычисления состояния нейронов;
      • вычисление значения ошибки δ для выходного слоя;
      • обратное распространение: последовательно от конца к началу для всех скрытых слоев вычисляем δ по формуле 2.13;
      • обновление весов сети на вычисленную ранее δ ошибки.

      Алгоритм обратного распространения ошибки в многослойном персептроне продемонстрирован ниже:

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

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

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


      Рисунок 2.8 — Интерпретация операции свертки в многослойный вид, где связи с одинаковым цветом имеют один и тот же вес. Синим цветом обозначена подвыборочная карта, разноцветным – синаптическое ядро, оранжевым – получившаяся свертка

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

      Рисунок 2.9 — Вычисление δ подвыборочного слоя за счет δ сверточного слоя и ядра

      Обратная свертка – это тот же самый способ вычисления дельт, только немного хитрым способом, заключающийся в повороте ядра на 180 градусов и скользящем процессе сканирования сверточной карты дельт с измененными краевыми эффектами. Простыми словами, нам необходимо взять ядро сверточной карты (следующего за подвыборочным слоем) повернуть его на 180 градусов и сделать обычную свертку по вычисленным ранее дельтам сверточной карты, но так чтобы окно сканирования выходило за пределы карты. Результат операции обратной свертки в соответствии с рисунком 2.10, цикл прохода обратной свертки в соответствии с рисунком 2.11.

      Рисунок 2.10 — Результат операции обратной свертки


      Рисунок 2.11 — Повернутое ядро на 180 градусов сканирует сверточную карту

      Расчет ошибки на сверточном слое

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

      Заключение

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

      Источники

      Алгоритм обратного распространения ошибки для сверточной нейронной сети

      Обратное распространение ошибки в сверточных слоях
      раз и два

      Обратное распространение ошибки в персептроне

      Еще можно почитать в РГБ диссертацию Макаренко: АЛГОРИТМЫ И ПРОГРАММНАЯ СИСТЕМА КЛАССИФИКАЦИИ

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

      Впервые алгоритм обратного распространения придумали в 1970-х, но его важность не была до конца осознана вплоть до знаменитой работы 1986 года, которую написали Дэвид Румельхарт, Джоффри Хинтон и Рональд Уильямс. В работе описано несколько нейросетей, в которых обратное распространение работает гораздо быстрее, чем в более ранних подходах к обучению, из-за чего с тех пор можно было использовать нейросеть для решения ранее неразрешимых проблем. Сегодня алгоритм обратного распространения – рабочая лошадка обучения нейросети.

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

      Причина, конечно, в понимании. В основе обратного распространения лежит выражение частной производной ∂C / ∂w функции стоимости C по весу w (или смещению b) сети. Выражение показывает, насколько быстро меняется стоимость при изменении весов и смещений. И хотя это выражение довольно сложное, у него есть своя красота, ведь у каждого его элемента есть естественная и интуитивная интерпретация. Поэтому обратное распространение – не просто быстрый алгоритм для обучения. Он даёт нам подробное понимание того, как изменение весов и смещений меняет всё поведение сети. А это стоит того, чтобы изучить подробности.

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

      Для разогрева: быстрый матричный подход вычисления выходных данных нейросети

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

      Начнём с записи, позволяющей нам недвусмысленно обозначать веса в сети. Мы будем использовать wljk для обозначения веса связи нейрона №k в слое №(l-1) с нейроном №j в слое №l. Так, к примеру, на диаграмме ниже показан вес связи четвёртого нейрона второго слоя со вторым нейроном третьего слоя:

      Сначала такая запись кажется неуклюжей, и требует некоторых усилий на привыкание. Однако вскоре она покажется вам простой и естественной. Одна её особенность – порядок индексов j и k. Вы могли бы решить, что разумнее было бы использовать j для обозначения входного нейрона, а k – для выходного, а не наоборот, как у нас. Причину такой особенности я объясню ниже.

      Сходные обозначения мы будем использовать для смещений и активаций сети. В частности, blj будет обозначать смещение нейрона №j в слое №l. alj будет обозначать активацию нейрона №j в слое №l. На следующей диаграмме показаны примеры использования этой записи:

      С такой записью активация alj нейрона №j в слое №l связана с активацией в слое №(l-1) следующим уравнением (сравните с уравнением (4) и его обсуждением в прошлой главе):

      $ a^l_j = sigma (sum_k w^l_{jk} a^{l−1}_k + b^l_j) tag{23} $

      где сумма идёт по всем нейронам k в слое (l-1). Чтобы перезаписать это выражение в матричном виде, мы определим матрицу весов wl для каждого слоя l. Элементы матрицы весов – это просто веса, соединённые со слоем №l, то есть, элемент в строке №j и столбце №k будет wljk. Сходным образом для каждого слоя l мы определяем вектор смещения bl. Вы, наверное, догадались, как это работает – компонентами вектора смещения будут просто значения blj, по одному компоненту для каждого нейрона в слое №l. И, наконец, мы определим вектор активации al, компонентами которого будут активации alj.

      Последним ингредиентом, необходимым для того, чтобы перезаписать (23), будет матричная форма векторизации функции σ. С векторизацией мы вскользь столкнулись в прошлой главе – идея в том, что мы хотим применить функцию σ к каждому элементу вектора v. Мы используем очевидную запись σ(v) для обозначения поэлементного применения функции. То есть, компонентами σ(v) будут просто σ(v)j = σ(vj). Для примера, если у нас есть функция f(x) = x2, то векторизованная форма f даёт нам

      $ f( begin{bmatrix} 2 3 end{bmatrix} ) = begin{bmatrix} f(2) f(3) end{bmatrix} = begin{bmatrix} 4 9 end{bmatrix} tag{24} $

      то есть, векторизованная f просто возводит в квадрат каждый элемент вектора.

      Учитывая все эти формы записи, уравнение (23) можно переписать в красивой и компактной векторизованной форме:

      $ a^l = sigma(w^l a^{l−1} + b^l) tag{25} $

      Такое выражение позволяет нам более глобально взглянуть на связь активаций одного слоя с активациями предыдущего: мы просто применяем матрицу весов к активациям, добавляем вектор смещения и потом применяем сигмоиду. Кстати, именно эта запись и требует использования записи wljk. Если бы мы использовали индекс j для обозначения входного нейрона, а k для выходного, нам пришлось бы заменить матрицу весов в уравнении (25) на транспонированную. Это небольшое, но раздражающее изменение, и мы бы потеряли простоту заявления (и размышления) о «применении матрицы весов к активациям». Такой глобальный подход проще и лаконичнее (и использует меньше индексов!), чем понейронный. Это просто способ избежать индексного ада, не теряя точности обозначения происходящего. Также это выражение полезно на практике, поскольку большинство матричных библиотек предлагают быстрые способы перемножения матриц, сложения векторов и векторизации. Код в прошлой главе непосредственно пользовался этим выражением для вычисления поведения сети.

      Используя уравнение (25) для вычисления al, мы вычисляем промежуточное значение zl ≡ wlal−1+bl. Эта величина оказывается достаточно полезной для именования: мы называем zl взвешенным входом нейронов слоя №l. Позднее мы активно будем использовать этот взвешенный вход. Уравнение (25) иногда записывают через взвешенный вход, как al = σ(zl). Стоит также отметить, что у zl есть компоненты $ z^l_j = sum_k w^l_{jk} a^{l−1}_k + b^l_j $, то есть, zlj — это всего лишь взвешенный вход функции активации нейрона j в слое l.

      Два необходимых предположения по поводу функции стоимости

      Цель обратного распространения – вычислить частные производные ∂C/∂w и ∂C/∂b функции стоимости C для каждого веса w и смещения b сети. Чтобы обратное распространение сработало, нам необходимо сделать два главных предположения по поводу формы функции стоимости. Однако перед этим полезно будет представлять себе пример функции стоимости. Мы используем квадратичную функцию из прошлой главы (уравнение (6)). В записи из предыдущего раздела она будет иметь вид

      $ C = frac{1}{2n}sum_x ||y(x) − a^L(x)||^2 tag{26} $

      где: n – общее количество обучающих примеров; сумма идёт по всем примерам x; y=y(x) – необходимые выходные данные; L обозначает количество слоёв в сети; aL = aL (x) – вектор выхода активаций сети, когда на входе x.

      Ладно, так какие нам нужны предположения касательно функции стоимости С, чтобы применять обратное распространение? Первое – функцию стоимости можно записать как среднее C = 1/n ∑x Cx функций стоимости Cx для отдельных обучающих примеров x. Это выполняется в случае квадратичной функции стоимости, где стоимость одного обучающего примера Cx = 1/2 ||y − aL||2. Это предположение будет верным и для всех остальных функций стоимости, которые встретятся нам в книге.

      Это предположение нужно нам потому, что на самом деле обратное распространение позволяет нам вычислять частные производные ∂C/∂w и ∂C/∂b, усредняя по обучающим примерам. Приняв это предположение, мы предположим, что обучающий пример x фиксирован, и перестанем указывать индекс x, записывая стоимость Cx как C. Потом мы вернём x, но пока что лучше его просто подразумевать.

      Второе предположение касательно функции стоимости – её можно записать как функцию выхода нейросети:

      К примеру, квадратичная функция стоимости удовлетворяет этому требованию, поскольку квадратичную стоимость одного обучающего примера x можно записать, как

      $ C = 1/2 || y−a^L ||^2 = 1/2 sum_j (y_j − a^L_j)^2 tag{27} $

      что и будет функцией выходных активаций. Конечно, эта функция стоимости также зависит от желаемого выхода y, и вы можете удивиться, почему мы не рассматриваем C как функцию ещё и от y. Однако вспомним, что входной обучающий пример x фиксирован, поэтому выход y тоже фиксирован. В частности, его мы не можем изменить, меняя веса и смещения, то есть, это не то, что выучивает нейросеть. Поэтому имеет смысл считать C функцией от только выходных активаций aL, а y – просто параметром, помогающим её определять.

      Произведение Адамара s⊙t

      Алгоритм обратного распространения основан на обычных операциях линейной алгебры – сложении векторов, умножении вектора на матрицу, и т.д. Однако одна из операций используется менее часто. Допустим, s и t – два вектора одной размерности. Тогда через s⊙t мы обозначим поэлементное перемножение двух векторов. Тогда компоненты s⊙t будут просто (s⊙t)j = sjtj. Например:

      $ begin{bmatrix} 1 2 end{bmatrix} odot begin{bmatrix} 3 4 end{bmatrix} = begin{bmatrix} 1 * 3 2 * 4 end{bmatrix} = begin{bmatrix} 3 8 end{bmatrix} tag{28} $

      Такое поэлементное произведение иногда называют произведением Адамара или произведением Шура. Мы будем называть его произведением Адамара. Хорошие библиотеки для работы с матрицами обычно имеют быструю реализацию произведения Адамара, и это бывает удобно при реализации обратного распространения.

      Четыре фундаментальных уравнения в основе обратного распространения

      Обратное распространение связано с пониманием того, как изменение весов и смещений сети меняет функцию стоимости. По сути, это означает подсчёт частных производных ∂C/∂wljk и ∂C/∂blj. Но для их вычисления сначала мы вычисляем промежуточное значение δlj, которую мы называем ошибкой в нейроне №j в слое №l. Обратное распространение даст нам процедуру для вычисления ошибки δlj, а потом свяжет δlj с ∂C/∂wljk и ∂C/∂blj.

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

      Он сидит на нейроне №j в слое №l. При поступлении входных данных демон нарушает работу нейрона. Он добавляет небольшое изменение Δzlj к взвешенному входу нейрона, и вместо того, чтобы выдавать σ(zlj), нейрон выдаст σ(zlj + Δzlj). Это изменение распространится и через следующие слои сети, что в итоге изменит общую стоимость на (∂C/∂zlj) * Δzlj.

      Но наш демон хороший, и он пытается помочь вам улучшить стоимость, то есть, найти Δzlj, уменьшающее стоимость. Допустим, значение ∂C/∂zlj велико (положительное или отрицательное). Тогда демон может серьёзно уменьшить стоимость, выбрав Δzlj со знаком, противоположным ∂C/∂zlj. А если же ∂C/∂zlj близко к нулю, тогда демон не может сильно улучшить стоимость, меняя взвешенный вход zlj. Так что, с точки зрения демона, нейрон уже близок к оптимуму (это, конечно, верно только для малых Δzlj. Допустим, таковы ограничения действий демона). Поэтому в эвристическом смысле ∂C/∂zlj является мерой ошибки нейрона.

      Под мотивацией от этой истории определим ошибку δlj нейрона j в слое l, как

      $ delta l_j equiv frac{partial C}{partial z^l_j} tag{29} $

      По обычному нашему соглашению мы используем δl для обозначения вектора ошибок, связанного со слоем l. Обратное распространение даст нам способ подсчитать δl для любого слоя, а потом соотнести эти ошибки с теми величинами, которые нас реально интересуют, ∂C/∂wljk и ∂C/∂blj.

      Вас может интересовать, почему демон меняет взвешенный вход zlj. Ведь было бы естественнее представить, что демон изменяет выходную активацию alj, чтобы мы использовали ∂C/∂alj в качестве меры ошибки. На самом деле, если сделать так, то всё получается очень похожим на то, что мы будем обсуждать дальше. Однако в этом случае представление обратного распространения будет алгебраически чуть более сложным. Поэтому мы остановимся на варианте δlj = ∂C/∂zlj в качестве меры ошибки.

      В задачах классификации термин «ошибка» иногда означает количество неправильных классификаций. К примеру, если нейросеть правильно классифицирует 96,0% цифр, то ошибка будет равна 4,0%. Очевидно, это совсем не то, что мы имеем в виду под векторами δ. Но на практике обычно можно без труда понять, какое значение имеется в виду.

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

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

      Уравнение ошибки выходного слоя, δL: компоненты δL считаются, как

      $ delta^L_j = frac{partial C}{partial a^L_j} sigma' (z^L_j) tag{BP1} $

      Очень естественное выражение. Первый член справа, ∂C / ∂aLj, измеряет, насколько быстро стоимость меняется как функция выходной активации №j. Если, к примеру, C не особенно зависит от конкретного выходного нейрона j, тогда δLj будет малым, как и ожидается. Второй член справа, σ'(zLj), измеряет, насколько быстро функция активации σ меняется в zLj.

      Заметьте, что всё в (BP1) легко подсчитать. В частности, мы вычисляем zLj при подсчёте поведения сети, и на вычисление σ'(zLj) уйдёт незначительно больше ресурсов. Конечно, точная форма ∂C / ∂aLj зависит от формы функции стоимость. Однако, если функция стоимости известна, то не должно быть проблем с вычислением ∂C / ∂aLj. К примеру, если мы используем квадратичную функцию стоимости, тогда C = 1/2 ∑j (yj − aLj)2, поэтому ∂C / ∂aLj = (aLj − yj), что легко подсчитать.

      Уравнение (BP1) – это покомпонентное выражение δL. Оно совершенно нормальное, но не записано в матричной форме, которая нужна нам для обратного распространения. Однако, его легко переписать в матричной форме, как

      $ delta^L =nabla_a C odot sigma'(z^L) tag{BP1a} $

      Здесь ∇a C определяется, как вектор, чьими компонентами будут частные производные ∂C / ∂aLj. Его можно представлять, как выражение скорости изменения C по отношению к выходным активациям. Легко видеть, что уравнения (BP1a) и (BP1) эквивалентны, поэтому далее мы будем использовать (BP1) для отсылки к любому из них. К примеру, в случае с квадратичной стоимостью, у нас будет ∇a C = (aL — y), поэтому полной матричной формой (BP1) будет

      $ delta^L = (a^L - y) odot sigma'(z^L) tag{30} $

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

      Выражение ошибки δl через ошибку в следующем слое, δl+1: в частности,

      $ delta^l = ((w^{l+1})^T delta^{l+1}) cdot sigma'(z^l) tag{BP2} $

      где (wl+1)T — транспонирование весовой матрицы wl+1 для слоя №(l+1). Уравнение кажется сложным, но каждый его элемент легко интерпретировать. Допустим, мы знаем ошибку δl+1 для слоя (l+1). Транспонирование весовой матрицы, (wl+1)T, можно представить себе, как перемещение ошибки назад по сети, что даёт нам некую меру ошибки на выходе слоя №l. Затем мы считаем произведение Адамара ⊙σ'(zl). Это продвигает ошибку назад через функцию активации в слое l, давая нам значение ошибки δl во взвешенном входе для слоя l.

      Комбинируя (BP2) с (BP1), мы можем подсчитать ошибку δl для любого слоя сети. Мы начинаем с использования (BP1) для подсчёта δL, затем применяем уравнение (BP2) для подсчёта δL-1, затем снова для подсчёта δL-2, и так далее, до упора по сети в обратную сторону.

      Уравнение скорости изменения стоимости по отношению к любому смещению в сети: в частности:

      $ frac{partial C}{partial b^l_j} = delta^l_j tag{BP3} $

      То есть, ошибка δlj точно равна скорости изменения ∂C / ∂blj. Это превосходно, поскольку (BP1) и (BP2) уже рассказали нам, как подсчитывать δlj. Мы можем перезаписать (BP3) короче, как

      $ frac{partial C}{partial b} = delta tag{31} $

      где δ оценивается для того же нейрона, что и смещение b.

      Уравнение для скорости изменения стоимости по отношению к любому весу в сети: в частности:

      $ frac{partial C}{partial w^l_{jk}} = a^{l-1}_k delta^l_j tag{BP4} $

      Отсюда мы узнаём, как подсчитать частную производную ∂C/∂wljk через значения δl и al-1, способ расчёта которых нам уже известен. Это уравнение можно переписать в менее загруженной индексами форме:

      $ frac{partial C}{partial w} = a_{rm in} delta_{rm out} tag{32} $

      где ain — активация нейронного входа для веса w, а δout — ошибка нейронного выхода от веса w. Если подробнее посмотреть на вес w и два соединённых им нейрона, то можно будет нарисовать это так:

      Приятное следствие уравнения (32) в том, что когда активация ain мала, ain ≈ 0, член градиента ∂C/∂w тоже стремится к нулю. В таком случае мы говорим, что вес обучается медленно, то есть, не сильно меняется во время градиентного спуска. Иначе говоря, одним из следствий (BP4) будет то, что весовой выход нейронов с низкой активацией обучается медленно.

      Из (BP1)-(BP4) можно почерпнуть и другие идеи. Начнём с выходного слоя. Рассмотрим член σ'(zLj) в (BP1). Вспомним из графика сигмоиды из прошлой главы, что она становится плоской, когда σ(zLj) приближается к 0 или 1. В данных случаях σ'(zLj) ≈ 0. Поэтому вес в последнем слое будет обучаться медленно, если активация выходного нейрона мала (≈ 0) или велика (≈ 1). В таком случае обычно говорят, что выходной нейрон насыщен, и в итоге вес перестал обучаться (или обучается медленно). Те же замечания справедливы и для смещений выходного нейрона.

      Сходные идеи можно получить и касательно более ранних слоёв. В частности, рассмотрим член σ'(zl) в (BP2). Это значит, что δlj, скорее всего, будет малой при приближении нейрона к насыщению. А это, в свою очередь, означает, что любые веса на входе насыщенного нейрона будут обучаться медленно (правда, это не сработает, если у wl+1Tδl+1 будут достаточно большие элементы, компенсирующие небольшое значение σ'(zLj)).

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

      В этом нет ничего особенно удивительного. И всё же, эти наблюдения помогают улучшить наше представление о том, что происходит при обучении сети. Более того, мы можем подойти к этим рассуждениям с обратной стороны. Четыре фундаментальных уравнения справедливы для любой функции активации, а не только для стандартной сигмоиды (поскольку, как мы увидим далее, в доказательствах не используются свойства сигмоиды). Поэтому эти уравнения можно использовать для разработки функций активации с определёнными нужными свойствами обучения. Для примера, допустим, мы выбрали функцию активации σ, непохожую на сигмоиду, такую, что σ’ всегда положительна и не приближается к нулю. Это предотвратить замедление обучения, происходящее при насыщении обычных сигмоидных нейронов. Позднее в книге мы увидим примеры, где функция активации меняется подобным образом. Учитывая уравнения (BP1)-(BP4), мы можем объяснить, зачем нужны такие модификации, и как они могут повлиять на ситуацию.


      Итог: уравнения обратного распространения

      Задачи

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

      $ delta^L = Sigma'(z^L) nabla_a C tag{33} $

      где Σ'(zL) – квадратная матрица, у которой по диагонали расположены значения σ'(zLj), а другие элементы равны 0. Учтите, что эта матрица взаимодействует с ∇a C через обычное перемножение матриц.

      Покажите, что (BP2) можно переписать, как

      $ delta^l = Sigma'(z^l) (w^{l+1})^T delta^{l+1} tag{34} $

      Комбинируя предыдущие задачи, покажите, что:

      $ delta^l = Sigma'(z^l) (w^{l+1})^T ldots Sigma'(z^{L-1}) (w^L)^T Sigma'(z^L) nabla_a C tag{35} $

      Для читателей, привычных к матричному перемножению, это уравнение будет легче понять, чем (BP1) и (BP2). Я концентрируюсь на (BP1) и (BP2) потому, что этот подход оказывается быстрее реализовать численно. [здесь Σ — это не сумма (∑), а заглавная σ (сигма) / прим. перев.]

      Доказательство четырёх фундаментальных уравнений (необязательный раздел)

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

      Начнём с уравнения (BP1), которое даёт нам выражение для выходной ошибки δL. Чтобы доказать его, вспомним, что, по определению:

      $ delta^L_j = frac{partial C}{partial z^L_j} tag{36} $

      Применяя цепное правило, перепишем частные производные через частные производные по выходным активациям:

      $ delta^L_j = sum_k frac{partial C}{partial a^L_k} frac{partial a^L_k}{partial z^L_j} tag{37} $

      где суммирование идёт по всем нейронам k в выходном слое. Конечно, выходная активация aLk нейрона №k зависит только от взвешенного входа zLj для нейрона №j, когда k=j. Поэтому ∂aLk / ∂zLj исчезает, когда k ≠ j. В итоге мы упрощаем предыдущее уравнение до

      $ delta^L_j = frac{partial C}{partial a^L_j} frac{partial a^L_j}{partial z^L_j} tag{38} $

      Вспомнив, что aLj = σ(zLj), мы можем переписать второй член справа, как σ'(zLj), и уравнение превращается в

      $ delta^L_j = frac{partial C}{partial a^L_j} sigma'(z^L_j) tag{39} $

      то есть, в (BP1) в покомпонентном виде.

      Затем докажем (BP2), дающее уравнение для ошибки δl через ошибку в следующем слое δl+1. Для этого нам надо переписать δlj = ∂C / ∂zlj через δl+1k = ∂C / ∂zl+1k. Это можно сделать при помощи цепного правила:

      $ delta^l_j = frac{partial C}{partial z^l_j} tag{40} $

      $ = sum_k frac{partial C}{partial z^{l+1}_k} frac{partial z^{l+1}_k}{partial z^l_j} tag{41} $

      $ = sum_k frac{partial z^{l+1}_k}{partial z^l_j} delta^{l+1}_k tag{42} $

      где в последней строчке мы поменяли местами два члена справа, и подставили определение δl+1k. Чтобы вычислить первый член на последней строчке, отметим, что

      $ z^{l+1}_k = sum_j w^{l+1}_{kj} a^l_j +b^{l+1}_k = sum_j w^{l+1}_{kj} sigma(z^l_j) +b^{l+1}_k tag{43} $

      Продифференцировав, получим

      $ frac{partial z^{l+1}_k}{partial z^l_j} = w^{l+1}_{kj} sigma'(z^l_j). tag{44} $

      Подставив это в (42), получим

      $ delta^l_j = sum_k w^{l+1}_{kj} delta^{l+1}_k sigma'(z^l_j). tag{45} $

      То есть, (BP2) в покомпонентной записи.

      Остаётся доказать (BP3) и (BP4). Они тоже следуют из цепного правила, примерно таким же методом, как и два предыдущих. Оставлю их вам в качестве упражнения.

      Упражнение

      • Докажите (BP3) и (BP4).

      Вот и всё доказательство четырёх фундаментальных уравнений обратного распространения. Оно может показаться сложным. Но на самом деле это просто результат аккуратного применения цепного правила. Говоря менее лаконично, обратное распространение можно представить себе, как способ подсчёта градиента функции стоимости через систематическое применение цепного правила из анализа функций многих переменных. И это реально всё, что представляет собой обратное распространение – остальное просто детали.

      Алгоритм обратного распространения

      Уравнения обратного распространения дают нам метод подсчёта градиента функции стоимости. Давайте запишем это явно в виде алгоритма:

      1. Вход x: назначить соответствующую активацию a1 для входного слоя.
      2. Прямое распространение: для каждого l = 2,3,…,L вычислить zl = wlal−1+bl и al = σ(zl).
      3. Выходная ошибка δL: вычислить вектор δL = ∇a C ⊙ σ'(zL).
      4. Обратное распространение ошибки: для каждого l = L−1,L−2,…,2 вычислить δl = ((wl+1)Tδl+1) ⊙ σ'(zl).
      5. Выход: градиент функции стоимости задаётся $frac{partial C}{partial w^l_{jk}} = a^{l-1}_k delta^l_j$ и $frac{partial C}{partial b^l_j} = delta^l_j$.

      Посмотрев на алгоритм, вы поймёте, почему он называется обратное распространение. Мы вычисляем векторы ошибки δl задом наперёд, начиная с последнего слоя. Может показаться странным, что мы идём по сети назад. Но если подумать о доказательстве обратного распространения, то обратное движение является следствием того, что стоимость – это функция выхода сети. Чтобы понять, как меняется стоимость в зависимости от ранних весов и смещений, нам нужно раз за разом применять цепное правило, идя назад через слои, чтобы получить полезные выражения.

      Упражнения

      • Обратное распространение с одним изменённым нейроном. Допустим, мы изменили один нейрон в сети с прямым распространением так, чтобы его выход был f(∑j wjxj+b), где f – некая функция, не похожая на сигмоиду. Как нам поменять алгоритм обратного распространения в данном случае?
      • Обратное распространение с линейными нейронами. Допустим, мы заменим обычную нелинейную сигмоиду на σ(z) = z по всей сети. Перепишите алгоритм обратного распространения для данного случая.

      Как я уже пояснял ранее, алгоритм обратного распространения вычисляет градиент функции стоимости для одного обучающего примера, C = Cx. На практике часто комбинируют обратное распространение с алгоритмом обучения, например, со стохастическим градиентным спуском, когда мы подсчитываем градиент для многих обучающих примеров. В частности, при заданном мини-пакете m обучающих примеров, следующий алгоритм применяет градиентный спуск на основе этого мини-пакета:

      1. Вход: набор обучающих примеров.
      2. Для каждого обучающего примера x назначить соответствующую входную активацию ax,1 и выполнить следующие шаги:
        • Прямое распространение для каждого l=2,3,…,L вычислить zx,l = wlax,l−1+bl и ax,l = σ(zx,l).
        • Выходная ошибка δx,L: вычислить вектор δx,L = ∇a Cx ⋅ σ'(zx,L).
        • Обратное распространение ошибки: для каждого l=L−1,L−2,…,2 вычислить δx,l = ((wl+1)Tδx,l+1) ⋅ σ'(zx,l).
      3. Градиентный спуск: для каждого l=L,L−1,…,2 обновить веса согласно правилу $w^l rightarrow w^l-frac{eta}{m} sum_x delta^{x,l} (a^{x,l-1})^T$, и смещения согласно правилу $b^l rightarrow b^l-frac{eta}{m} sum_x delta^{x,l}$.

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

      Код для обратного распространения

      Поняв абстрактную сторону обратного распространения, теперь мы можем понять код, использованный в предыдущей главе, реализующий обратное распространение. Вспомним из той главы, что код содержался в методах update_mini_batch и backprop класс Network. Код этих методов – прямой перевод описанного выше алгоритма. В частности, метод update_mini_batch обновляет веса и смещения сети, подсчитывая градиент для текущего mini_batch обучающих примеров:

      class Network(object):
      ...
          def update_mini_batch(self, mini_batch, eta):
              """Обновить веса и смещения сети, применяя градиентный спуск с использованием обратного распространения к одному мини-пакету. mini_batch – это список кортежей (x, y), а eta – скорость обучения."""
              nabla_b = [np.zeros(b.shape) for b in self.biases]
              nabla_w = [np.zeros(w.shape) for w in self.weights]
              for x, y in mini_batch:
                  delta_nabla_b, delta_nabla_w = self.backprop(x, y)
                  nabla_b = [nb+dnb for nb, dnb in zip(nabla_b, delta_nabla_b)]
                  nabla_w = [nw+dnw for nw, dnw in zip(nabla_w, delta_nabla_w)]
              self.weights = [w-(eta/len(mini_batch))*nw 
                              for w, nw in zip(self.weights, nabla_w)]
              self.biases = [b-(eta/len(mini_batch))*nb 
                             for b, nb in zip(self.biases, nabla_b)]

      Большую часть работы делают строки delta_nabla_b, delta_nabla_w = self.backprop(x, y), использующие метод backprop для подсчёта частных производных ∂Cx/∂blj и ∂Cx/∂wljk. Метод backprop почти повторяет алгоритм предыдущего раздела. Есть одно небольшое отличие – мы используем немного другой подход к индексированию слоёв. Это сделано для того, чтобы воспользоваться особенностью python, отрицательными индексами массива, позволяющими отсчитывать элементы назад, с конца. l[-3] будет третьим элементом с конца массива l. Код backprop приведён ниже, вместе со вспомогательными функциями, используемыми для подсчёта сигмоиды, её производной и производной функции стоимости. С ними код получается законченным и понятным. Если что-то неясно, обратитесь к первому описанию кода с полным листингом.

      class Network(object):
      ...
         def backprop(self, x, y):
              """Вернуть кортеж ``(nabla_b, nabla_w)``, представляющий градиент для функции стоимости C_x.  ``nabla_b`` и ``nabla_w`` - послойные списки массивов numpy, похожие на ``self.biases`` and ``self.weights``."""
              nabla_b = [np.zeros(b.shape) for b in self.biases]
              nabla_w = [np.zeros(w.shape) for w in self.weights]
              # прямой проход
              activation = x
              activations = [x] # список для послойного хранения активаций
              zs = [] # список для послойного хранения z-векторов
              for b, w in zip(self.biases, self.weights):
                  z = np.dot(w, activation)+b
                  zs.append(z)
                  activation = sigmoid(z)
                  activations.append(activation)
              # обратный проход
              delta = self.cost_derivative(activations[-1], y) * 
                  sigmoid_prime(zs[-1])
              nabla_b[-1] = delta
              nabla_w[-1] = np.dot(delta, activations[-2].transpose())
              """Переменная l в цикле ниже используется не так, как описано во второй главе книги. l = 1 означает последний слой нейронов, l = 2 – предпоследний, и так далее. Мы пользуемся преимуществом того, что в python можно использовать отрицательные индексы в массивах."""
              for l in xrange(2, self.num_layers):
                  z = zs[-l]
                  sp = sigmoid_prime(z)
                  delta = np.dot(self.weights[-l+1].transpose(), delta) * sp
                  nabla_b[-l] = delta
                  nabla_w[-l] = np.dot(delta, activations[-l-1].transpose())
              return (nabla_b, nabla_w)
      
      ...
      
          def cost_derivative(self, output_activations, y):
              """Вернуть вектор частных производных (чп C_x / чп a) для выходных активаций."""
              return (output_activations-y) 
      
      def sigmoid(z):
          """Сигмоида."""
          return 1.0/(1.0+np.exp(-z))
      
      def sigmoid_prime(z):
          """Производная сигмоиды."""
          return sigmoid(z)*(1-sigmoid(z))

      Задача

      • Полностью основанный на матрицах подход к обратному распространению на мини-пакете. Наша реализация стохастического градиентного спуска использует цикл по обучающим примерам из мини-пакета. Алгоритм обратного распространения можно поменять так, чтобы он вычислял градиенты для всех обучающих примерах мини-пакета одновременно. Вместо того, чтобы начинать с одного вектора x, мы можем начать с матрицы X=[x1x2…xm], чьими столбцами будут векторы мини-пакета. Прямое распространение идёт через произведение весовых матриц, добавление подходящей матрицы для смещений и повсеместного применения сигмоиды. Обратное распространение идёт по той же схеме. Напишите псевдокод для такого подхода для алгоритма обратного распространения. Измените network.py так, чтобы он использовал этот матричный подход. Преимуществом такого подхода будет использование всех преимуществ современных библиотек для линейной алгебры. В итоге он может работать быстрее цикла по мини-пакеты (к примеру, на моём компьютере программа ускоряется примерно в 2 раза на задачах классификации MNIST). На практике все серьёзные библиотеки для обратного распространения используют такой полноматричный подход или какой-то его вариант.

      В каком смысле обратное распространение является быстрым алгоритмом?

      В каком смысле обратное распространение является быстрым алгоритмом? Для ответа на этот вопрос рассмотрим ещё один подход к вычислению градиента. Представьте себе ранние дни исследований нейросетей. Возможно, это 1950-е или 1960-е годы, и вы – первый человек в мире, придумавший использовать для обучения градиентный спуск! Но чтобы это сработало, вам нужно подсчитать градиент функции стоимости. Вы вспоминаете алгебру и решаете посмотреть, можно ли использовать цепное правило для вычисления градиента. Немного поигравшись, вы видите, что алгебра кажется сложной, и вы разочаровываетесь. Вы пытаетесь найти другой подход. Вы решаете считать стоимость функцией только весов C=C(w) (к смещениям вернёмся чуть позже). Вы нумеруете веса w1, w2,… и хотите вычислить ∂C/∂wj для веса wj. Очевидный способ – использовать приближение

      $ frac{partial C}{partial w_{j}} approx frac{C(w+epsilon e_j)-C(w)}{epsilon} tag{46} $

      Где ε > 0 – небольшое положительное число, а ej — единичный вектор направления j. Иначе говоря, мы можем приблизительно оценить ∂C/∂wj, вычислив стоимость C для двух немного различных значений wj, а потом применить уравнение (46). Та же идея позволит нам подсчитать частные производные ∂C/∂b по смещениям.

      Подход выглядит многообещающим. Концептуально простой, легко реализуемый, использует только несколько строк кода. Он выглядит гораздо более многообещающим, чем идея использования цепного правила для подсчёта градиента!

      К сожалении, хотя такой подход выглядит многообещающим, при его реализации в коде оказывается, что работает он крайне медленно. Чтобы понять, почему, представьте, что у нас в сети миллион весов. Тогда для каждого веса wj нам нужно вычислить C(w + εej), чтобы подсчитать ∂C/∂wj. А это значит, что для вычисления градиента нам нужно вычислить функцию стоимости миллион раз, что потребует миллион прямых проходов по сети (на каждый обучающий пример). А ещё нам надо подсчитать C(w), так что получается миллион и один проход по сети.

      Хитрость обратного распространения в том, что она позволяет нам одновременно вычислять все частные производные ∂C/∂wj, используя только один прямой проход по сети, за которым следует один обратный проход. Грубо говоря, вычислительная стоимость обратного прохода примерно такая же, как у прямого.

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

      Впервые это ускорение сполна оценили в 1986, и это резко расширило диапазон задач, решаемых с помощью нейросетей. В свою очередь, это привело к увеличению количества людей, использующих нейросети. Конечно, обратное распространение – не панацея. Даже в конце 1980-х люди уже натолкнулись на её ограничения, особенно при попытках использовать обратное распространение для обучения глубоких нейросетей, то есть сетей со множеством скрытых слоёв. Позже мы увидим, как современные компьютеры и новые хитрые идеи позволяют использовать обратное распространение для обучения таких глубоких нейросетей.

      Обратное распространение: в общем и целом

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

      Чтобы улучшить понимание работы алгоритма, представим, что мы провели небольшое изменение Δwljk некоего веса wljk:

      Это изменение веса приведёт к изменению выходной активации соответствующего нейрона:

      Это приведёт к изменению всех активаций следующего слоя:

      Эти изменения приведут к изменениям следующего слоя, и так далее, вплоть до последнего, а потом к изменениям функции стоимости:

      Изменение ΔC связано с изменением Δwljk уравнением

      $ Delta C approx frac{partial C}{partial w^l_{jk}} Delta w^l_{jk} tag{47} $

      Из этого следует, что вероятным подходом к вычислению ∂C/∂wljk будет тщательное отслеживание распространения небольшого изменения wljk, приводящего к небольшому изменению в C. Если мы сможем это сделать, тщательно выражая по пути всё в величинах, которые легко вычислить, то мы сможем вычислить и ∂C/∂wljk.

      Давайте попробуем. Изменение Δwljk вызывает небольшое изменение Δalj в активации нейрона j в слое l. Это изменение задаётся

      $ Delta a^l_j approx frac{partial a^l_j}{partial w^l_{jk}} Delta w^l_{jk} tag{48} $

      Изменение активации Δalj приводит к изменениям во всех активациях следующего слоя, (l+1). Мы сконцентрируемся только на одной из этих изменённых активаций, допустим, al+1q,

      Это приведёт к следующим изменениям:

      $ Delta a^{l+1}_q approx frac{partial a^{l+1}_q}{partial a^l_j} Delta a^l_j tag{49} $

      Подставляя уравнение (48), получаем:

      $ Delta a^{l+1}_q approx frac{partial a^{l+1}_q}{partial a^l_j} frac{partial a^l_j}{partial w^l_{jk}} Delta w^l_{jk} tag{50} $

      Конечно, изменение Δal+1q изменит и активации в следующем слое. Мы даже можем представить путь по всей сети от wljk до C, где каждое изменение активации приводит к изменению следующей активации, и, наконец, к изменению стоимости на выходе. Если путь проходит через активации alj, al+1q,…,aL−1n, aLm, тогда итоговое выражение будет

      $ Delta C approx frac{partial C}{partial a^L_m} frac{partial a^L_m}{partial a^{L-1}_n} frac{partial a^{L-1}_n}{partial a^{L-2}_p} ldots frac{partial a^{l+1}_q}{partial a^l_j} frac{partial a^l_j}{partial w^l_{jk}} Delta w^l_{jk} tag{51} $

      То есть, мы выбираем член вида ∂a/∂a для каждого следуюшего проходимого нами нейрона, а также для члена ∂C / ∂aLm в конце. Это представление изменений в C из-за изменений в активациях по данному конкретному пути сквозь сеть. Конечно, существует много путей, по которым изменение в wljk может пройти и повлиять на стоимость, а мы рассматривали только один из них. Чтобы подсчитать общее изменение C разумно предположить, что мы должны просуммировать все возможные пути от веса до конечной стоимости:

      $ Delta C approx sum_{mnpldots q} frac{partial C}{partial a^L_m} frac{partial a^L_m}{partial a^{L-1}_n} frac{partial a^{L-1}_n}{partial a^{L-2}_p} ldots frac{partial a^{l+1}_q}{partial a^l_j} frac{partial a^l_j}{partial w^l_{jk}} Delta w^l_{jk} tag{52} $

      где мы просуммировали все возможные выборы для промежуточных нейронов по пути. Сравнивая это с (47), мы видим, что:

      $ frac{partial C}{partial w^l_{jk}} = sum_{mnpldots q} frac{partial C}{partial a^L_m} frac{partial a^L_m}{partial a^{L-1}_n} frac{partial a^{L-1}_n}{partial a^{L-2}_p} ldots frac{partial a^{l+1}_q}{partial a^l_j} frac{partial a^l_j}{partial w^l_{jk}}. tag{53} $

      Уравнение (53) выглядит сложно. Однако у него есть приятная интуитивная интерпретация. Мы подсчитываем изменение C по отношению к весам сети. Оно говорит нам, что каждое ребро между двумя нейронами сети связано с фактором отношения, являющимся только лишь частной производной активации одного нейрона по отношению к активации другого нейрона. У ребра от первого веса до первого нейрона фактор отношения равен ∂alj / ∂wljk. Коэффициент отношения для пути – это просто произведение коэффициентов по всему пути. А общий коэффициент изменения ∂C / ∂wljk является суммой коэффициентов по всем путям от начального веса до конечной стоимости. Эта процедура показана далее, для одного пути:

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

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

      Что насчёт другой загадки – как вообще можно было открыть обратное распространение? На самом деле, если вы последуете по обрисованному мною пути, вы получите доказательство обратного распространения. К несчастью, доказательство будет длиннее и сложнее чем то, что я описал ранее. Так как же было открыто то короткое (но ещё более загадочное) доказательство? Если записать все детали длинного доказательства, вам сразу бросятся в глаза несколько очевидных упрощений. Вы применяете упрощения, получаете более простое доказательство, записываете его. А затем вам опять на глаза попадаются несколько очевидных упрощений. И вы повторяете процесс. После нескольких повторений получится то доказательство, что мы видели ранее – короткое, но немного непонятное, поскольку из него удалены все путеводные вехи! Я, конечно, предлагаю вам поверить мне на слово, однако никакой загадки происхождения приведённого доказательства на самом деле нет. Просто много тяжёлой работы по упрощению доказательства, описанного мною в этом разделе.

      Однако в этом процессе есть один хитроумный трюк. В уравнении (53) промежуточные переменные – это активации, типа al+1q. Хитрость в том, чтобы перейти к использованию взвешенных входов, типа zl+1q, в качестве промежуточных переменных. Если не пользоваться этим, и продолжать использовать активации, полученное доказательство будет немногим более сложным, чем данное ранее в этой главе.


      Recovery mode


      Из песочницы

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

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

      Теоретическая часть

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

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

      Архитектура

      На рисунке 1 показана многослойная нейронная сеть с одним слоем скрытых нейронов ( элементы Z ).

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

      Описание алгоритма

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

      Обозначения:

      В алгоритме обучения сети используются следующие обозначения:

      Входной вектор обучающих данных
      Вектор целевых выходных значений, предоставляемых учителем
      Составляющая корректировки весов связей , соответствующая ошибке выходного нейрона ; также, информация об ошибке нейрона , которая распространяется тем нейронам скрытого слоя, которые связаны с .
      Составляющая корректировки весов связей, соответствующая распространяемой от выходного слоя к скрытому нейрону информации об ошибке.
      Скорость обучения.
      Нейрон на входе с индексом i. Для входных нейронов входной и выходной сигналы одинаковы — .
      Смещение скрытого нейрона j.
      Скрытый нейрон j; Суммарное значение подаваемое на вход скрытого элемента обозначается :
      Сигнал на выходе ( результат применения к активационной функции ) обозначается :
      Смещение нейрона на выходе.
      Нейрон на выходе под индексом k; Суммарное значение подаваемое на вход выходного элемента обозначается : . Сигнал на выходе ( результат применения к активационной функции ) обозначается :

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

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



      Другой широко распространенной активационной функцией является биполярный сигмоид с областью значений ( -1, 1 ) и определенный как:


      Алгоритм обучения

      Алгоритм обучения выглядит следующим образом:

      Шаг 0.

      Инициализация весов ( веса всех связей инициализируются случайными небольшими значениями ).

      Шаг 1.

      До тех пор пока условие прекращения работы алгоритма неверно, выполняются шаги 2 — 9.

      Шаг 2.

      Для каждой пары { данные, целевое значение } выполняются шаги 3 — 8.

      Распространение данных от входов к выходам:
      Шаг 3.

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

      Шаг 4.

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

      Шаг 5.

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

      Обратное распространение ошибки:
      Шаг 6.

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

      Шаг 7.

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

      Шаг 8. Изменение весов.

      Каждый выходной нейрон изменяет веса своих связей с элементом смещения и скрытыми нейронами:
      Каждый скрытый нейрон изменяет веса своих связей с элементом смещения и выходными нейронами:

      Шаг 9.

      Проверка условия прекращения работы алгоритма.

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

      Выбор первоначальных весов и смещения

      Случайная инициализация. Выбор начальных весов окажет влияние на то, сумеет ли сеть достичь глобального ( или только локального) минимума ошибки, и насколько быстро этот процесс будет происходить. Изменение весов между двумя нейронами связано с производной активационной функции нейрона из последующего слоя и активационной функции нейрона слоя предыдущего. В связи с этим, важно избегать выбора таких начальных весов, которые обнулят активационную функцию или ее производную. Также начальные веса не должны быть слишком большими ( или входные сигнал для каждого скрытого или выходного нейрона скорее всего попадут в регион очень малых значений сигмоида ( регион насыщения ) ). С другой стороны, если начальные веса будут слишком маленькими, то входной сигнал на скрытые или выходные нейроны будет близок к нулю, что также приведет к очень низкой скорости обучения. Стандартная процедура инициализации весов состоит в присвоении им случайных значений в интервале ( -0,5; 0,5). Значения могут быть как положительными, так и отрицательными, так как конечные веса, получающиеся после обучения сети, могут быть обоих знаков. Инициализация Nguyen – Widrow. Представленная далее простая модификация стандартной процедуру инициализации способствует более быстрому обучению: Веса связей скрытых и выходных нейронов, а также смещение выходного слоя инициализируются также, как и в стандартной процедуре — случайными значениями из интервала ( -0,5; 0,5).

      Введем обозначения:
      количество входных нейронов
      количество скрытых нейронов
      фактор масштабирования:

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

      вычислить
      переинициализировать веса:
      задать значение смещения:

      Практическая часть

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

      Интерфейс нейрона

      /**
       *	Neuron base class.
       *	Represents a basic element of neural network, node in the net's graph.
       *	There are several possibilities for creation an object of type Neuron, different constructors suites for
       *	different situations.
      */
      
      template <typename T>
      class Neuron
      {
      public:
      
      	 /**
      	 * 		A default Neuron constructor.
      	 * 		- Description:		Creates a Neuron; general purposes.
      	 * 		- Purpose:			Creates a Neuron, linked to nothing, with a Linear network function.
      	 * 		- Prerequisites:	None.
      	 */
      
      						Neuron( ) : mNetFunc( new Linear ), mSumOfCharges( 0.0 ) { };
      
      	 /**
      	 * 		A Neuron constructor based on NetworkFunction.
      	 * 		- Description:		Creates a Neuron; mostly designed to create an output kind of neurons.
      	 * 			@param inNetFunc - a network function which is producing neuron's output signal;
      	 * 		- Purpose:			Creates a Neuron, linked to nothing, with a specific network function.
      	 * 		- Prerequisites:	The existence of NetworkFunction object.
      	 */
      
      						Neuron( NetworkFunction * inNetFunc ) : mNetFunc( inNetFunc ), mSumOfCharges( 0.0 ){ };
      
      						Neuron( std::vector<NeuralLink<T > *>& inLinksToNeurons, NetworkFunction * inNetFunc ) :
      							mNetFunc( inNetFunc ),
      							mLinksToNeurons(inLinksToNeurons),
      							mSumOfCharges(0.0){ };
      
      	 /**
      	 * 		A Neuron constructor based on layer of Neurons.
      	 * 		- Description:		Creates a Neuron; mostly designed to create an input and hidden kinds of neurons.
      	 * 			@param inNeuronsLinkTo - a vector of pointers to Neurons which is representing a layer;
      	 * 			@param inNetFunc - a network function which is producing neuron's output signal;
      	 * 		- Purpose:			Creates a Neuron, linked to every Neuron in provided layer.
      	 * 		- Prerequisites:	The existence of std::vector<Neuron *> and NetworkFunction.
      	 */
      
      									Neuron( std::vector<Neuron *>& inNeuronsLinkTo, NetworkFunction * inNetFunc );
      
      	virtual					                ~Neuron( );
      
      	virtual std::vector<NeuralLink<T > *>& GetLinksToNeurons( ){ return mLinksToNeurons; };
      	virtual NeuralLink<T> * at( const int& inIndexOfNeuralLink ) { return mLinksToNeurons[ inIndexOfNeuralLink ]; };
      
      	virtual void SetLinkToNeuron( NeuralLink<T> * inNeuralLink ){ mLinksToNeurons.push_back( inNeuralLink ); };
      
      	virtual void Input( double inInputData ){ mSumOfCharges += inInputData; };
      	virtual double Fire( );
      	virtual int GetNumOfLinks( ) { return mLinksToNeurons.size( ); };
      	virtual double GetSumOfCharges( );
      	virtual void ResetSumOfCharges( ){ mSumOfCharges = 0.0; };
      	virtual double Process( ) { return mNetFunc->Process( mSumOfCharges ); };
      	virtual double Process( double inArg ){ return mNetFunc->Process( inArg ); };
      	virtual double Derivative( ){ return mNetFunc->Derivative( mSumOfCharges ); };
      
      	virtual void SetInputLink( NeuralLink<T> * inLink ){ mInputLinks.push_back( inLink ); };
      	virtual std::vector<NeuralLink<T > *>& GetInputLink( ){ return mInputLinks; };
      
      
      
      	virtual double PerformTrainingProcess( double inTarget );
      	virtual void PerformWeightsUpdating( );
      
      	virtual void ShowNeuronState( );
      protected:
      	NetworkFunction * mNetFunc;
      	std::vector<NeuralLink<T > *> mInputLinks;
      	std::vector<NeuralLink<T > *> mLinksToNeurons;
      
      	double mSumOfCharges;
      };
      
      template <typename T>
      class OutputLayerNeuronDecorator : public Neuron<T>
      {
      public:
      						          OutputLayerNeuronDecorator( Neuron<T> * inNeuron ){ mOutputCharge = 0; mNeuron = inNeuron; };
      	virtual					~OutputLayerNeuronDecorator( );
      
      	virtual std::vector<NeuralLink<T > *>& GetLinksToNeurons( ){ return mNeuron->GetLinksToNeurons( ) ;};
      	virtual NeuralLink<T> * at( const int& inIndexOfNeuralLink ){ return ( mNeuron->at( inIndexOfNeuralLink ) ) ;};
      	virtual void SetLinkToNeuron( NeuralLink<T> * inNeuralLink ){ mNeuron->SetLinkToNeuron( inNeuralLink ); };
      	virtual double GetSumOfCharges( ) { return mNeuron->GetSumOfCharges( ); };
      
      	virtual void ResetSumOfCharges( ){ mNeuron->ResetSumOfCharges( ); };
      	virtual void Input( double inInputData ){ mNeuron->Input( inInputData ); };
      	virtual double Fire( );
      	virtual int GetNumOfLinks( ) { return mNeuron->GetNumOfLinks( ); };
      
      
      	virtual double Process( ) { return mNeuron->Process( ); };
      	virtual double Process( double inArg ){ return mNeuron->Process( inArg ); };
      
      	virtual double Derivative( ) { return mNeuron->Derivative( ); };
      
      	virtual void SetInputLink( NeuralLink<T> * inLink ){ mNeuron->SetInputLink( inLink ); };
      	virtual std::vector<NeuralLink<T > *>&	GetInputLink( ) { return mNeuron->GetInputLink( ); };
      
      	virtual double PerformTrainingProcess( double inTarget );
      	virtual void PerformWeightsUpdating( );
      	virtual void ShowNeuronState( ) { mNeuron->ShowNeuronState( ); };
      protected:
      	double mOutputCharge;
      	Neuron<T> *	 mNeuron;
      
      };
      
      template <typename T>
      class HiddenLayerNeuronDecorator : public Neuron<T>
      {
      public:
      						          HiddenLayerNeuronDecorator( Neuron<T> * inNeuron ) { mNeuron = inNeuron; };
      	virtual					~HiddenLayerNeuronDecorator( );
      
      	virtual std::vector<NeuralLink<T > *>&	GetLinksToNeurons( ){ return mNeuron->GetLinksToNeurons( ); };
      	virtual void SetLinkToNeuron( NeuralLink<T> * inNeuralLink ){ mNeuron->SetLinkToNeuron( inNeuralLink ); };
      	virtual double GetSumOfCharges( ){ return mNeuron->GetSumOfCharges( ) ;};
      
      	virtual void ResetSumOfCharges( ){mNeuron->ResetSumOfCharges( ); };
      	virtual void Input( double inInputData ){ mNeuron->Input( inInputData ); };
      	virtual double Fire( );
      	virtual int GetNumOfLinks( ){ return mNeuron->GetNumOfLinks( ); };
      	virtual NeuralLink<T> * ( const int& inIndexOfNeuralLink ){ return ( mNeuron->at( inIndexOfNeuralLink) ); };
      
      	virtual double Process( ){ return mNeuron->Process( ); };
      	virtual double Process( double inArg ){ return mNeuron->Process( inArg ); };
      
      	virtual double Derivative( ){ return mNeuron->Derivative( ); };
      
      	virtual void SetInputLink( NeuralLink<T> * inLink ){ mNeuron->SetInputLink( inLink ); };
      	virtual std::vector<NeuralLink<T > *>& GetInputLink( ){ return mNeuron->GetInputLink( ); };
      
      	virtual double PerformTrainingProcess( double inTarget );
      	virtual void PerformWeightsUpdating( );
      
      	virtual void ShowNeuronState( ){ mNeuron->ShowNeuronState( ); };
      protected:
      
      
      	Neuron<T> *	 mNeuron;
      
      };
      
      

      Интерфейс нейронных связей представлен ниже, каждая связь хранит вес и указатель на нейрон:

      Интерфейс нейронной связи

      template <typename T>
      class Neuron;
      
      
      
      template <typename T>
      class NeuralLink
      {
      public:
                                             NeuralLink( ) : mWeightToNeuron( 0.0 ),
                                                                        mNeuronLinkedTo( 0 ), 
                                                                        mWeightCorrectionTerm( 0 ), 
                                                                        mErrorInformationTerm( 0 ),  
                                                                        mLastTranslatedSignal( 0 ){ };
      
                                             NeuralLink( Neuron<T> * inNeuronLinkedTo, double inWeightToNeuron = 0.0 ) : 
                                                                       mWeightToNeuron( inWeightToNeuron ), 
                                                                       mNeuronLinkedTo( inNeuronLinkedTo ), 
                                                                       mWeightCorrectionTerm( 0 ),
                                                                       mErrorInformationTerm( 0 ), 
                                                                       mLastTranslatedSignal( 0 ){ };
        
        void SetWeight( const double& inWeight ){ mWeightToNeuron = inWeight; };
        const double& GetWeight( ){ return mWeightToNeuron; };
        
        void SetNeuronLinkedTo( Neuron<T> * inNeuronLinkedTo ){ mNeuronLinkedTo = inNeuronLinkedTo; };
        Neuron<T> * GetNeuronLinkedTo( ){ return mNeuronLinkedTo; };
        
        void SetWeightCorrectionTerm( double inWeightCorrectionTerm ){ mWeightCorrectionTerm = inWeightCorrectionTerm; };
        double GetWeightCorrectionTerm( ){ return mWeightCorrectionTerm; };
        
        void UpdateWeight( ){ mWeightToNeuron = mWeightToNeuron + mWeightCorrectionTerm; };
        
        double GetErrorInFormationTerm( ){ return mErrorInformationTerm; };
        void SetErrorInFormationTerm( double inEITerm ){ mErrorInformationTerm = inEITerm; };
        
        void SetLastTranslatedSignal( double inLastTranslatedSignal ){ mLastTranslatedSignal = inLastTranslatedSignal; };
        double GetLastTranslatedSignal( ){ return mLastTranslatedSignal; };
      protected:
        double mWeightToNeuron;
        Neuron<T> * mNeuronLinkedTo;
        double mWeightCorrectionTerm;
        double mErrorInformationTerm;
        double mLastTranslatedSignal;
      };
      

      Каждая активационная функция наследует от абстрактного класса, реализуя саму функцию и производную:

      Интерфейс активационной функции

      
      class NetworkFunction {
      public:
        NetworkFunction(){};
        virtual ~NetworkFunction(){};
        virtual double 	Process( double inParam ) = 0;
        virtual double 	Derivative( double inParam ) = 0;
      };
      
      class Linear : public NetworkFunction {
      public:
        Linear(){};
        virtual ~Linear(){};
        virtual double 	Process( double inParam ){ return inParam; };
        virtual double 	Derivative( double inParam ){ return 0; };
      };
      
      
      class Sigmoid : public NetworkFunction {
      public:
        Sigmoid(){};
        virtual ~Sigmoid(){};
        virtual double 	Process( double inParam ){ return ( 1 / ( 1 + exp( -inParam ) ) ); };
        virtual double 	Derivative( double inParam ){ return ( this->Process(inParam)*(1 - this->Process(inParam)) );};
      };
      
      class BipolarSigmoid : public NetworkFunction {
      public:
        BipolarSigmoid(){};
        virtual ~BipolarSigmoid(){};
        virtual double 	Process( double inParam ){ return ( 2 / ( 1 + exp( -inParam ) ) - 1 ) ;};
        virtual double 	Derivative( double inParam ){ return ( 0.5 * ( 1 + this->Process( inParam ) ) * ( 1 - this->Process( inParam ) ) ); };
      };
      
      

      За производство нейронов ответственна нейронная фабрика:

      Интерфейс нейронной фабрики

      
      template <typename T>
      class NeuronFactory
      {
      public:
      		    NeuronFactory(){};
      	virtual ~NeuronFactory(){};
      	virtual Neuron<T> * CreateInputNeuron( std::vector<Neuron<T > *>& inNeuronsLinkTo, NetworkFunction * inNetFunc ) = 0;
      	virtual Neuron<T> * CreateOutputNeuron( NetworkFunction * inNetFunc ) = 0;
      	virtual Neuron<T> * CreateHiddenNeuron( std::vector<Neuron<T > *>& inNeuronsLinkTo, NetworkFunction * inNetFunc ) = 0;
      
      };
      
      template <typename T>
      class PerceptronNeuronFactory : public NeuronFactory<T>
      {
      public:
      		    PerceptronNeuronFactory(){};
      	virtual ~PerceptronNeuronFactory(){};
      	virtual Neuron<T> * CreateInputNeuron( std::vector<Neuron<T > *>& inNeuronsLinkTo, NetworkFunction * inNetFunc ){ return new Neuron<T>( inNeuronsLinkTo, inNetFunc ); };
      	virtual Neuron<T> * CreateOutputNeuron( NetworkFunction * inNetFunc ){ return new OutputLayerNeuronDecorator<T>( new Neuron<T>( inNetFunc ) ); };
      	virtual Neuron<T> *  CreateHiddenNeuron( std::vector<Neuron<T > *>& inNeuronsLinkTo, NetworkFunction * inNetFunc ){ return new HiddenLayerNeuronDecorator<T>( new Neuron<T>( inNeuronsLinkTo, inNetFunc ) ); };
      };
      

      Сама нейронная сеть хранит указатели на нейроны, организованные
      слоями ( вообще, указатели на нейроны хранятся в векторах, которые
      нужно заменить на объекты-слои ), включает в себя абстрактную
      фабрику нейронов, а также алгоритм обучения сети.

      Интерфейс нейронной сети

      template <typename T> class TrainAlgorithm; /** * Neural network class. * An object of that type represents a neural network of several types: * - Single layer perceptron; * - Multiple layers perceptron. * * There are several training algorithms available as well: * - Perceptron; * - Backpropagation. * * How to use this class: * To be able to use neural network , you have to create an instance of that class, specifying * a number of input neurons, output neurons, number of hidden layers and amount of neurons in hidden layers. * You can also specify a type of neural network, by passing a string with a name of neural network, otherwise * MultiLayerPerceptron will be used. ( A training algorithm can be changed via public calls); * * Once the neural network was created, all u have to do is to set the biggest MSE required to achieve during * the training phase ( or u can skip this step, then mMinMSE will be set to 0.01 ), * train the network by providing a training data with target results. * Afterwards u can obtain the net response by feeding the net with data; * */ template <typename T> class NeuralNetwork { public: /** * A Neural Network constructor. * - Description: A template constructor. T is a data type, all the nodes will operate with. Create a neural network by providing it with: * @param inInputs - an integer argument - number of input neurons of newly created neural network; * @param inOutputs- an integer argument - number of output neurons of newly created neural network; * @param inNumOfHiddenLayers - an integer argument - number of hidden layers of newly created neural network, default is 0; * @param inNumOfNeuronsInHiddenLayers - an integer argument - number of neurons in hidden layers of newly created neural network ( note that every hidden layer has the same amount of neurons), default is 0; * @param inTypeOfNeuralNetwork - a const char * argument - a type of neural network, we are going to create. The values may be: * <UL> * <LI>MultiLayerPerceptron;</LI> * <LI>Default is MultiLayerPerceptron.</LI> * </UL> * - Purpose: Creates a neural network for solving some interesting problems. * - Prerequisites: The template parameter has to be picked based on your input data. * */ NeuralNetwork( const int& inInputs, const int& inOutputs, const int& inNumOfHiddenLayers = 0, const int& inNumOfNeuronsInHiddenLayers = 0, const char * inTypeOfNeuralNetwork = "MultiLayerPerceptron" ); ~NeuralNetwork( ); /** * Public method Train. * - Description: Method for training the network. * - Purpose: Trains a network, so the weights on the links adjusted in the way to be able to solve problem. * - Prerequisites: * @param inData - a vector of vectors with data to train with; * @param inTarget - a vector of vectors with target data; * - the number of data samples and target samples has to be equal; * - the data and targets has to be in the appropriate order u want the network to learn. */ bool Train( const std::vector<std::vector<T > >& inData, const std::vector<std::vector<T > >& inTarget ); /** * Public method GetNetResponse. * - Description: Method for actually get response from net by feeding it with data. * - Purpose: By calling this method u make the network evaluate the response for u. * - Prerequisites: * @param inData - a vector data to feed with. */ std::vector<int> GetNetResponse( const std::vector<T>& inData ); /** * Public method SetAlgorithm. * - Description: Setter for algorithm of training the net. * - Purpose: Can be used for dynamic change of training algorithm. * - Prerequisites: * @param inTrainingAlgorithm - an existence of already created object of type TrainAlgorithm. */ void SetAlgorithm( TrainAlgorithm<T> * inTrainingAlgorithm ) { mTrainingAlgoritm = inTrainingAlgorithm; }; /** * Public method SetNeuronFactory. * - Description: Setter for the factory, which is making neurons for the net. * - Purpose: Can be used for dynamic change of neuron factory. * - Prerequisites: * @param inNeuronFactory - an existence of already created object of type NeuronFactory. */ void SetNeuronFactory( NeuronFactory<T> * inNeuronFactory ) { mNeuronFactory = inNeuronFactory; }; /** * Public method ShowNetworkState. * - Description: Prints current state to the standard output: weight of every link. * - Purpose: Can be used for monitoring the weights change during training of the net. * - Prerequisites: None. */ void ShowNetworkState( ); /** * Public method GetMinMSE. * - Description: Returns the biggest MSE required to achieve during the training phase. * - Purpose: Can be used for getting the biggest MSE required to achieve during the training phase. * - Prerequisites: None. */ const double& GetMinMSE( ){ return mMinMSE; }; /** * Public method SetMinMSE. * - Description: Setter for the biggest MSE required to achieve during the training phase. * - Purpose: Can be used for setting the biggest MSE required to achieve during the training phase. * - Prerequisites: * @param inMinMse - double value, the biggest MSE required to achieve during the training phase. */ void SetMinMSE( const double& inMinMse ){ mMinMSE = inMinMse; }; /** * Friend class. */ friend class Hebb<T>; /** * Friend class. */ friend class Backpropagation<T>; protected: /** * Protected method GetLayer. * - Description: Getter for the layer by index of that layer. * - Purpose: Can be used by inner implementation for getting access to neural network's layers. * - Prerequisites: * @param inInd - an integer index of layer. */ std::vector<Neuron<T > *>& GetLayer( const int& inInd ){ return mLayers[inInd]; }; /** * Protected method size. * - Description: Returns the number of layers in the network. * - Purpose: Can be used by inner implementation for getting number of layers in the network. * - Prerequisites: None. */ unsigned int size( ){ return mLayers.size( ); }; /** * Protected method GetNumOfOutputs. * - Description: Returns the number of units in the output layer. * - Purpose: Can be used by inner implementation for getting number of units in the output layer. * - Prerequisites: None. */ std::vector<Neuron<T > *>& GetOutputLayer( ){ return mLayers[mLayers.size( )-1]; }; /** * Protected method GetInputLayer. * - Description: Returns the input layer. * - Purpose: Can be used by inner implementation for getting the input layer. * - Prerequisites: None. */ std::vector<Neuron<T > *>& GetInputLayer( ){ return mLayers[0]; }; /** * Protected method GetBiasLayer. * - Description: Returns the vector of Biases. * - Purpose: Can be used by inner implementation for getting vector of Biases. * - Prerequisites: None. */ std::vector<Neuron<T > *>& GetBiasLayer( ) { return mBiasLayer; }; /** * Protected method UpdateWeights. * - Description: Updates the weights of every link between the neurons. * - Purpose: Can be used by inner implementation for updating the weights of links between the neurons. * - Prerequisites: None, but only makes sense, when its called during the training phase. */ void UpdateWeights( ); /** * Protected method ResetCharges. * - Description: Resets the neuron's data received during iteration of net training. * - Purpose: Can be used by inner implementation for reset the neuron's data between iterations. * - Prerequisites: None, but only makes sense, when its called during the training phase. */ void ResetCharges( ); /** * Protected method AddMSE. * - Description: Changes MSE during the training phase. * - Purpose: Can be used by inner implementation for changing MSE during the training phase. * - Prerequisites: * @param inInd - a double amount of MSE to be add. */ void AddMSE( double inPortion ){ mMeanSquaredError += inPortion; }; /** * Protected method GetMSE. * - Description: Getter for MSE value. * - Purpose: Can be used by inner implementation for getting access to the MSE value. * - Prerequisites: None. */ double GetMSE( ){ return mMeanSquaredError; }; /** * Protected method ResetMSE. * - Description: Resets MSE value. * - Purpose: Can be used by inner implementation for resetting MSE value. * - Prerequisites: None. */ void ResetMSE( ) { mMeanSquaredError = 0; }; NeuronFactory<T> * mNeuronFactory; /*!< Member, which is responsible for creating neurons @see SetNeuronFactory */ TrainAlgorithm<T> * mTrainingAlgoritm; /*!< Member, which is responsible for the way the network will trained @see SetAlgorithm */ std::vector<std::vector<Neuron<T > *> > mLayers; /*!< Inner representation of neural networks */ std::vector<Neuron<T > *> mBiasLayer; /*!< Container for biases */ unsigned int mInputs, mOutputs, mHidden; /*!< Number of inputs, outputs and hidden units */ double mMeanSquaredError; /*!< Mean Squared Error which is changing every iteration of the training*/ double mMinMSE; /*!< The biggest Mean Squared Error required for training to stop*/ }; 

      И, наконец, сам интерфейс класса, ответственного за обучение сети:

      Интерфейс алгоритма обучения

       template <typename T> class NeuralNetwork; template <typename T> class TrainAlgorithm { public: virtual ~TrainAlgorithm(){}; virtual double Train(const std::vector<T>& inData, const std::vector<T>& inTarget) = 0; virtual void WeightsInitialization() = 0; protected: }; template <typename T> class Hebb : public TrainAlgorithm<T> { public: Hebb(NeuralNetwork<T> * inNeuralNetwork) : mNeuralNetwork(inNeuralNetwork){}; virtual ~Hebb(){}; virtual double Train(const std::vector<T>& inData, const std::vector<T>& inTarget); virtual void WeightsInitialization(); protected: NeuralNetwork<T> * mNeuralNetwork; }; template <typename T> class Backpropagation : public TrainAlgorithm<T> { public: Backpropagation(NeuralNetwork<T> * inNeuralNetwork); virtual ~Backpropagation(){}; virtual double Train(const std::vector<T>& inData, const std::vector<T>& inTarget); virtual void WeightsInitialization(); protected: void NguyenWidrowWeightsInitialization(); void CommonInitialization(); NeuralNetwork<T> * mNeuralNetwork; }; 

      Весь код доступен на github: Sovietmade/NeuralNetworks

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

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

      Алгоритм обучения нейронной сети был взят из изумительной книги:
      Laurene V. Fausett “Fundamentals of Neural Networks: Architectures, Algorithms And Applications”.

      Привет. Я хочу продолжить тему реализации методов машинного обучения на c#, и в этой статье я расскажу про алгоритм обратного распространения ошибки для обучения нейронной сети прямого распространения, а также приведу его реализацию на языке C#. Особенность данной реализации в том, что реализация алгоритма абстрагирована от реализаций целевой функции (той, которую нейросеть пытается минимизировать) и функции активации нейронов. В итоге получится некий конструктор, с помощью которого можно поиграться с различными параметрами сети и алгоритма обучения, посмотреть и сравнить результат. Предполагается, что вы уже знакомы с тем, что такое искусственная нейросеть (если нет, то настоятельно рекомендую для начала изучить википедию или одну из подобных статей). Интересно? Лезем под кат.

      Обозначения

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

      Функция активации нейрона, или передаточная функция от значения сумматора:
      image

      • у каждого нейрона сети может быть своя функция активации
      • для всех слоев кроме первого, входным вектором будет являться выходной вектор предыдущего слоя, так что image

      От нейрона перейдем к самой сети. Нейросеть — это модель, она обладает параметрами, и задача алгоритма обучения заключается в подборе таких параметров сети, чтобы минимизоровать значение функции ошибки. Функцию ошибки будем обозначать через E. Параметрами модели являются веса нейронов: image — вес j-ого нейрона слоя n, который берет свое начало в i-ом нейроне слоя (n — 1).

      Греческой эта image обозначим гиперпараметр алгоритма обучения — скорость обучения.

      Изменение веса обозначим через дельта:
      image

      • направление градиента показывает нам в сторону роста значения функции, но нам для минимизации необходимо двигаться в обратном направлении

      Таким образом, новый вес нейрона выглядит следующим образом: image
      Стоит упомянуть, что к изменению веса еще можно (или, скорее, нужно) добавить регуляризацию. Функция регуляризации R — это функция от параметров модели, в нашем случае это веса нейронов. Таким образом, новая функция ошибки выглядит как E + R, а формула изменения веса преобразуется в следующую:
      image

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

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

      Алгоритм обратного распространения ошибки

      Для начала остановимся на режимах обучения. Изменять веса можно несколькими способами:

      • либо после каждого обучающего примера (обучение в реальном режиме времени, online обучение, batchSize = 1)
      • либо накопить изменения для всей обучающей выборки, а затем изменить все веса (full-batch, batchSize = trainingSet.Length)
      • либо после прогона некоторого количества обучающих примеров (mini-batch, batchSize = any_number < trainingSet.Length)

      Рассмотрим ситуацию с онлайн-обучением, так будет проще. Итак, на вход сети пришел импульс image, сеть выдала отклик image, хотя правильной реакцией на x, является image.
      Рассмотрим частную производную функции ошибки E:

      Дальнейшее рассуждение разделяется на две ветки: для последнего слоя и для остальных слоев.

      Выходной слой

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

      • image

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

      Любой скрытый слой

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

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

      Что же мы имеем:

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

      Реализация

      Функция ошибки

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

          public interface IMetrics<T>
      {
      double Calculate(T[] v1, T[] v2);
      /// <summary>
      /// Calculate value of partial derivative by v2[v2Index]
      /// </summary>
      T CalculatePartialDerivaitveByV2Index(T[] v1, T[] v2, int v2Index);
      }
      

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

      Для примера давайте напишем несколько реализаций.

      Минимизация половины квадрата Евклидова расстояния

      image
      А производная будет выглядеть следующим образом:
      image

      internal class HalfSquaredEuclidianDistance : IMetrics<T> { public override double Calculate(double[] v1, double[] v2) { double d = 0; for (int i = 0; i < v1.Length; i++) { d += (v1[i] - v2[i]) * (v1[i] - v2[i]); } return 0.5 * d; } public override double CalculatePartialDerivaitveByV2Index(double[] v1, double[] v2, int v2Index) { return v2[v2Index] - v1[v2Index]; } } 

      Минимизация логарифмического правдоподобия

      image
      image

      internal class Loglikelihood : IMetrics<double> { public override double Calculate(double[] v1, double[] v2) { double d = 0; for (int i = 0; i < v1.Length; i++) { d += v1[i]*Math.Log(v2[i]) + (1 - v1[i])*Math.Log(1 - v2[i]); } return -d; } public override double CalculatePartialDerivaitveByV2Index(double[] v1, double[] v2, int v2Index) { return -(v1[v2Index]/v2[v2Index] - (1 - v1[v2Index])/(1 - v2[v2Index])); } } 

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

      Функция активации нейрона

      Аналогичным способом опишем функцию активации нейрона.

      public interface IFunction
      {
      double Compute(double x);
      double ComputeFirstDerivative(double x);
      }
      

      И примеры.

      Сигмоид

      image
      image

      internal class SigmoidFunction : IFunction { private double _alpha = 1; internal SigmoidFunction(double alpha) { _alpha = alpha; } public double Compute(double x) { double r = (1 / (1 + Math.Exp(-1 * _alpha * x))); //return r == 1f ? 0.9999999f : r; return r; } public double ComputeFirstDerivative(double x) { return _alpha * this.Compute(x) * (1 - this.Compute(x)); } } 

      Гиперболический тангенс

      image
      image

      internal class HyperbolicTangensFunction : IFunction { private double _alpha = 1; internal HyperbolicTangensFunction(double alpha) { _alpha = alpha; } public double Compute(double x) { return (Math.Tanh(_alpha * x)); } public double ComputeFirstDerivative(double x) { double t = Math.Tanh(_alpha*x); return _alpha*(1 - t*t); } } 

      Нейрон, слой и сеть

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

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

      public interface INeuron
      {
      /// <summary>
      /// Weights of the neuron
      /// </summary>
      double[] Weights { get; }
      /// <summary>
      /// Offset/bias of neuron (default is 0)
      /// </summary>
      double Bias { get; set; }
      /// <summary>
      /// Compute NET of the neuron by input vector
      /// </summary>
      /// <param name="inputVector">Input vector (must be the same dimension as was set in SetDimension)</param>
      /// <returns>NET of neuron</returns>
      double NET(double[] inputVector);
      /// <summary>
      /// Compute state of neuron
      /// </summary>
      /// <param name="inputVector">Input vector (must be the same dimension as was set in SetDimension)</param>
      /// <returns>State of neuron</returns>
      double Activate(double[] inputVector);
      /// <summary>
      /// Last calculated state in Activate
      /// </summary>
      double LastState { get; set; }
      /// <summary>
      /// Last calculated NET in NET
      /// </summary>
      double LastNET { get; set; }
      IList<INeuron> Childs { get; }
      IList<INeuron> Parents { get; }
      IFunction ActivationFunction { get; set; }
      double dEdz { get; set; }
      }
      

      Т.к. мы рассматриваем полносвязную «слоеную» сеть, то Childs и Parents можно не имплементировать, но если делать общий алгоритм, то придется. Рассмотрим поля, которые особо важны для алгоритма обучения:

      • LastNET — сумматор нейрона, тут хранится последнее вычисленное значение
      • LastState — выход нейрона, тут хранится последнее вычисленное значение
      • dEdz — это то самое dE/dz нейрона, что упоминается выше, и вычисляется в зависимости от того на каком слое находится текущий нейрон; частная производная функции ошибки по сумматору нейрона

      Слой сети выглядит проще:

      public interface ILayer
      {
      /// <summary>
      /// Compute output of the layer
      /// </summary>
      /// <param name="inputVector">Input vector</param>
      /// <returns>Output vector</returns>
      double[] Compute(double[] inputVector);
      /// <summary>
      /// Get last output of the layer
      /// </summary>
      double[] LastOutput { get; }
      /// <summary>
      /// Get neurons of the layer
      /// </summary>
      INeuron[] Neurons { get; }
      /// <summary>
      /// Get input dimension of neurons
      /// </summary>
      int InputDimension { get; }
      }
      

      И представление сети:

      public interface INeuralNetwork
      {
      /// <summary>
      /// Compute output vector by input vector
      /// </summary>
      /// <param name="inputVector">Input vector (double[])</param>
      /// <returns>Output vector (double[])</returns>
      double[] ComputeOutput(double[] inputVector);
      Stream Save();
      /// <summary>
      /// Train network with given inputs and outputs
      /// </summary>
      /// <param name="inputs">Set of input vectors</param>
      /// <param name="outputs">Set if output vectors</param>
      void Train(IList<DataItem<double>> data);
      }
      

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

      public interface IMultilayerNeuralNetwork : INeuralNetwork
      {
      /// <summary>
      /// Get array of layers of network
      /// </summary>
      ILayer[] Layers { get; }
      }
      
      Алгоритм обучения

      Алгоритм обучения будет реализован через паттерн стратегия:

      public interface ILearningStrategy<T>
      {
      /// <summary>
      /// Train neural network
      /// </summary>
      /// <param name="network">Neural network for training</param>
      /// <param name="inputs">Set of input vectors</param>
      /// <param name="outputs">Set of output vectors</param>
      void Train(T network, IList<DataItem<double>> data);
      }
      

      Для более наглядного понимания приведу типичную функцию Train любой нейросети в контексте данной реализации:

      public void Train(IList<DataItem<double>> data)
      {
      _learningStrategy.Train(this, data);
      }
      
      Формат входных данных

      Я использую следующий формат входных данных:

      public class DataItem<T>
      {
      private T[] _input = null;
      private T[] _output = null;
      public DataItem()
      {
      }
      public DataItem(T[] input, T[] output)
      {
      _input = input;
      _output = output;
      }
      public T[] Input
      {
      get { return _input; }
      set { _input = value; }
      }
      public T[] Output 
      { 
      get { return _output; }
      set { _output = value; }
      }
      }
      

      Как видно из кода в предыдущих частях, нейросеть работает с

      DataItem.
      Параметры алгоритма обучения

      Данным классом описываются параметры алгоритма обучения, я думаю названия полей говорят сами за себя (и комментарии), так что не буду дублировать текстом:
      public class LearningAlgorithmConfig { public double LearningRate { get; set; } /// <summary> /// Size of the butch. -1 means fullbutch size. /// </summary> public int BatchSize { get; set; } public double RegularizationFactor { get; set; } public int MaxEpoches { get; set; } /// <summary> /// If cumulative error for all training examples is less then MinError, then algorithm stops /// </summary> public double MinError { get; set; } /// <summary> /// If cumulative error change for all training examples is less then MinErrorChange, then algorithm stops /// </summary> public double MinErrorChange { get; set; } /// <summary> /// Function to minimize /// </summary> public IMetrics<double> ErrorFunction { get; set; } }
      Алгоритм

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

      internal class BackpropagationFCNLearningAlgorithm : ILearningStrategy, функция public void Train(IMultilayerNeuralNetwork network, IList<DataItem> data).

      Для начала подготавливаем некоторые переменные (общие для всех эпох обучения) для работы алгоритма:
      if (_config.BatchSize < 1 || _config.BatchSize > data.Count) { _config.BatchSize = data.Count; } double currentError = Single.MaxValue; double lastError = 0; int epochNumber = 0; Logger.Instance.Log("Start learning...");

      Затем запустится основной цикл работы алгоритма, в котором происходит прямой и обратный прогон всего массива данных, один прогон называется эпохой:

      do
      {
      //...
      } while (epochNumber < _config.MaxEpoches &&
      currentError > _config.MinError &&
      Math.Abs(currentError - lastError) > _config.MinErrorChange);
      

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

      lastError = currentError;
      DateTime dtStart = DateTime.Now;
      //preparation for epoche
      int[] trainingIndices = new int[data.Count];
      for (int i = 0; i < data.Count; i++)
      {
      trainingIndices[i] = i;
      }
      if (_config.BatchSize > 0)
      {
      trainingIndices = Shuffle(trainingIndices);
      }
      

      Далее наступает процесс обработки данных, в зависимости от размера пачки, и изменение весов, это выглядит так:

      //process data set
      int currentIndex = 0;
      do
      {
      #region initialize accumulated error for batch, for weights and biases
      double[][][] nablaWeights = new double[network.Layers.Length][][];
      double[][] nablaBiases = new double[network.Layers.Length][];
      for (int i = 0; i < network.Layers.Length; i++)
      {
      nablaBiases[i] = new double[network.Layers[i].Neurons.Length];
      nablaWeights[i] = new double[network.Layers[i].Neurons.Length][];
      for (int j = 0; j < network.Layers[i].Neurons.Length; j++)
      {
      nablaBiases[i][j] = 0;
      nablaWeights[i][j] = new double[network.Layers[i].Neurons[j].Weights.Length];
      for (int k = 0; k < network.Layers[i].Neurons[j].Weights.Length; k++)
      {
      nablaWeights[i][j][k] = 0;
      }
      }
      }
      #endregion
      //process one batch
      for (int inBatchIndex = currentIndex; inBatchIndex < currentIndex + _config.BatchSize && inBatchIndex < data.Count; inBatchIndex++)
      {
      //forward pass
      double[] realOutput = network.ComputeOutput(data[trainingIndices[inBatchIndex]].Input);
      //backward pass, error propagation
      //last layer
      //.......................................ОБРАБОТКА ПОСЛЕДНЕГО СЛОЯ
      //hidden layers
      //.......................................ОБРАБОТКА СКРЫТЫХ СЛОЕВ
      }
      //update weights and bias
      for (int layerIndex = 0; layerIndex < network.Layers.Length; layerIndex++)
      {
      for (int neuronIndex = 0; neuronIndex < network.Layers[layerIndex].Neurons.Length; neuronIndex++)
      {
      network.Layers[layerIndex].Neurons[neuronIndex].Bias -= nablaBiases[layerIndex][neuronIndex];
      for (int weightIndex = 0; weightIndex < network.Layers[layerIndex].Neurons[neuronIndex].Weights.Length; weightIndex++)
      {
      network.Layers[layerIndex].Neurons[neuronIndex].Weights[weightIndex] -=
      nablaWeights[layerIndex][neuronIndex][weightIndex];
      }
      }
      }
      currentIndex += _config.BatchSize;
      } while (currentIndex < data.Count);
      

      Рассмотрим обработку последнего слоя:

      • инициализировали "наблЫ", там мы храним аккумулированное значение градиента для пачки входных данных (при онлайн обучении, там окажется просто градиент по одному примеру)
      • пробегаемся по всем нейронам последнего слоя
      • вычисляем dE/dz
      • а затем вычисляем значение градиента для весов и смещения
      //last layer
      for (int j = 0; j < network.Layers[network.Layers.Length - 1].Neurons.Length; j++)
      {
      network.Layers[network.Layers.Length - 1].Neurons[j].dEdz =
      _config.ErrorFunction.CalculatePartialDerivaitveByV2Index(data[inBatchIndex].Output,
      realOutput, j) *
      network.Layers[network.Layers.Length - 1].Neurons[j].ActivationFunction.
      ComputeFirstDerivative(network.Layers[network.Layers.Length - 1].Neurons[j].LastNET);
      nablaBiases[network.Layers.Length - 1][j] += _config.LearningRate *
      network.Layers[network.Layers.Length - 1].Neurons[j].dEdz;
      for (int i = 0; i < network.Layers[network.Layers.Length - 1].Neurons[j].Weights.Length; i++)
      {
      nablaWeights[network.Layers.Length - 1][j][i] +=
      _config.LearningRate*(network.Layers[network.Layers.Length - 1].Neurons[j].dEdz*
      (network.Layers.Length > 1 ? 
      network.Layers[network.Layers.Length - 1 - 1].Neurons[i].LastState : 
      data[inBatchIndex].Input[i])
      +
      _config.RegularizationFactor *
      network.Layers[network.Layers.Length - 1].Neurons[j].Weights[i]
      / data.Count);
      }
      }
      

      Очень похоже на последний слой выглядит обработка всех скрытых слоев сети:

      • пробегаемся по всем скрытым слоям
      • инициализировали "наблЫ", там мы храним аккумулированное значение градиента для пачки входных данных (при онлайн обучении, там окажется просто градиент по одному примеру)
      • пробегаемся по всем нейронам последнего слоя
      • вычисляем dE/dz, но уже для этого мы используем значения вычисленные, на слое старше текущего
      • а затем вычисляем значение градиента для весов и смещения
      //hidden layers
      for (int hiddenLayerIndex = network.Layers.Length - 2; hiddenLayerIndex >= 0; hiddenLayerIndex--)
      {
      for (int j = 0; j < network.Layers[hiddenLayerIndex].Neurons.Length; j++)
      {
      network.Layers[hiddenLayerIndex].Neurons[j].dEdz = 0;
      for (int k = 0; k < network.Layers[hiddenLayerIndex + 1].Neurons.Length; k++)
      {
      network.Layers[hiddenLayerIndex].Neurons[j].dEdz +=
      network.Layers[hiddenLayerIndex + 1].Neurons[k].Weights[j]*
      network.Layers[hiddenLayerIndex + 1].Neurons[k].dEdz;
      }
      network.Layers[hiddenLayerIndex].Neurons[j].dEdz *=
      network.Layers[hiddenLayerIndex].Neurons[j].ActivationFunction.
      ComputeFirstDerivative(
      network.Layers[hiddenLayerIndex].Neurons[j].LastNET
      );
      nablaBiases[hiddenLayerIndex][j] += _config.LearningRate*
      network.Layers[hiddenLayerIndex].Neurons[j].dEdz;
      for (int i = 0; i < network.Layers[hiddenLayerIndex].Neurons[j].Weights.Length; i++)
      {
      nablaWeights[hiddenLayerIndex][j][i] += _config.LearningRate * (
      network.Layers[hiddenLayerIndex].Neurons[j].dEdz *
      (hiddenLayerIndex > 0 ? network.Layers[hiddenLayerIndex - 1].Neurons[i].LastState : data[inBatchIndex].Input[i])
      +
      _config.RegularizationFactor * network.Layers[hiddenLayerIndex].Neurons[j].Weights[i] / data.Count
      );
      }
      }
      }
      

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

      //recalculating error on all data
      //real error
      currentError = 0;
      for (int i = 0; i < data.Count; i++)
      {
      double[] realOutput = network.ComputeOutput(data[i].Input);
      currentError += _config.ErrorFunction.Calculate(data[i].Output, realOutput);
      }
      currentError *= 1d/data.Count;
      //regularization term
      if (Math.Abs(_config.RegularizationFactor - 0d) > Double.Epsilon)
      {
      double reg = 0;
      for (int layerIndex = 0; layerIndex < network.Layers.Length; layerIndex++)
      {
      for (int neuronIndex = 0; neuronIndex < network.Layers[layerIndex].Neurons.Length; neuronIndex++)
      {
      for (int weightIndex = 0; weightIndex < network.Layers[layerIndex].Neurons[neuronIndex].Weights.Length; weightIndex++)
      {
      reg += network.Layers[layerIndex].Neurons[neuronIndex].Weights[weightIndex] *
      network.Layers[layerIndex].Neurons[neuronIndex].Weights[weightIndex];
      }
      }
      }
      currentError += _config.RegularizationFactor * reg / (2 * data.Count);
      }
      epochNumber++;
      Logger.Instance.Log("Eposh #" + epochNumber.ToString() +
      " finished; current error is " + currentError.ToString() +
      "; it takes: " +
      (DateTime.Now - dtStart).Duration().ToString());
      

      Этот блок находится прямиком перед выходом из основного цикла, продублирую код для наглядности:

      } while (epochNumber < _config.MaxEpoches &&
      currentError > _config.MinError &&
      Math.Abs(currentError - lastError) > _config.MinErrorChange);
      

      Итог

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

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

      Поскольку я столкнулся с существенными затруднениями в поисках объяснения механизма обратного распространения ошибки, которое мне понравилось бы, я решил написать собственный пост об обратном распространении ошибки реализовав алгоритм Word2Vec. Моя цель, — объяснить сущность алгоритма, используя простую, но нетривиальную нейросеть. Кроме того, word2vec стал настолько популярным в NLP сообществе, что будет полезно сосредоточиться на нем.

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

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

      • линейная алгебра (в частности матричное умножение)
      • правило цепочки дифференцирования функций от многих переменных

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

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

      1. Что такое алгоритм обратного распространения

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

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


      Рисунок 1. Визуальное объяснение алгоритма градиентного спуска.

      Вначале мы не знаем оптимальные значения, то есть не знаем какие значения w1 и w2 минимизируют функцию потерь.

      Допустим, мы начинаем с красной точки. Если мы знаем как изменяется функция потерь при изменении весов, то есть если мы знаем производные $partialmathcal{L}/partial w_1$ и $partialmathcal{L}/partial w_2$, то мы можем сдвинуть красную точку ближе к минимуму функции потерь, которая представлена на графике синей точкой. Шаг сдвига определяется параметром $eta$, который обычно называется параметром обучения.

      2. Word2Vec

      Задача алгоритма word2vec, найти эмбеддинги слов в заданном текстовом корпусе, другими словами, это методика поиска представлений слов в низкой размерности. Как следствие, когда мы говорим о word2vec, мы обычно говорим о приложениях NLP.

      Например, модель word2vec обученная со скрытым слоем размерности [N, 3], где N -количество слов в словаре, даст трехмерные эмбеддинги слов. Это значит что, например, слово ‘квартира’, будет представлено трехмерным вектором действительных чисел, который будет близок (воспринимайте это как Евклидову метрику), к представлению аналогичного слова, такого как ‘дом’. Другими словами, word2vec это техника отображения слов в числа.

      В контексте word2vec используются две основные модели: мешок слов (CBOW) и скипграммы (skip-gram). Сначала мы рассмотрим простейшую модель, CBOW, с окном из одного слова, затем перейдем к окну из нескольких слов и наконец рассмотрим модель skip-gram.

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

      3. Простая модель CBOW

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

      Рисунок 2. Топология модели Continuous Bag-of-Words модели с контекстом из одного слова

      Один входной слой, один скрытый слой и выходной слой, функция активации скрытого слоя
      a = 1 (identity function, или функция линейной активации, хотя последнее неправильно).
      В качестве функции активации выходного слоя используется Softmax.

      Входной слой представлен one hot encoding вектором, длина которого совпадает с размером массива слов, все элементы данного вектора равны нулю, кроме элементов, индексы которых совпадают с индексами слов из контекста, по данным индексам значения вектора равны 1.

      Пример: словарь [‘мама’, ‘мыла’, ‘раму’, ‘маша’, ‘ела’, ‘кашу’]
      OneHot(‘маша’) = [0, 0, 0, 1, 0, 0]
      OneHot([‘мама’, ‘маша’]) = [1, 0, 0, 1, 0, 0]
      OneHot([‘мама’, ‘ела’, ‘кашу’]) = [1, 0, 0, 0, 1, 1]

      Перейдем к весам, весовые коэффициенты между входным и скрытым слоем представлены матрицей W размера $Vtimes N$, матрица между скрытым и выходным слоем $W’$ размером $Ntimes V$, где V — размер словаря, N — размер эмбеддинг вектора (то есть, те вектора которые и пытается найти word2vec)

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

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

      Пример

      Предположим, мы хотим обучить word2vec следующим текстом:
      «I like playing football»
      Мы решаем обучить модель CBOW с одним контекстным словом как на рисунке (2) выше.
      Учитывая текст который у нас есть, наш словарь будет состоять из 4 слов, соответственно V=4, также установим что скрытый слой будет иметь два нейрона, то есть N=2, наша нейросеть будет выглядеть так:

      Со словарём:

      $textrm{Vocabulary}=[textrm{“I”}, textrm{“like”}, textrm{“playing”}, textrm{“football”}]$

      Далее мы определяем как выбирать ‘целевое’ и ‘контекстное’ слово, и мы можем построить наши обучающие примеры двигаясь окном по тексту. Например:

      Тогда обучающие примеры будут выглядеть так:

      Чтобы передать эти данные в алгоритм понадобится преобразовать их в числа, для чего используем one-hot encoding.

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

      3.1 Функция потерь (Loss function)

      Исходя из топологии на рисунке 1, давайте запишем как посчитать значения скрытого и выходного слоев, при входе x:

      $begin{eqnarray*} textbf{h} = & W^Ttextbf{x} hspace{7.0cm}  textbf{u}= & W'^Ttextbf{h}=W'^TW^Ttextbf{x} hspace{4.6cm}  textbf{y}= & mathbb{S}textrm{oftmax}(textbf{u})= mathbb{S}textrm{oftmax}(W'^TW^Ttextbf{x}) hspace{2cm} end{eqnarray*}$

      где, h — сумма на скрытом слое, u — сумма на выходном слое, y — выход сети.

      Теперь, предположим, мы обучаем модель по паре целевое слово, контекстное слово (wt, wc). Целевое слово представляет собой идеальное предсказание нейронной сети, в виде onehot encoding вектора.

      Функция потерь должна будет оценить выходной слой, относительно onehot вектора wt (целевого слова).

      Поскольку значения softmax можно интерпретировать как условные вероятности целевого слова, то учитывая контекстное слово функцию потерь можно записать в следующем виде:

      $begin{equation*} mathcal{L} = -log mathbb{P}(w_t|w_c)=-log y_{j^*} =-log[mathbb{S}textrm{oftmax}(u_{j^*})]=-logleft(frac{exp{u_{j^*}}}{sum_i exp{u_i}}right), end{equation*}$

      , где j* — ожидаемая позиция правильно предсказанного слова.
      Добавление функции логарифма является стандартным подходом. Из данного выражения мы получаем уравнение (1):

      $begin{equation} bbox[lightblue,5px,border:2px solid red]{ mathcal{L} = -u_{j^*} + log sum_i exp{(u_i)}. } label{eq:loss} end{equation} >(1)$

      Функция потерь максимизирует вероятность предсказания правильного слова исходя из заданного контекста.

      Пример

      Вернемся к предыдущему примеру предложения «I like play football», предположим мы обучаем модель по первой записи обучающих данных, с контекстным словом «I» и целевым словом «like», в идеале веса сети должны быть такими, чтобы при вводе $textbf{x}=(1, 0, 0, 0)$ — что соответствует слову «I», результат работы был близок к $hat{textbf{y}}=(0, 1, 0, 0)$, что соответствует слову «like».

      Стандартный подход к инициализации весов word2vec, использование нормального распределения. Примем начальное состояние весов матрицы W размером $4times 2$

      $W = begin{pmatrix} -1.38118728 & 0.54849373  0.39389902 & -1.1501331  -1.16967628 & 0.36078022  0.06676289 & -0.14292845 end{pmatrix}$

      И начальное состояние весов матрицы $W’$ размером $2times 4$

      $W' = begin{pmatrix} 1.39420129 & -0.89441757 & 0.99869667 & 0.44447037  0.69671796 & -0.23364341 & 0.21975196 & -0.0022673 end{pmatrix}$

      Для обучающих данных «I like» мы получаем:

      $textbf{h} = W^Ttextbf{x}= begin{pmatrix} -1.38118728  0.54849373 end{pmatrix}$

      Затем

      $textbf{u} = W'^Ttextbf{h}= begin{pmatrix} -1.54350765  1.10720623  -1.25885456  -0.61514042 end{pmatrix}$

      И наконец

      $textbf{y} = mathbb{S}textrm{oftmax}(textbf{u})= begin{pmatrix} 0.05256567  0.7445479  0.06987559  0.13301083 end{pmatrix}$

      На этом этапе функция потерь будет отрицательным логарифмом второго элемента $textbf{y}$, или

      $mathcal{L}=-logmathbb{P}(textrm{“like”}|textrm{“I”})=-log y_3 = -log(0.7445479)= 0.2949781.$

      Также, мы могли бы рассчитать ее при помощи уравнения (1):

      $begin{eqnarray*} mathcal{L}=-u_2+logsum_{i=1}^4 u_i=-1.10720623 + log[exp(-1.54350765)+exp(1.10720623)  +exp(-1.25885456)+exp(-0.61514042)]=0.2949781. end{eqnarray*}$

      Теперь, прежде чем перейти к следующему обучающему примеру «like play», мы должны изменить веса нейросети, как это сделать расскажет следующий пункт про обратное распространение.

      3.2 Алгоритм обратного распространения для модели CBOW

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

      В первом разделе мы уже обсудили что в мире нейронный сетей эта проблема решается использованием градиентного спуска. На рисунке (1) показано как применить этот метод и обновить матрицы весов W и W`. Нам нужно найти производные $partial mathcal{L}/partial{W}$ и $partial mathcal{L}/partial{W’}$

      Я считаю что самый простой способ понять как это сделать, это записать соотношения между функцией потерь и матрицами весов. Глядя на уравнение (1) ясно что функция потерь зависит от весов W и W`, через переменную u=[u1, …., uV], или

      $begin{equation*} mathcal{L} = mathcal{L}(mathbf{u}(W,W'))=mathcal{L}(u_1(W,W'), u_2(W,W'),dots, u_V(W,W')) . end{equation*}$

      Получить производные можно из правила цепочки для функций многих переменных:

      $begin{equation} frac{partialmathcal{L}}{partial W'_{ij}} = sum_{k=1}^Vfrac{partialmathcal{L}}{partial u_k}frac{partial u_k}{partial W'_{ij}} label{eq:dLdWp} end{equation} >(2)$

      и

      $begin{equation} frac{partialmathcal{L}}{partial W_{ij}} = sum_{k=1}^Vfrac{partialmathcal{L}}{partial u_k}frac{partial u_k}{partial W_{ij}} . label{eq:dLdW} end{equation} >(3)$

      И это большая часть алгоритма обратного распространения, на данный момент нам просто нужно определить уравнения (2) и (3) для нашего случая.

      Начнем с уравнения (2), обратите внимание что вес Wij, относится к матрице W, и соединяет нейрон i в скрытом слое с нейроном j в выходном слое, и соответственно оказывает влияние только на выход uj (соответственно и на yj).


      Рисунок 3. (a) Выходной узел $y_j$ зависит от общего узла $h_i$ скрытого слоя только через элемент $W'_{ij}$ матрицы $W'$. (b) Но кроме того, этот же выходной узел зависит от общего входного узла $x_k$ через N элементов $W_{k1}dots W_{kN}$ матрицы W.

      Следовательно, среди всех производных $partial u_k/partial W'_{ij}$, только одна, где k=j, будет отличаться от 0.

      Уравнение (4):

      $begin{equation} frac{partialmathcal{L}}{partial W'_{ij}} = frac{partialmathcal{L}}{partial u_j}frac{partial u_j} {partial W'_{ij}} label{eq:derivative#1} end{equation} >(4)$

      Давайте посчитаем $partial mathcal{L}/partial u_j$, уравнение (5):

      $begin{equation} frac{partialmathcal{L}}{partial u_j} = -delta_{jj^*} + y_j := e_j label{eq:term#1} end{equation} >(5)$

      , где $delta_{jj^*}$ — дельта Кронекера, функция двух целых переменных, которая равна 1, если они равны, и 0 в противном случае.

      В уравнении (5) мы ввели вектор e размерности N (размер словаря), который мы используем чтобы снизить сложность обозначений, этот вектор представляет собой разницу между полученным и ожидаемым результатом, то есть это вектор ошибок предсказания.

      Для второго члена правой части уравнения (4) мы получим уравнения (6):

      $begin{equation} frac{partial u_j}{partial W'_{ij}} = sum_{k=1}^V W_{ik}x_k label{eq:term#2} end{equation} >(6)$

      После подставновки уравнений (5) и (6) в уравнение (4) мы получим уравнение (7):

      $begin{equation} bbox[white,5px,border:2px dotted red]{ frac{partialmathcal{L}}{partial W'_{ij}} = (-delta_{jj^*} + y_j) left(sum_{k=1}^V W_{ki}x_kright) } label{eq:backprop1} end{equation} >(7)$

      Мы можем найти выполнить аналогичное упражнение для поиска производной $partialmathcal{L}/partial W_{ij}$, однако на этот раз отметим что после задания входа Xk, выход yj нейрона j зависит от всех элементов матрицы W соединенных со входом, как видно на рисунке 3(b). Поэтому мы должны оценивать все элементы в сумме. Прежде чем перейти к оценке $partial u_k/partial W_{ij}$, полезно перезаписать выражение для элемента uk из вектора u как:

      $begin{equation*} u_k = sum_{m=1}^Nsum_{l=1}^VW'_{mk}W_{lm}x_l . end{equation*}$

      Из этого уравнения легко вывести $partial u_k/partial W_{ij}$, поскольку единственный выживший член производной будет с индексами l=i и m=j, или же в виде уравнения (8):

      $begin{equation} frac{partial u_k}{partial W_{ij}} = W'_{jk}x_i . label{eq:term#3} end{equation} >(8)$

      Наконец применив уравнения (5) и (8) мы получим результат, уравнение (9):

      $begin{equation} bbox[white,5px,border:2px dotted red]{ frac{partial mathcal{L}}{partial W_{ij}} = sum_{k=1}^V (-delta_{kk^*}+y_k)W'_{jk}x_i } label{eq:backprop2} end{equation} >(9)$

      Векторизация

      Мы можем упростить запись уравнений. (7) и (9) используя векторную нотацию. Сделав это мы получим для уравнения (7)

      $begin{equation} bbox[white,5px,border:2px dotted red]{ frac{partialmathcal{L}}{partial W'} = (W^Ttextbf{x}) otimes textbf{e} } end{equation} >(10)$

      Где символ ⊗ обозначает векторное произведение.

      Для уравнения (9) мы получим:

      $begin{equation} bbox[white,5px,border:2px dotted red]{ frac{partial mathcal{L}}{partial W} = textbf{x}otimes(W'textbf{e}) } end{equation} >(11)$

      3.3 Градиентный спуск на основе полученных выше результатов

      Теперь, когда у нас есть уравнения (7) и (9), у нас есть все необходимое для реализации одной итерации обучения нейросети на базе алгоритма обратного распространения ошибки, применяя градиентный спуск. Каждая итерация должна немного приближать к минимуму функцию потерь. После задания скорости обучения $eta>0$, мы можем обновить наши веса следующим образом:

      $begin{eqnarray} W_{textrm{new}} & = W_{textrm{old}} - eta frac{partial mathcal{L}}{partial W} nonumber  W'_{textrm{new}} & = W'_{textrm{old}} - eta frac{partial mathcal{L}}{partial W'} nonumber  end{eqnarray}$

      3.4 Итерация алгоритма

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

      4. Алгоритм обратного распространения для контекста из нескольких слов в модели CBOW

      Мы уже знаем как работает алгоритм обратного распространения для модели CBOW с одним словом на входе. Теперь увеличим сложность и добавим в контекст несколько слов. Рисунок (4) показывает как выглядит нейросеть теперь. Вход теперь представляет собой серию OneHot Encoded векторов слов входящих в контекст. Количество слов в контексте является параметром который мы можем задавать при инициализации word2vec. Скрытый слой получает усреднение из всех контекстных слов.


      Рисунок 4. Топология модели CBOW с контекстом из нескольких слов

      Уравнения модели CBOW с несколькими контекстными словами являются обобщением уравнений модели CBOW с одним контекстным словом.

      $begin{eqnarray} textbf{h} = & frac{1}{C} W^T sum_{c=1}^Ctextbf{x}^{(c)} = W^Toverline{textbf{x}}hspace{5.8cm} nonumber  textbf{u}= & W'^Ttextbf{h}= frac{1}{C}sum_{c=1}^CW'^T W^Ttextbf{x}^{(c)}=W'^T W^Toverline{textbf{x}} hspace{2.8cm} nonumber  textbf{y}= & mathbb{S}textrm{oftmax}(textbf{u})= mathbb{S}textrm{oftmax}left( W'^T W^Toverline{textbf{x}}right) hspace{3.6cm} nonumber end{eqnarray}$

      Обратите внимание, что для удобства мы ввели определение ‘усредненный’ входной вектор $overline{textbf{x}}=sum_{c=1}^Ctextbf{x}^{(c)}/C$

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

      $begin{equation} mathcal{L} = -logmathbb{P}(w_o|w_{c,1},w_{c,2},dots,w_{c,C})=-u_{j^*} + log sum_i exp{(u_i)}. end{equation} >(12)$

      Снова выпишем уравнения по правилу цепочки, аналогично предыдущим:

      $begin{equation} frac{partialmathcal{L}}{partial W'_{ij}} = sum_{k=1}^Vfrac{partialmathcal{L}}{partial u_k}frac{partial u_k}{partial W'_{ij}} end{equation} >(13)$

      и

      $begin{equation} frac{partialmathcal{L}}{partial W_{ij}} = sum_{k=1}^Vfrac{partialmathcal{L}}{partial u_k}frac{partial u_k}{partial W_{ij}} . end{equation} >(14)$

      Производные функции потерь по весам такие же как для модели CBOW с одним словом на входе, с единственным отличием, что мы заменим входной вектор из одного слова на усредненный вектор из слов контекста. Выведем эти уравнения начиная с производной по $W’_{ij}$

      $begin{equation} frac{partialmathcal{L}}{partial W'_{ij}} = sum_{k=1}^Vfrac{partialmathcal{L}}{partial u_k}frac{partial u_k}{partial W'_{ij}} = frac{partialmathcal{L}}{partial u_j}frac{partial u_j}{partial W'_{ij}} = (-delta_{jj^*} + y_j) left(sum_{k=1}^V W_{ki}overline{x}_kright) end{equation} > (15)$

      Теперь запишем производную по $W_{ij}$:

      $begin{equation} frac{partialmathcal{L}}{partial W_{ij}} = sum_{k=1}^Vfrac{partialmathcal{L}}{partial u_k}frac{partial}{partial W_{ij}}left(frac{1}{C}sum_{m=1}^Nsum_{l=1}^V W'_{mk}sum_{c=1}^C W_{lm}x_l^{(c)}right)=frac{1}{C}sum_{k=1}^Vsum_{c=1}^C(-delta_{kk^*} + y_k)W'_{jk}x_i^{(c)} . end{equation} >(16)$

      Подведя итог имеем следующее:

      $begin{equation} bbox[white,5px,border:2px dotted red]{ frac{partialmathcal{L}}{partial W'_{ij}} = (-delta_{jj^*} + y_j) left(sum_{k=1}^V W_{ki}overline{x}_kright) } label{eq:backprop1_multi} end{equation} >(17)$

      и

      $begin{equation} bbox[white,5px,border:2px dotted red]{ frac{partialmathcal{L}}{partial W_{ij}} = sum_{k=1}^V(-delta_{kk^*} + y_k)W'_{jk}overline{x}_i . } label{eq:backprop2_multi} end{equation} > (18)$

      Векторизация

      Перепишем уравнения (17) и (18) в записи для векторов.
      Уравнение (17) примет вид:

      $begin{equation} bbox[white,5px,border:2px dotted red]{ frac{partialmathcal{L}}{partial W'} = (W^Toverline{textbf{x}}) otimes textbf{e} } end{equation} >(19)$

      Для уравнения (18):

      $begin{equation} bbox[white,5px,border:2px dotted red]{ frac{partial mathcal{L}}{partial W} =overline{textbf{x}}otimes(W'textbf{e}) } end{equation} >(20)$

      Еще раз, обратите внимание что уравнения идентичны уравнениям в модели CBOW для контекста из одного слова.
      Оператор ⊗ обозначает векторное произведение.

      5. Алгоритм обратного распространения для модели Skip-gram

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


      Рисунок 5.Топология Skip-gram модели.

      Уравнения skip-gram модели следующие:

      $begin{eqnarray*} textbf{h} = & W^Ttextbf{x} hspace{9.4cm}  textbf{u}_c= & W'^Ttextbf{h}=W'^TW^Ttextbf{x} hspace{4cm} c=1, dots, C hspace{0.7cm} textbf{y}_c = & mathbb{S}textrm{oftmax}(textbf{u})= mathbb{S}textrm{oftmax}(W'^TW^Ttextbf{x}) hspace{2cm} c=1, dots, C end{eqnarray*}$

      Обратите внимание что выходные вектора (как и вектор $textbf{u}_c$) идентичны, $mathbf{y}_1=mathbf{y}_2dots= mathbf{y}_C$. Функция потерь выглядит следующим образом:

      $begin{eqnarray*} mathcal{L} = -log mathbb{P}(w_{c,1}, w_{c,2}, dots, w_{c,C}|w_o)=-log prod_{c=1}^C mathbb{P}(w_{c,i}|w_o)  = -log prod_{c=1}^C frac{exp(u_{c,j^*})}{sum_{j=1}^V exp(u_{c,j})} =-sum_{c=1}^C u_{c,j^*} + sum_{c=1}^C log sum_{j=1}^V exp(u_{c,j}) end{eqnarray*}$

      Для модели skip-gram функция потерь зависит от $Ctimes V$ переменных
      через:

      $begin{equation*} mathcal{L} = mathcal{L}(mathbf{u_1}(W,W'), mathbf{u_2}(W,W'), dots, mathbf{u_C}(W,W'))=mathcal{L}(u_{1,1}(W,W'), u_{1,2}(W,W'), dots, u_{C,V}(W,W')) end{equation*}$

      Соответственно правило цепочки выглядит так:

      $begin{equation*} frac{partialmathcal{L}}{partial W'_{ij}} = sum_{k=1}^Vsum_{c=1}^Cfrac{partialmathcal{L}}{partial u_{c,k}}frac{partial u_{c,k}}{partial W'_{ij}} end{equation*}$

      и

      $begin{equation*} frac{partialmathcal{L}}{partial W_{ij}} = sum_{k=1}^Vsum_{c=1}^Cfrac{partialmathcal{L}}{partial u_{c,k}}frac{partial u_{c,k}}{partial W_{ij}} . end{equation*}$

      Посчитаем $partial mathcal{L}/partial u_{c,j}$, получим:

      $begin{equation*} frac{partialmathcal{L}}{partial u_{c,j}} = -delta_{jj_c^*} + y_{c,j} := e_{c,j} end{equation*}$

      Аналогично модели CBOW получаем:

      $begin{equation*} frac{partialmathcal{L}}{partial W'_{ij}} = sum_{k=1}^Vsum_{c=1}^Cfrac{partialmathcal{L}}{partial u_{c,k}}frac{partial u_{c,k}}{partial W'_{ij}} = sum_{c=1}^Cfrac{partialmathcal{L}}{partial u_{c,j}}frac{partial u_{c,j}}{partial W'_{ij}} = sum_{c=1}^C(-delta_{jj_c^*} + y_{c,j}) left(sum_{k=1}^V W_{ki}x_kright) end{equation*}$

      Производная по $W_{ij}$ самая сложная, но выполнимая:

      $begin{equation*} frac{partialmathcal{L}}{partial W_{ij}} = sum_{k=1}^Vsum_{c=1}^Cfrac{partialmathcal{L}}{partial u_{c,k}}frac{partial}{partial W_{ij}}left(sum_{m=1}^Nsum_{l=1}^V W'_{mk} W_{lm}x_lright)=sum_{k=1}^Vsum_{c=1}^C (-delta_{kk_c^*} + y_{c,k})W'_{jk}x_i . end{equation*}$

      Подведя итог, для модели skip-gram мы имеем:

      $begin{equation} bbox[white,5px,border:2px dotted red]{ frac{partialmathcal{L}}{partial W'_{ij}} = sum_{c=1}^C(-delta_{jj_c^*} + y_{c,j}) left(sum_{k=1}^V W_{ki}x_kright) } label{eq:backprop1_skip} end{equation} >(21)$

      и

      $begin{equation} bbox[white,5px,border:2px dotted red]{ frac{partialmathcal{L}}{partial W_{ij}} = sum_{k=1}^Vsum_{c=1}^C (-delta_{kk_c^*} + y_{c,k})W'_{jk}x_i . } label{eq:backprop2_skip} end{equation} >(22)$

      Векторизация

      Векторизованная версия уравнения (21):

      $begin{equation} bbox[white,5px,border:2px dotted red]{ frac{partialmathcal{L}}{partial W'} = (W^Ttextbf{x}) otimes sum_{c=1}^Ctextbf{e}_c } end{equation} >(23)$

      Уравнение (22):

      $begin{equation} bbox[white,5px,border:2px dotted red]{ frac{partial mathcal{L}}{partial W} = textbf{x}otimesleft(W'sum_{c=1}^Ctextbf{e}_cright) } end{equation} >(24)$

      6. Что дальше

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

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

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

      Надеюсь увидеть вас там!

      Дополнительные ссылки

      [1] X. Rong, word2vec Parameter Learning Explained, arXiv:1411.2738 (2014).
      [2] T. Mikolov, K. Chen, G. Corrado, J. Dean, Efficient Estimation of Word Representations in Vector Space, arXiv:1301.3781 (2013).

      Нейронные сети для начинающих. Часть 2 +38

      Алгоритмы, Машинное обучение


      Рекомендация: подборка платных и бесплатных курсов таргетированной рекламе — https://katalog-kursov.ru/

      Добро пожаловать во вторую часть руководства по нейронным сетям. Сразу хочу принести извинения всем кто ждал вторую часть намного раньше. По определенным причинам мне пришлось отложить ее написание. На самом деле я не ожидал, что у первой статьи будет такой спрос и что так много людей заинтересует данная тема. Взяв во внимание ваши комментарии, я постараюсь предоставить вам как можно больше информации и в то же время сохранить максимально понятный способ ее изложения. В данной статье, я буду рассказывать о способах обучения/тренировки нейросетей (в частности метод обратного распространения) и если вы, по каким-либо причинам, еще не прочитали первую часть, настоятельно рекомендую начать с нее. В процессе написания этой статьи, я хотел также рассказать о других видах нейросетей и методах тренировки, однако, начав писать про них, я понял что это пойдет вразрез с моим методом изложения. Я понимаю, что вам не терпится получить как можно больше информации, однако эти темы очень обширны и требуют детального анализа, а моей основной задачей является не написать очередную статью с поверхностным объяснением, а донести до вас каждый аспект затронутой темы и сделать статью максимально легкой в освоении. Спешу расстроить любителей “покодить”, так как я все еще не буду прибегать к использованию языка программирования и буду объяснять все “на пальцах”. Достаточно вступления, давайте теперь продолжим изучение нейросетей.

      Что такое нейрон смещения?

      Перед тем как начать нашу основную тему, мы должны ввести понятие еще одного вида нейронов — нейрон смещения. Нейрон смещения или bias нейрон — это третий вид нейронов, используемый в большинстве нейросетей. Особенность этого типа нейронов заключается в том, что его вход и выход всегда равняются 1 и они никогда не имеют входных синапсов. Нейроны смещения могут, либо присутствовать в нейронной сети по одному на слое, либо полностью отсутствовать, 50/50 быть не может (красным на схеме обозначены веса и нейроны которые размещать нельзя). Соединения у нейронов смещения такие же, как у обычных нейронов — со всеми нейронами следующего уровня, за исключением того, что синапсов между двумя bias нейронами быть не может. Следовательно, их можно размещать на входном слое и всех скрытых слоях, но никак не на выходном слое, так как им попросту не с чем будет формировать связь.

      Для чего нужен нейрон смещения?


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

      Из школьного курса математики, мы знаем, что если взять функцию y = ax+b и менять у нее значения “а”, то будет изменяться наклон функции (цвета линий на графике слева), а если менять “b”, то мы будем смещать функцию вправо или влево (цвета линий на графике справа). Так вот “а” — это вес H1, а “b” — это вес нейрона смещения B1. Это грубый пример, но примерно так все и работает (если вы посмотрите на функцию активации справа на изображении, то заметите очень сильное сходство между формулами). То есть, когда в ходе обучения, мы регулируем веса скрытых и выходных нейронов, мы меняем наклон функции активации. Однако, регулирование веса нейронов смещения может дать нам возможность сдвинуть функцию активации по оси X и захватить новые участки. Иными словами, если точка, отвечающая за ваше решение, будет находиться, как показано на графике слева, то ваша НС никогда не сможет решить задачу без использования нейронов смещения. Поэтому, вы редко встретите нейронные сети без нейронов смещения.

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

      ВАЖНО знать, что иногда на схемах не обозначают нейроны смещения, а просто учитывают их веса при вычислении входного значения например:

      input = H1*w1+H2*w2+b3
      b3 = bias*w3

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

      Как сделать чтобы НС давала правильные ответы?

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

      • Метод обратного распространения (Backpropagation)
      • Метод упругого распространения (Resilient propagation или Rprop)
      • Генетический Алгоритм (Genetic Algorithm)

      Об Rprop и ГА речь пойдет в других статьях, а сейчас мы с вами посмотрим на основу основ — метод обратного распространения, который использует алгоритм градиентного спуска.

      Что такое градиентный спуск?

      Это способ нахождения локального минимума или максимума функции с помощью движения вдоль градиента. Если вы поймете суть градиентного спуска, то у вас не должно возникнуть никаких вопросов во время использования метода обратного распространения. Для начала, давайте разберемся, что такое градиент и где он присутствует в нашей НС. Давайте построим график, где по оси х будут значения веса нейрона(w) а по оси у — ошибка соответствующая этому весу(e).

      Посмотрев на этот график, мы поймем, что график функция f(w) является зависимостью ошибки от выбранного веса. На этом графике нас интересует глобальный минимум — точка (w2,e2) или, иными словами, то место где график подходит ближе всего к оси х. Эта точка будет означать, что выбрав вес w2 мы получим самую маленькую ошибку — e2 и как следствие, самый лучший результат из всех возможных. Найти же эту точку нам поможет метод градиентного спуска (желтым на графике обозначен градиент). Соответственно у каждого веса в нейросети будет свой график и градиент и у каждого надо найти глобальный минимум.

      Так что же такое, этот градиент? Градиент — это вектор который определяет крутизну склона и указывает его направление относительно какой либо из точек на поверхности или графике. Чтобы найти градиент нужно взять производную от графика по данной точке (как это и показано на графике). Двигаясь по направлению этого градиента мы будем плавно скатываться в низину. Теперь представим что ошибка — это лыжник, а график функции — гора. Соответственно, если ошибка равна 100%, то лыжник находиться на самой вершине горы и если ошибка 0% то в низине. Как все лыжники, ошибка стремится как можно быстрее спуститься вниз и уменьшить свое значение. В конечном случае у нас должен получиться следующий результат:

      Представьте что лыжника забрасывают, с помощью вертолета, на гору. На сколько высоко или низко зависит от случая (аналогично тому, как в нейронной сети при инициализации веса расставляются в случайном порядке). Допустим ошибка равна 90% и это наша точка отсчета. Теперь лыжнику нужно спуститься вниз, с помощью градиента. На пути вниз, в каждой точке мы будем вычислять градиент, что будет показывать нам направление спуска и при изменении наклона, корректировать его. Если склон будет прямым, то после n-ого количества таких действий мы доберемся до низины. Но в большинстве случаев склон (график функции) будет волнистый и наш лыжник столкнется с очень серьезной проблемой — локальный минимум. Я думаю все знают, что такое локальный и глобальный минимум функции, для освежения памяти вот пример. Попадание в локальный минимум чревато тем, что наш лыжник навсегда останется в этой низине и никогда не скатиться с горы, следовательно мы никогда не сможем получить правильный ответ. Но мы можем избежать этого, снарядив нашего лыжника реактивным ранцем под названием момент (momentum). Вот краткая иллюстрация момента:

      Как вы уже наверное догадались, этот ранец придаст лыжнику необходимое ускорение чтобы преодолеть холм, удерживающий нас в локальном минимуме, однако здесь есть одно НО. Представим что мы установили определенное значение параметру момент и без труда смогли преодолеть все локальные минимумы, и добраться до глобального минимума. Так как мы не можем просто отключить реактивный ранец, то мы можем проскочить глобальный минимум, если рядом с ним есть еще низины. В конечном случае это не так важно, так как рано или поздно мы все равно вернемся обратно в глобальный минимум, но стоит помнить, что чем больше момент, тем больше будет размах с которым лыжник будет кататься по низинам. Вместе с моментом в методе обратного распространения также используется такой параметр как скорость обучения (learning rate). Как наверняка многие подумают, чем больше скорость обучения, тем быстрее мы обучим нейросеть. Нет. Скорость обучения, также как и момент, является гиперпараметром — величина которая подбирается путем проб и ошибок. Скорость обучения можно напрямую связать со скоростью лыжника и можно с уверенностью сказать — тише едешь дальше будешь. Однако здесь тоже есть определенные аспекты, так как если мы совсем не дадим лыжнику скорости то он вообще никуда не поедет, а если дадим маленькую скорость то время пути может растянуться на очень и очень большой период времени. Что же тогда произойдет если мы дадим слишком большую скорость?

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

      Что такое Метод Обратного Распространения (МОР)?

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

      А теперь давайте подробно разберем каждый этап. Если вы помните то в предыдущей статье мы считали выход НС. По другому это называется передача вперед (Forward pass), то есть мы последовательно передаем информацию от входных нейронов к выходным. После чего мы вычисляем ошибку и основываясь на ней делаем обратную передачу, которая заключается в том, чтобы последовательно менять веса нейронной сети, начиная с весов выходного нейрона. Значение весов будут меняться в ту сторону, которая даст нам наилучший результат. В моих вычисления я буду пользоваться методом нахождения дельты, так как это наиболее простой и понятный способ. Также я буду использовать стохастический метод обновления весов (об этом чуть позже).

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

      Данные задачи из предыдущей статьи

      Данные: I1=1, I2=0, w1=0.45, w2=0.78 ,w3=-0.12 ,w4=0.13 ,w5=1.5 ,w6=-2.3.

      H1input = 1*0.45+0*-0.12=0.45
      H1output = sigmoid(0.45)=0.61

      H2input = 1*0.78+0*0.13=0.78
      H2output = sigmoid(0.78)=0.69

      O1input = 0.61*1.5+0.69*-2.3=-0.672
      O1output = sigmoid(-0.672)=0.33

      O1ideal = 1 (0xor1=1)

      Error = ((1-0.33)^2)/1=0.45

      Результат — 0.33, ошибка — 45%.

      Так как мы уже подсчитали результат НС и ее ошибку, то мы можем сразу приступить к МОРу. Как я уже упоминал ранее, алгоритм всегда начинается с выходного нейрона. В таком случае давайте посчитаем для него значение ? (дельта) по формуле 1.
      Так как у выходного нейрона нет исходящих синапсов, то мы будем пользоваться первой формулой (? output), следственно для скрытых нейронов мы уже будем брать вторую формулу (? hidden). Тут все достаточно просто: считаем разницу между желаемым и полученным результатом и умножаем на производную функции активации от входного значения данного нейрона. Прежде чем приступить к вычислениям я хочу обратить ваше внимание на производную. Во первых как это уже наверное стало понятно, с МОР нужно использовать только те функции активации, которые могут быть дифференцированы. Во вторых чтобы не делать лишних вычислений, формулу производной можно заменить на более дружелюбную и простую формула вида:

      Таким образом наши вычисления для точки O1 будут выглядеть следующим образом.

      Решение

      O1output = 0.33
      O1ideal = 1
      Error = 0.45

      ?O1 = (1 — 0.33) * ( (1 — 0.33) * 0.33 ) = 0.148

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

      Решение

      H1output = 0.61
      w5 = 1.5
      ?O1 = 0.148

      ?H1 = ( (1 — 0.61) * 0.61 ) * ( 1.5 * 0.148 ) = 0.053

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

      Здесь точка A это точка в начале синапса, а точка B на конце синапса. Таким образом мы можем подсчитать градиент w5 следующим образом:

      Решение

      H1output = 0.61
      ?O1 = 0.148

      GRADw5 = 0.61 * 0.148 = 0.09

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

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

      Здесь мы видим 2 константы о которых мы уже говорили, когда рассматривали алгоритм градиентного спуска: E (эпсилон) — скорость обучения, ? (альфа) — момент. Переводя формулу в слова получим: изменение веса синапса равно коэффициенту скорости обучения, умноженному на градиент этого веса, прибавить момент умноженный на предыдущее изменение этого веса (на 1-ой итерации равно 0). В таком случае давайте посчитаем изменение веса w5 и обновим его значение прибавив к нему ?w5.

      Решение

      E = 0.7
      ? = 0.3
      w5 = 1.5
      GRADw5 = 0.09
      ?w5(i-1) = 0

      ?w5 = 0.7 * 0.09 + 0 * 0.3 = 0.063
      w5 = w5 + ?w5 = 1.563

      Таким образом после применения алгоритма наш вес увеличился на 0.063. Теперь предлагаю сделать вам тоже самое для H2.

      Решение

      H2output = 0.69
      w6 = -2.3
      ?O1 = 0.148
      E = 0.7
      ? = 0.3
      ?w6(i-1) = 0

      ?H2 = ( (1 — 0.69) * 0.69 ) * ( -2.3 * 0.148 ) = -0.07

      GRADw6 = 0.69 * 0.148 = 0.1

      ?w6 = 0.7 * 0.1 + 0 * 0.3 = 0.07

      w6 = w6 + ?w6 = -2.2

      И конечно не забываем про I1 и I2, ведь у них тоже есть синапсы веса которых нам тоже нужно обновить. Однако помним, что нам не нужно находить дельты для входных нейронов так как у них нет входных синапсов.

      Решение

      w1 = 0.45, ?w1(i-1) = 0
      w2 = 0.78, ?w2(i-1) = 0
      w3 = -0.12, ?w3(i-1) = 0
      w4 = 0.13, ?w4(i-1) = 0
      ?H1 = 0.053
      ?H2 = -0.07
      E = 0.7
      ? = 0.3

      GRADw1 = 1 * 0.053 = 0.053
      GRADw2 = 1 * -0.07 = -0.07
      GRADw3 = 0 * 0.053 = 0
      GRADw4 = 0 * -0.07 = 0

      ?w1 = 0.7 * 0.053 + 0 * 0.3 = 0.04
      ?w2 = 0.7 * -0.07 + 0 * 0.3 = -0.05
      ?w3 = 0.7 * 0 + 0 * 0.3 = 0
      ?w4 = 0.7 * 0 + 0 * 0.3 = 0

      w1 = w1 + ?w1 = 0.5
      w2 = w2 + ?w2 = 0.73
      w3 = w3 + ?w3 = -0.12
      w4 = w4 + ?w4 = 0.13

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

      Решение

      I1 = 1
      I2 = 0
      w1 = 0.5
      w2 = 0.73
      w3 = -0.12
      w4 = 0.13
      w5 = 1.563
      w6 = -2.2

      H1input = 1 * 0.5 + 0 * -0.12 = 0.5
      H1output = sigmoid(0.5) = 0.62

      H2input = 1 * 0.73 + 0 * 0.124 = 0.73
      H2output = sigmoid(0.73) = 0.675

      O1input = 0.62* 1.563 + 0.675 * -2.2 = -0.51
      O1output = sigmoid(-0.51) = 0.37

      O1ideal = 1 (0xor1=1)

      Error = ((1-0.37)^2)/1=0.39

      Результат — 0.37, ошибка — 39%.

      Как мы видим после одной итерации МОР, нам удалось уменьшить ошибку на 0.04 (6%). Теперь нужно повторять это снова и снова, пока ваша ошибка не станет достаточно мала.

      Что еще нужно знать о процессе обучения?

      Нейросеть можно обучать с учителем и без (supervised, unsupervised learning).

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

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

      Существует еще такой интересный метод, как обучение с подкреплением (reinforcement learning). Этот метод заслуживает отдельной статьи, но я попытаюсь вкратце описать его суть. Такой способ применим тогда, когда мы можем основываясь на результатах полученных от НС, дать ей оценку. Например мы хотим научить НС играть в PAC-MAN, тогда каждый раз когда НС будет набирать много очков мы будем ее поощрять. Иными словами мы предоставляем НС право найти любой способ достижения цели, до тех пор пока он будет давать хороший результат. Таким способом, сеть начнет понимать чего от нее хотят добиться и пытается найти наилучший способ достижения этой цели без постоянного предоставления данных “учителем”.

      Также обучение можно производить тремя методами: стохастический метод (stochastic), пакетный метод (batch) и мини-пакетный метод (mini-batch). Существует очень много статей и исследований на тему того, какой из методов лучше и никто не может прийти к общему ответу. Я же сторонник стохастического метода, однако я не отрицаю тот факт, что каждый метод имеет свои плюсы и минусы.

      Вкратце о каждом методе:

      Стохастический (его еще иногда называют онлайн) метод работает по следующему принципу — нашел ?w, сразу обнови соответствующий вес.

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

      Мини-пакетный метод является золотой серединой и пытается совместить в себе плюсы обоих методов. Здесь принцип таков: мы в свободном порядке распределяем веса по группам и меняем их веса на сумму ?w всех весов в той или иной группе.

      Что такое гиперпараметры?

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

      • Момент и скорость обучения
      • Количество скрытых слоев
      • Количество нейронов в каждом слое
      • Наличие или отсутствие нейронов смещения

      В других типах НС присутствуют дополнительные гиперпараметры, но о них мы говорить не будем. Подбор верных гиперпараметров очень важен и будет напрямую влиять на сходимость вашей НС. Понять стоит ли использовать нейроны смещения или нет достаточно просто. Количество скрытых слоев и нейронов в них можно вычислить перебором основываясь на одном простом правиле — чем больше нейронов, тем точнее результат и тем экспоненциально больше время, которое вы потратите на ее обучение. Однако стоит помнить, что не стоит делать НС с 1000 нейронов для решения простых задач. А вот с выбором момента и скорости обучения все чуточку сложнее. Эти гиперпараметры будут варьироваться, в зависимости от поставленной задачи и архитектуры НС. Например, для решения XOR скорость обучения может быть в пределах 0.3 — 0.7, но в НС которая анализирует и предсказывает цену акций, скорость обучения выше 0.00001 приводит к плохой сходимости НС. Не стоит сейчас заострять свое внимание на гиперпараметрах и пытаться досконально понять, как же их выбирать. Это придет с опытом, а пока что советую просто экспериментировать и искать примеры решения той или иной задачи в сети.

      Что такое сходимость?


      Сходимость говорит о том, правильная ли архитектура НС и правильно ли были подобраны гиперпараметры в соответствии с поставленной задачей. Допустим наша программа выводит ошибку НС на каждой итерации в лог. Если с каждой итерацией ошибка будет уменьшаться, то мы на верном пути и наша НС сходится. Если же ошибка будет прыгать вверх — вниз или застынет на определенном уровне, то НС не сходится. В 99% случаев это решается изменением гиперпараметров. Оставшийся 1% будет означать, что у вас ошибка в архитектуре НС. Также бывает, что на сходимость влияет переобучение НС.

      Что такое переобучение?

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

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

      Заключение

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

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

      Открытие метода обратного распространения ошибки стало одним из наиболее значимых событий в области искусственного интеллекта. В актуальном виде он был предложен в 1986 году Дэвидом Э. Румельхартом, Джеффри Э. Хинтоном и Рональдом Дж. Вильямсом и независимо и одновременно красноярскими математиками С. И. Барцевым и В. А. Охониным. С тех пор для нахождения градиентов параметров нейронной сети используется метод вычисления производной сложной функции, и оценка градиентов параметров сети стала хоть сложной инженерной задачей, но уже не искусством. Несмотря на простоту используемого математического аппарата, появление этого метода привело к значительному скачку в развитии искусственных нейронных сетей.

      Суть метода можно записать одной формулой, тривиально следующей из формулы производной сложной функции: если $f(x) = g_m(g_{m-1}(ldots (g_1(x)) ldots))$, то $frac{partial f}{partial x} = frac{partial g_m}{partial g_{m-1}}frac{partial g_{m-1}}{partial g_{m-2}}ldots frac{partial g_2}{partial g_1}frac{partial g_1}{partial x}$. Уже сейчас мы видим, что градиенты можно вычислять последовательно, в ходе одного обратного прохода, начиная с $frac{partial g_m}{partial g_{m-1}}$ и умножая каждый раз на частные производные предыдущего слоя.

      Backpropagation в одномерном случае

      В одномерном случае всё выглядит особенно просто. Пусть $w_0$ — переменная, по которой мы хотим продифференцировать, причём сложная функция имеет вид

      $$f(w_0) = g_m(g_{m-1}(ldots g_1(w_0)ldots)),$$

      где все $g_i$ скалярные. Тогда

      $$f'(w_0) = g_m'(g_{m-1}(ldots g_1(w_0)ldots))cdot g’_{m-1}(g_{m-2}(ldots g_1(w_0)ldots))cdotldots cdot g’_1(w_0)$$

      Суть этой формулы такова. Если мы уже совершили forward pass, то есть уже знаем

      $$g_1(w_0), g_2(g_1(w_0)),ldots,g_{m-1}(ldots g_1(w_0)ldots),$$

      то мы действуем следующим образом:

      • берём производную $g_m$ в точке $g_{m-1}(ldots g_1(w_0)ldots)$;

      • умножаем на производную $g_{m-1}$ в точке $g_{m-2}(ldots g_1(w_0)ldots)$;

      • и так далее, пока не дойдём до производной $g_1$ в точке $w_0$.

      Проиллюстрируем это на картинке, расписав по шагам дифференцирование по весам $w_i$ функции потерь логистической регрессии на одном объекте (то есть для батча размера 1):

      17_1.png

      Собирая все множители вместе, получаем:

      $$frac{partial f}{partial w_0} = (-y)cdot e^{-y(w_0 + w_1x_1 + w_2x_2)}cdotfrac{-1}{1 + e^{-y(w_0 + w_1x_1 + w_2x_2)}}$$

      $$frac{partial f}{partial w_1} = x_1cdot(-y)cdot e^{-y(w_0 + w_1x_1 + w_2x_2)}cdotfrac{-1}{1 + e^{-y(w_0 + w_1x_1 + w_2x_2)}}$$

      $$frac{partial f}{partial w_2} = x_2cdot(-y)cdot e^{-y(w_0 + w_1x_1 + w_2x_2)}cdotfrac{-1}{1 + e^{-y(w_0 + w_1x_1 + w_2x_2)}}$$

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

      Почему же нельзя просто пойти и начать везде вычислять производные?

      В главе, посвящённой матричным дифференцированиям, мы поднимаем вопрос о том, что вычислять частные производные по отдельности — это зло, лучше пользоваться матричными вычислениями. Но есть и ещё одна причина: даже и с матричной производной в принципе не всегда хочется иметь дело. Рассмотрим простой пример. Допустим, что $X^r$ и $X^{r+1}$ — два последовательных промежуточных представления $Ntimes M$ и $Ntimes K$, связанных функцией $X^{r+1} = f^{r+1}(X^r)$. Предположим, что мы как-то посчитали производную $frac{partialmathcal{L}}{partial X^{r+1}_{ij}}$ функции потерь $mathcal{L}$, тогда

      $$frac{partialmathcal{L}}{partial X^{r}_{st}} = sum_{i,j}frac{partial f^{r+1}_{ij}}{partial X^{r}_{st}}frac{partialmathcal{L}}{partial X^{r+1}_{ij}}$$

      И мы видим, что, хотя оба градиента $frac{partialmathcal{L}}{partial X_{ij}^{r+1}}$ и $frac{partialmathcal{L}}{partial X_{st}^{r}}$ являются просто матрицами, в ходе вычислений возникает «четырёхмерный кубик» $frac{partial f_{ij}^{r+1}}{partial X_{st}^{r}}$, даже хранить который весьма болезненно: уж больно много памяти он требует ($N^2MK$ по сравнению с безобидными $NM + NK$, требуемыми для хранения градиентов). Поэтому хочется промежуточные производные $frac{partial f^{r+1}}{partial X^{r}}$ рассматривать не как вычисляемые объекты $frac{partial f_{ij}^{r+1}}{partial X_{st}^{r}}$, а как преобразования, которые превращают $frac{partialmathcal{L}}{partial X_{ij}^{r+1}}$ в $frac{partialmathcal{L}}{partial X_{st}^{r}}$. Целью следующих глав будет именно это: понять, как преобразуется градиент в ходе error backpropagation при переходе через тот или иной слой.

        Вы спросите себя: надо ли мне сейчас пойти и прочитать главу учебника про матричное дифференцирование?

      Встречный вопрос. Найдите производную функции по вектору $x$:

      $$f(x) = x^TAx, Ain Mat_{n}{mathbb{R}}text{ — матрица размера }ntimes n$$

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

      Мы же сразу перейдём к интересующей нас вещи: к вычислению градиентов сложных функций.

      Градиент сложной функции

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

      $$left[D_{x_0} (color{#5002A7}{u} circ color{#4CB9C0}{v}) right](h) = color{#5002A7}{left[D_{v(x_0)} u right]} left( color{#4CB9C0}{left[D_{x_0} vright]} (h)right)$$

      Теперь разберёмся с градиентами. Пусть $f(x) = g(h(x))$ – скалярная функция. Тогда

      $$left[D_{x_0} f right] (x-x_0) = langlenabla_{x_0} f, x-x_0rangle.$$

      С другой стороны,

      $$left[D_{h(x_0)} g right] left(left[D_{x_0}h right] (x-x_0)right) = langlenabla_{h_{x_0}} g, left[D_{x_0} hright] (x-x_0)rangle = langleleft[D_{x_0} hright]^* nabla_{h(x_0)} g, x-x_0rangle.$$

      То есть $color{#FFC100}{nabla_{x_0} f} = color{#348FEA}{left[D_{x_0} h right]}^* color{#FFC100}{nabla_{h(x_0)}}g$ — применение сопряжённого к $D_{x_0} h$ линейного отображения к вектору $nabla_{h(x_0)} g$.

      Эта формула — сердце механизма обратного распространения ошибки. Она говорит следующее: если мы каким-то образом получили градиент функции потерь по переменным из некоторого промежуточного представления $X^k$ нейронной сети и при этом знаем, как преобразуется градиент при проходе через слой $f^k$ между $X^{k-1}$ и $X^k$ (то есть как выглядит сопряжённое к дифференциалу слоя между ними отображение), то мы сразу же находим градиент и по переменным из $X^{k-1}$:

      17_2.png

      Таким образом слой за слоем мы посчитаем градиенты по всем $X^i$ вплоть до самых первых слоёв.

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

      Градиенты для типичных слоёв

      Рассмотрим несколько важных примеров.

      Примеры

      1. $f(x) = u(v(x))$, где $x$ — вектор, а $v(x)$ – поэлементное применение $v$:

        $$vbegin{pmatrix}
        x_1
        vdots
        x_N
        end{pmatrix}
        = begin{pmatrix}
        v(x_1)
        vdots
        v(x_N)
        end{pmatrix}$$

        Тогда, как мы знаем,

        $$left[D_{x_0} fright] (h) = langlenabla_{x_0} f, hrangle = left[nabla_{x_0} fright]^T h.$$

        Следовательно,

        $$begin{multline*}
        left[D_{v(x_0)} uright] left( left[ D_{x_0} vright] (h)right) = left[nabla_{v(x_0)} uright]^T left(v'(x_0) odot hright) =[0.1cm]
        = sumlimits_i left[nabla_{v(x_0)} uright]_i v'(x_{0i})h_i
        = langleleft[nabla_{v(x_0)} uright] odot v'(x_0), hrangle.
        end{multline*},$$

        где $odot$ означает поэлементное перемножение. Окончательно получаем

        $$color{#348FEA}{nabla_{x_0} f = left[nabla_{v(x_0)}uright] odot v'(x_0) = v'(x_0) odot left[nabla_{v(x_0)} uright]}$$

        Отметим, что если $x$ и $h(x)$ — это просто векторы, то мы могли бы вычислять всё и по формуле $frac{partial f}{partial x_i} = sum_jbig(frac{partial z_j}{partial x_i}big)cdotbig(frac{partial h}{partial z_j}big)$. В этом случае матрица $big(frac{partial z_j}{partial x_i}big)$ была бы диагональной (так как $z_j$ зависит только от $x_j$: ведь $h$ берётся поэлементно), и матричное умножение приводило бы к тому же результату. Однако если $x$ и $h(x)$ — матрицы, то $big(frac{partial z_j}{partial x_i}big)$ представлялась бы уже «четырёхмерным кубиком», и работать с ним было бы ужасно неудобно.

      2. $f(X) = g(XW)$, где $X$ и $W$ — матрицы. Как мы знаем,

        $$left[D_{X_0} f right] (X-X_0) = text{tr}, left(left[nabla_{X_0} fright]^T (X-X_0)right).$$

        Тогда

        $$begin{multline*}
        left[ D_{X_0W} g right] left(left[D_{X_0} left( ast Wright)right] (H)right) =
        left[ D_{X_0W} g right] left(HWright)=
        = text{tr}, left( left[nabla_{X_0W} g right]^T cdot (H) W right) =
        =
        text{tr} , left(W left[nabla_{X_0W} (g) right]^T cdot (H)right) = text{tr} , left( left[left[nabla_{X_0W} gright] W^Tright]^T (H)right)
        end{multline*}$$

        Здесь через $ast W$ мы обозначили отображение $Y hookrightarrow YW$, а в предпоследнем переходе использовалось следующее свойство следа:

        $$
        text{tr} , (A B C) = text{tr} , (C A B),
        $$

        где $A, B, C$ — произвольные матрицы подходящих размеров (то есть допускающие перемножение в обоих приведённых порядках). Следовательно, получаем

        $$color{#348FEA}{nabla_{X_0} f = left[nabla_{X_0W} (g) right] cdot W^T}$$

      3. $f(W) = g(XW)$, где $W$ и $X$ — матрицы. Для приращения $H = W — W_0$ имеем

        $$
        left[D_{W_0} f right] (H) = text{tr} , left( left[nabla_{W_0} f right]^T (H)right)
        $$

        Тогда

        $$ begin{multline*}
        left[D_{XW_0} g right] left( left[D_{W_0} left(X astright) right] (H)right) = left[D_{XW_0} g right] left( XH right) =
        = text{tr} , left( left[nabla_{XW_0} g right]^T cdot X (H)right) =
        text{tr}, left(left[X^T left[nabla_{XW_0} g right] right]^T (H)right)
        end{multline*} $$

        Здесь через $X ast$ обозначено отображение $Y hookrightarrow XY$. Значит,

        $$color{#348FEA}{nabla_{X_0} f = X^T cdot left[nabla_{XW_0} (g)right]}$$

      4. $f(X) = g(softmax(X))$, где $X$ — матрица $Ntimes K$, а $softmax$ — функция, которая вычисляется построчно, причём для каждой строки $x$

        $$softmax(x) = left(frac{e^{x_1}}{sum_te^{x_t}},ldots,frac{e^{x_K}}{sum_te^{x_t}}right)$$

        В этом примере нам будет удобно воспользоваться формализмом с частными производными. Сначала вычислим $frac{partial s_l}{partial x_j}$ для одной строки $x$, где через $s_l$ мы для краткости обозначим $softmax(x)_l = frac{e^{x_l}} {sum_te^{x_t}}$. Нетрудно проверить, что

        $$frac{partial s_l}{partial x_j} = begin{cases}
        s_j(1 — s_j), & j = l,
        -s_ls_j, & jne l
        end{cases}$$

        Так как softmax вычисляется независимо от каждой строчки, то

        $$frac{partial s_{rl}}{partial x_{ij}} = begin{cases}
        s_{ij}(1 — s_{ij}), & r=i, j = l,
        -s_{il}s_{ij}, & r = i, jne l,
        0, & rne i
        end{cases},$$

        где через $s_{rl}$ мы обозначили для краткости $softmax(X)_{rl}$.

        Теперь пусть $nabla_{rl} = nabla g = frac{partialmathcal{L}}{partial s_{rl}}$ (пришедший со следующего слоя, уже известный градиент). Тогда

        $$frac{partialmathcal{L}}{partial x_{ij}} = sum_{r,l}frac{partial s_{rl}}{partial x_{ij}} nabla_{rl}$$

        Так как $frac{partial s_{rl}}{partial x_{ij}} = 0$ при $rne i$, мы можем убрать суммирование по $r$:

        $$ldots = sum_{l}frac{partial s_{il}}{partial x_{ij}} nabla_{il} = -s_{i1}s_{ij}nabla_{i1} — ldots + s_{ij}(1 — s_{ij})nabla_{ij}-ldots — s_{iK}s_{ij}nabla_{iK} =$$

        $$= -s_{ij}sum_t s_{it}nabla_{it} + s_{ij}nabla_{ij}$$

        Таким образом, если мы хотим продифференцировать $f$ в какой-то конкретной точке $X_0$, то, смешивая математические обозначения с нотацией Python, мы можем записать:

        $$begin{multline*}
        color{#348FEA}{nabla_{X_0}f =}
        color{#348FEA}{= -softmax(X_0) odot text{sum}left(
        softmax(X_0)odotnabla_{softmax(X_0)}g, text{ axis = 1}
        right) +}
        color{#348FEA}{softmax(X_0)odot nabla_{softmax(X_0)}g}
        end{multline*}
        $$

      Backpropagation в общем виде

      Подытожим предыдущее обсуждение, описав алгоритм error backpropagation (алгоритм обратного распространения ошибки). Допустим, у нас есть текущие значения весов $W^i_0$ и мы хотим совершить шаг SGD по мини-батчу $X$. Мы должны сделать следующее:

      1. Совершить forward pass, вычислив и запомнив все промежуточные представления $X = X^0, X^1, ldots, X^m = widehat{y}$.
      2. Вычислить все градиенты с помощью backward pass.
      3. С помощью полученных градиентов совершить шаг SGD.

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

      17_3.png Обучаемые параметры – матрицы $U$ и $W$. Как найти градиенты по ним в точке $U_0, W_0$?

      $$nabla_{W_0}mathcal{L} = nabla_{W_0}{left({vphantom{frac12}mathcal{L}circ hcircleft[Wmapsto g(XU_0)Wright]}right)}=$$

      $$=g(XU_0)^Tnabla_{g(XU_0)W_0}(mathcal{L}circ h) = underbrace{g(XU_0)^T}_{ktimes N}cdot
      left[vphantom{frac12}underbrace{h’left(vphantom{int_0^1}g(XU_0)W_0right)}_{Ntimes 1}odot
      underbrace{nabla_{hleft(vphantom{int_0^1}g(XU_0)W_0right)}mathcal{L}}_{Ntimes 1}right]$$

      Итого матрица $ktimes 1$, как и $W_0$

      $$nabla_{U_0}mathcal{L} = nabla_{U_0}left(vphantom{frac12}
      mathcal{L}circ hcircleft[Ymapsto YW_0right]circ gcircleft[ Umapsto XUright]
      right)=$$

      $$=X^Tcdotnabla_{XU^0}left(vphantom{frac12}mathcal{L}circ hcirc [Ymapsto YW_0]circ gright) =$$

      $$=X^Tcdotleft(vphantom{frac12}g'(XU_0)odot
      nabla_{g(XU_0)}left[vphantom{in_0^1}mathcal{L}circ hcirc[Ymapsto YW_0right]
      right)$$

      $$=ldots = underset{Dtimes N}{X^T}cdotleft(vphantom{frac12}
      underbrace{g'(XU_0)}_{Ntimes K}odot
      underbrace{left[vphantom{int_0^1}left(
      underbrace{h’left(vphantom{int_0^1}g(XU_0)W_0right)}_{Ntimes1}odotunderbrace{nabla_{h(vphantom{int_0^1}gleft(XU_0right)W_0)}mathcal{L}}_{Ntimes 1}
      right)cdot underbrace{W^T}_{1times K}right]}_{Ntimes K}
      right)$$

      Итого $Dtimes K$, как и $U_0$

      Схематически это можно представить следующим образом:

      17_4.gif

      Backpropagation для двуслойной нейронной сети

      Если вы не уследили за вычислениями в предыдущем примере, давайте более подробно разберём его чуть более конкретную версию (для $g = h = sigma$)Рассмотрим двуслойную нейронную сеть для классификации. Мы уже встречали ее ранее при рассмотрении линейно неразделимой выборки. Предсказания получаются следующим образом:

      $$
      widehat{y} = sigma(X^1 W^2) = sigmaBig(big(sigma(X^0 W^1 )big) W^2 Big).
      $$

      Пусть $W^1_0$ и $W^2_0$ — текущее приближение матриц весов. Мы хотим совершить шаг по градиенту функции потерь, и для этого мы должны вычислить её градиенты по $W^1$ и $W^2$ в точке $(W^1_0, W^2_0)$.

      Прежде всего мы совершаем forward pass, в ходе которого мы должны запомнить все промежуточные представления: $X^1 = X^0 W^1_0$, $X^2 = sigma(X^0 W^1_0)$, $X^3 = sigma(X^0 W^1_0) W^2_0$, $X^4 = sigma(sigma(X^0 W^1_0) W^2_0) = widehat{y}$. Они понадобятся нам дальше.

      Для полученных предсказаний вычисляется значение функции потерь:

      $$
      l = mathcal{L}(y, widehat{y}) = y log(widehat{y}) + (1-y) log(1-widehat{y}).
      $$

      Дальше мы шаг за шагом будем находить производные по переменным из всё более глубоких слоёв.

      1. Градиент $mathcal{L}$ по предсказаниям имеет вид

        $$
        nabla_{widehat{y}}l = frac{y}{widehat{y}} — frac{1 — y}{1 — widehat{y}} = frac{y — widehat{y}}{widehat{y} (1 — widehat{y})},
        $$

        где, напомним, $ widehat{y} = sigma(X^3) = sigmaBig(big(sigma(X^0 W^1_0 )big) W^2_0 Big)$ (обратите внимание на то, что $W^1_0$ и $W^2_0$ тут именно те, из которых мы делаем градиентный шаг).

      2. Следующий слой — поэлементное взятие $sigma$. Как мы помним, при переходе через него градиент поэлементно умножается на производную $sigma$, в которую подставлено предыдущее промежуточное представление:

        $$
        nabla_{X^3}l = sigma'(X^3)odotnabla_{widehat{y}}l = sigma(X^3)left( 1 — sigma(X^3) right) odot frac{y — widehat{y}}{widehat{y} (1 — widehat{y})} =
        $$

        $$
        = sigma(X^3)left( 1 — sigma(X^3) right) odot frac{y — sigma(X^3)}{sigma(X^3) (1 — sigma(X^3))} =
        y — sigma(X^3)
        $$

      3. Следующий слой — умножение на $W^2_0$. В этот момент мы найдём градиент как по $W^2$, так и по $X^2$. При переходе через умножение на матрицу градиент, как мы помним, умножается с той же стороны на транспонированную матрицу, а значит:

        $$
        color{blue}{nabla_{W^2_0}l} = (X^2)^Tcdot nabla_{X^3}l = (X^2)^Tcdot(y — sigma(X^3)) =
        $$

        $$
        = color{blue}{left( sigma(X^0W^1_0) right)^T cdot (y — sigma(sigma(X^0W^1_0)W^2_0))}
        $$

        Аналогичным образом

        $$
        nabla_{X^2}l = nabla_{X^3}lcdot (W^2_0)^T = (y — sigma(X^3))cdot (W^2_0)^T =
        $$

        $$
        = (y — sigma(X^2W_0^2))cdot (W^2_0)^T
        $$

      4. Следующий слой — снова взятие $sigma$.

        $$
        nabla_{X^1}l = sigma'(X^1)odotnabla_{X^2}l = sigma(X^1)left( 1 — sigma(X^1) right) odot left( (y — sigma(X^2W_0^2))cdot (W^2_0)^T right) =
        $$

        $$
        = sigma(X^1)left( 1 — sigma(X^1) right) odotleft( (y — sigma(sigma(X^1)W_0^2))cdot (W^2_0)^T right)
        $$

      5. Наконец, последний слой — это умножение $X^0$ на $W^1_0$. Тут мы дифференцируем только по $W^1$:

        $$
        color{blue}{nabla_{W^1_0}l} = (X^0)^Tcdot nabla_{X^1}l = (X^0)^Tcdot big( sigma(X^1) left( 1 — sigma(X^1) right) odot (y — sigma(sigma(X^1)W_0^2))cdot (W^2_0)^Tbig) =
        $$

        $$
        = color{blue}{(X^0)^Tcdotbig(sigma(X^0W^1_0)left( 1 — sigma(X^0W^1_0) right) odot (y — sigma(sigma(X^0W^1_0)W_0^2))cdot (W^2_0)^Tbig) }
        $$

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

      Автоматизация и autograd

      Итак, чтобы нейросеть обучалась, достаточно для любого слоя $f^k: X^{k-1}mapsto X^k$ с параметрами $W^k$ уметь:

      • превращать $nabla_{X^k_0}mathcal{L}$ в $nabla_{X^{k-1}_0}mathcal{L}$ (градиент по выходу в градиент по входу);
      • считать градиент по его параметрам $nabla_{W^k_0}mathcal{L}$.

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

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

      Но это лишь начало

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

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

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

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

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

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

      То есть на входы нейронов I1 и I2 мы подаем какие-либо числа, а на выходе сети получаем соответственно новое значение. При этом нам необходима некая выборка данных, включающая в себя значения входов и соответствующее им, правильное, значение на выходе:

      bold{I_1} bold{I_2} bold{O_{net}}
      x_{11} x_{12} y_{1}
      x_{21} x_{22} y_{2}
      x_{31} x_{32} y_{3}
      x_{N1} x_{N2} y_{N}

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

      bold{I_1} bold{I_2} bold{O_{net}}
      1 4 5
      2 7 9
      3 5 8
      1000 1500 2500

      Эти значения и используются для обучения сети. Как именно — рассмотрим чуть ниже, пока сконцентрируемся на идее процесса в целом. Для того, чтобы иметь возможность тестировать работу сети в процессе обучения, исходную выборку данных делят на две части — обучающую и тестовую. Пусть имеется 1000 образцов, тогда можно 900 использовать для обучения, а оставшиеся 100 — для тестирования. Эти величины взяты исключительно ради наглядности и демонстрации логики выполнения операций, на практике все зависит от задачи, размер обучающей выборки может спокойно достигать и сотен тысяч образцов.

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

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

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

      Анализируем нашу гипотетическую выборку:

      Обучающая выборка.

      Таким образом, для тестирования подаем на вход сети значения x_{(M+1)1}, x_{(M+1)2} и проверяем, чему равен выход, ожидаем очевидно значение y_{(M+1)}. Аналогично поступаем и для оставшихся тестовых образцов. После чего мы можем сделать вывод, успешно или нет работает сеть. Например, сеть дает правильный ответ для 90% тестовых данных, дальше уже встает вопрос — устраивает ли нас данная точность или процесс обучения необходимо повторить, либо провести заново, изменив какие-либо параметры сети.

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

      Обучение нейронных сетей. Градиентный спуск.

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

      Здесь Delta w_{ij} — величина, на которую необходимо изменить вес синапса, связывающего нейроны i и j нашей сети. Соответственно, зная это, необходимо на каждом этапе обучения производить корректировку весов связей между всеми элементами нейронной сети. Задача ясна, переходим к делу.

      Пусть функция ошибки от веса имеет следующий вид:

      Для удобства рассмотрим зависимость функции ошибки от одного конкретного веса:

      График ошибки.

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

      Минимизация ошибки при обучении нейронной сети.

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

      Градиентный спуск.

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

      Алгоритм обратного распространения ошибки.

      Находясь в точке 1, целью является перейти в точку 2, поскольку в ней значение ошибки меньше (E_2 < E_1), а глобальная задача по-прежнему заключается в ее минимизации. Для этого необходимо изменить величину w на некое значение Delta w (Delta w = w_2 — w_1 > 0). При всем при этом в точке 1 градиент отрицательный. Фиксируем данные факты и переходим к точке 3, предположим, что мы находимся именно в ней.

      Тогда для уменьшения ошибки наш путь лежит в точку 4, а необходимое изменение значения: Delta w = w_4 — w_3 < 0. Градиент же в точке 3 положителен. Этот факт также фиксируем.

      А теперь соберем воедино эту информацию в виде следующей иллюстрации:

      Переход bold{Delta w} Знак bold{Delta w} Градиент
      1 rArr 2 w_2 — w_1 +
      3 rArr 4 w_4 — w_3 +

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

      Delta w = -alpha cdot frac{dE}{dw}

      Имеем в наличии:

      • Delta w — величина, на которую необходимо изменить значение w.
      • frac{dE}{dw} — градиент в этой точке.
      • alpha — скорость обучения.

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

      Delta w_{ij} = -alpha cdot frac{dE}{dw_{ij}}

      Более того, вспомним о важном свойстве, которое мы отдельно пометили. И заключается оно в том, что величина градиента будет уменьшаться по мере приближения к минимуму функции. Что это нам дает? А то, что в том случае, если наша текущая дислокация далека от места назначения, то величина, корректирующая вес связи, будет больше. А это обеспечит скорейшее приближение к цели. При приближении к целевому пункту, величина frac{dE}{dw_{ij}} будет уменьшаться, что поможет нам точнее попасть в нужную точку, а кроме того, не позволит нам ее проскочить. Визуализируем вышеописанное:

      Скорость обучения.

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

      Норма обучения.

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

      И даже на этом еще не все, здесь присутствует один важный нюанс, который в большинстве статей опускается, либо вовсе не упоминается. Реальная зависимость может иметь совсем другой вид:

      Локальные минимумы при обучении нейронных сетей.

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

      Delta w_{ij} = -alpha cdot frac{dE}{dw_{ij}} + gamma cdot Delta w_{ij}^{t - 1}

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

      Итого, резюмируем продвижение к цели:

      • Нашей задачей было найти закон, по которому необходимо изменять величину весов связей между нейронами.
      • Наш результат — Delta w_{ij} = -alpha cdot frac{dE}{dw_{ij}} + gamma cdot Delta w_{ij}^{t — 1} — именно то, что и требовалось 👍

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

      Обучение нейронных сетей. Функция ошибки.

      Начнем с того, что определимся с тем, что у нас в наличии, для этого вернемся к конкретной нейронной сети. Пусть вид ее таков:

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

      Интересует нас, в первую очередь, часть, относящаяся к нейронам выходного слоя. Подав на вход определенные значения, получаем значения на выходе сети: O_{net, 1} и O_{net, 2}. Кроме того, поскольку мы ведем речь о процессе обучения нейронной сети, то нам известны целевые значения: O_{correct, 1} и O_{correct, 2}. И именно этот набор данных на этом этапе является для нас исходным:

      • Известно: O_{net, 1}, O_{net, 2}, O_{correct, 1} и O_{correct, 2}.
      • Необходимо определить величины Delta w_{ij} для корректировки весов, для этого нужно вычислить градиенты (frac{dE}{dw_{ij}}) для каждого из синапсов.

      Полдела сделано — задача четко сформулирована, начинаем деятельность по поиску решения.

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

      E_k = O_{correct, k} - O_{net, k}

      Дополним пример числовыми значениями:

      Нейрон bold{O_{net}} bold{O_{correct}} bold{E}
      1 0.9 0.5 -0.4
      2 0.2 0.6 0.4

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

      E_{sum} = e_1 + e_2 = -0.4 + 0.4 = 0

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

      Вторым, традиционно упоминаемым, методом вычисления ошибки является использование модуля разности:

      E_k = | O_{correct, k} - O_{net, k} |

      Тут в действие вступает уже проблема иного рода:

      График модуля.

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

      В итоге хороший результат дает зависимость (для выходного нейрона под номером k):

      E_k = (O_{correct, k} - O_{net, k})^2

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

      Краткий вывод промежуточного шага, на который мы вышли:

      • Имеющееся: frac{dE}{dw_{jk}} = frac{d}{d w_{jk}}(O_{correct, k} — O_{net, k})^2.
      • Искомое по-прежнему: Delta w_{jk}.

      Несложные диффернциально-математические изыскания выводят на следующий результат:

      frac{dE}{d w_{jk}} = -(O_{correct, k} - O_{net, k}) cdot f{Large{prime}}(sum_{j}w_{jk}O_j) cdot O_j

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

      Освежим в памяти структуру сети:

      Пример обучения нейронных сетей.

      Формулу можно упростить, сгруппировав отдельные ее части:

      • (O_{correct, k} — O_{net, k}) cdot f{Large{prime}}(sum_{j}w_{jk}O_j) — ошибка нейрона k.
      • O_j — тут все понятно, выходной сигнал нейрона j.

      f{Large{prime}}(sum_{j}w_{jk}O_j) — значение производной функции активации. Причем, обратите внимание, что sum_{j}w_{jk}O_j — это не что иное, как сигнал на входе нейрона k (I_{k}). Тогда для расчета ошибки выходного нейрона: delta_k = (O_{correct, k} — O_{net, k}) cdot f{Large{prime}}(I_k).

      Итог: frac{dE}{d w_{jk}} = -delta_k cdot O_j.

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

      f{'}(x) = f(x)medspace (1medspace-medspace f(x))

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

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

      frac{dE}{d w_{ij}} = -delta_j cdot O_i

      Который примет следующий вид:

      delta_j = (sum_{k}{}{delta_kmedspace w_{jk}}) cdot f{Large{prime}}(I_j)

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

      frac{dE}{d w_{ij}} = -(sum_{k}{}{delta_kmedspace w_{jk}}) cdot f{Large{prime}}(I_j) cdot O_i

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

      • Ошибка:
        • выходной слой: delta_k = (O_{correct, k} — O_{net, k}) cdot f{Large{prime}}(I_k)
        • скрытые слои: delta_j = (sum_{k}{}{delta_kmedspace w_{jk}}) cdot f{Large{prime}}(I_j)
      • Градиент: frac{dE}{d w_{ij}} = -delta_j cdot O_i
      • Корректировка весовых коэффициентов: Delta w_{ij} = -alpha cdot frac{dE}{dw_{ij}} + gamma cdot Delta w_{ij}^{t — 1}

      Преобразуем последнюю формулу:

      Delta w_{ij} = alpha cdot delta_j cdot O_i + gamma cdot Delta w_{ij}^{t - 1}

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

      Метод обратного распространения ошибки.

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

      Суть же метода подразумевает наличие двух этапов:

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

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

      Вернемся к конкретному примеру для явной демонстрации этих принципов:

      Обратное распространение ошибки.

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

      Процесс обучения нейронной сети для алгоритма обратного распространения ошибки будет таким:

      1. Прямой проход. Подаем на вход значения I_1, I_2, I_3 из обучающей выборки. В результате работы сети получаем выходные значения O_{net, 1}, O_{net, 2}. Этому целиком и полностью был посвящен предыдущий манускрипт.
      2. Рассчитываем величины ошибок для всех слоев:
        • для выходного: delta_k = (O_{correct, k} — O_{net, k}) cdot f{Large{prime}}(I_k)
        • для скрытых: delta_j = (sum_{k}{}{delta_kmedspace w_{jk}}) cdot f{Large{prime}}(I_j)
      3. Далее используем полученные значения для расчета Delta w_{ij} = alpha cdot delta_j cdot O_i + gamma cdot Delta w_{ij}^{t — 1}
      4. И финишируем, рассчитывая новые значения весов: w_{ij medspace new} = w_{ij} + Delta w_{ij}
      5. На этом один цикл обучения закончен, данные шаги 1 — 4 повторяются для других образцов из обучающей выборки.

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

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

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

      Возьмем нейронную сеть и зададим начальные значения весов:

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

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

      В качестве функции активации используем сигмоиду:

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

      И ее производная:

      f{Large{prime}}(x) = f(x)medspace (1medspace-medspace f(x))

      Берем один образец из обучающей выборки, пусть будут такие значения:

      • Входные: I_1 = 0.6, I_1 = 0.7.
      • Выходное: O_{correct} = 0.9.

      Скорость обучения alpha пусть будет равна 0.3, момент — gamma = 0.1. Все готово, теперь проведем полный цикл для метода обратного распространения ошибки, то есть прямой проход и обратный.

      Прямой проход.

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

      O_1 = I_1 = 0.6  O_2 = I_2 = 0.7

      Значения на входе нейронов 3, 4 и 5:

      I_3 = O_1 cdot w_{13} + O_2 cdot w_{23} = 0.6 cdot (-1medspace) + 0.7 cdot 1 = 0.1  I_4 = 0.6 cdot 2.5 + 0.7 cdot 0.4 = 1.78  I_5 = 0.6 cdot 1 + 0.7 cdot (-1.5medspace) = -0.45

      На выходе этих же нейронов первого скрытого слоя:

      O_3 = f(I3medspace) = 0.52  O_4 = 0.86 O_5 = 0.39

      Продолжаем аналогично для следующего скрытого слоя:

      I_6 = O_3 cdot w_{36} + O_4 cdot w_{46} + O_5 cdot w_{56} = 0.52 cdot 2.2 + 0.86 cdot (-1.4medspace) + 0.39 cdot 0.56 = 0.158  I_7 = 0.52 cdot 0.34 + 0.86 cdot 1.05 + 0.39 cdot 3.1 = 2.288  O_6 = f(I_6) = 0.54  O_7 = 0.908

      Добрались до выходного нейрона:

      I_8 = O_6 cdot w_{68} + O_7 cdot w_{78} = 0.54 cdot 0.75 + 0.908 cdot (-0.22medspace) = 0.205  O_8 = O_{net} = f(I_8) = 0.551

      Получили значение на выходе сети, кроме того, у нас есть целевое значение O_{correct} = 0.9. То есть все, что необходимо для обратного прохода, имеется.

      Обратный проход.

      Как мы и обсуждали, первым этапом будет вычисление ошибок всех нейронов, действуем:

      delta_8 = (O_{correct} - O_{net}) cdot f{Large{prime}}(I_8) = (O_{correct} - O_{net}) cdot f(I_8) cdot (1-f(I_8)) = (0.9 - 0.551medspace) cdot 0.551 cdot (1-0.551medspace) = 0.0863  delta_7 = (sum_{k}{}{delta_kmedspace w_{jk}}) cdot f{Large{prime}}(I_7) = (delta_8 cdot w_{78}) cdot f{Large{prime}}(I_7) = 0.0863 cdot (-0.22medspace) cdot 0.908 cdot (1 - 0.908medspace) = -0.0016  delta_6 = 0.086 cdot 0.75 cdot 0.54 cdot (1 - 0.54medspace) = 0.016  delta_5 = (sum_{k}{}{delta_kmedspace w_{jk}}) cdot f{Large{prime}}(I_5) = (delta_7 cdot w_{57} + delta_6 cdot w_{56}) cdot f{Large{prime}}(I_7) = (-0.0016 cdot 3.1 + 0.016 cdot 0.56) cdot 0.39 cdot (1 - 0.39medspace) = 0.001  delta_4 = (-0.0016 cdot 1.05 + 0.016 cdot (-1.4)) cdot 0.86 cdot (1 - 0.86medspace) = -0.003  delta_3 = (-0.0016 cdot 0.34 + 0.016 cdot 2.2) cdot 0.52 cdot (1 - 0.52medspace) = -0.0087

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

      Delta w_{ij} = alpha cdot delta_j cdot O_i + gamma cdot Delta w_{ij}^{t - 1}

      Как вы помните, Delta w_{ij}^{t — 1} — это величина поправки для данного веса на предыдущей итерации. Но поскольку у нас это первый проход, то данное значение будет нулевым, соответственно, в данном случае второе слагаемое отпадает. Но забывать о нем нельзя. Продолжаем калькулировать:

      Delta w_{78} = alpha cdot delta_8 cdot O_7 = 0.3 cdot 0.0863 cdot 0.908 = 0.0235  Delta w_{68} = 0.3 cdot 0.0863 cdot 0.54= 0.014  Delta w_{57} = alpha cdot delta_7 cdot O_5 = 0.3 cdot (−0.0016medspace) cdot 0.39= -0.00019  Delta w_{47} = 0.3 cdot (−0.0016medspace) cdot 0.86= -0.0004  Delta w_{37} = 0.3 cdot (−0.0016medspace) cdot 0.52= -0.00025  Delta w_{56} = alpha cdot delta_6 cdot O_5 = 0.3 cdot 0.016 cdot 0.39= 0.0019  Delta w_{46} = 0.3 cdot 0.016 cdot 0.86= 0.0041  Delta w_{36} = 0.3 cdot 0.016 cdot 0.52= 0.0025  Delta w_{25} = alpha cdot delta_5 cdot O_2 = 0.3 cdot 0.001 cdot 0.7= 0.00021  Delta w_{15} = 0.3 cdot 0.001 cdot 0.6= 0.00018  Delta w_{24} = alpha cdot delta_4 cdot O_2 = 0.3 cdot (-0.003medspace) cdot 0.7= -0.00063  Delta w_{14} = 0.3 cdot (-0.003medspace) cdot 0.6= -0.00054  Delta w_{23} = alpha cdot delta_3 cdot O_2 = 0.3 cdot (−0.0087medspace) cdot 0.7= -0.00183  Delta w_{13} = 0.3 cdot (−0.0087medspace) cdot 0.6= -0.00157

      И самый что ни на есть заключительный этап — непосредственно изменение значений весовых коэффициентов:

      w_{78 medspace new} = w_{78} + Delta w_{78} = -0.22 + 0.0235 = -0.1965  w_{68 medspace new} = 0.75+ 0.014 = 0.764  w_{57 medspace new} = 3.1 + (−0.00019medspace) = 3.0998 w_{47 medspace new} = 1.05 + (−0.0004medspace) = 1.0496 w_{37 medspace new} = 0.34 + (−0.00025medspace) = 0.3398 w_{56 medspace new} = 0.56 + 0.0019 = 0.5619  w_{46 medspace new} = -1.4 + 0.0041 = -1.3959  w_{36 medspace new} = 2.2 + 0.0025 = 2.2025  w_{25 medspace new} = -1.5 + 0.00021 = -1.4998  w_{15 medspace new} = 1 + 0.00018 = 1.00018  w_{24 medspace new} = 0.4 + (−0.00063medspace) = 0.39937  w_{14 medspace new} = 2.5 + (−0.00054medspace) = 2.49946  w_{23 medspace new} = 1 + (−0.00183medspace) = 0.99817  w_{13 medspace new} = -1 + (−0.00157medspace) = -1.00157

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

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

      Содержание

      • 1 Обучение как задача оптимизации
      • 2 Дифференцирование для однослойной сети
        • 2.1 Находим производную ошибки
      • 3 Алгоритм
      • 4 Недостатки алгоритма
        • 4.1 Паралич сети
        • 4.2 Локальные минимумы
      • 5 Примечания
      • 6 См. также
      • 7 Источники информации

      Обучение как задача оптимизации

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

      Простая нейронная сеть с двумя входными вершинами и одной выходной

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

      где ошибка.

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

      График ошибки для нейрона с линейной функцией активации и одним тренировочным объектом

      В таком случае, выходное значение нейрона — взвешенная сумма всех его входных значений:

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

      Дифференцирование для однослойной сети

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

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

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

      Для каждого нейрона , его выходное значение определено как

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

      Функция активации нелинейна и дифференцируема. Одна из распространенных функций активации — сигмоида:

      у нее удобная производная:

      Находим производную ошибки

      Вычисление частной производной ошибки по весам выполняется с помощью цепного правила:

      Только одно слагаемое в зависит от , так что

      Если нейрон в первом слое после входного, то — это просто .

      Производная выходного значения нейрона по его входному значению — это просто частная производная функции активации (предполагается что в качестве функции активации используется сигмоида):

      По этой причине данный метод требует дифференцируемой функции активации. (Тем не менее, функция ReLU стала достаточно популярной в последнее время, хоть и не дифференцируема в 0)

      Первый множитель легко вычислим, если нейрон находится в выходном слое, ведь в таком случае и

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

      Если рассмотреть как функцию, берущую на вход все нейроны получающие на вход значение нейрона ,

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

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

      Если собрать все месте:

      и

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

      Алгоритм

      • — скорость обучения
      • — коэффициент инерциальности для сглаживания резких скачков при перемещении по поверхности целевой функции
      • — обучающее множество
      • — количество повторений
      • — функция, подающая x на вход сети и возвращающая выходные значения всех ее узлов
      • — количество слоев в сети
      • — множество нейронов в слое i
      • — множество нейронов в выходном слое
      fun BackPropagation:
      init 
      repeat :
      for  =  to :
      =  
      for :
      = 
      for  =  to :
      for :
      = 
      for :
      = 
      = 
      return 
      

      Недостатки алгоритма

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

      Градиентный спуск может найти локальный минимум вместо глобального

      Паралич сети

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

      Локальные минимумы

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

      Примечания

      • Алгоритм обучения многослойной нейронной сети методом обратного распространения ошибки
      • Neural Nets
      • Understanding backpropagation

      См. также

      • Нейронные сети, перцептрон
      • Стохастический градиентный спуск
      • Настройка глубокой сети
      • Практики реализации нейронных сетей

      Источники информации

      • https://en.wikipedia.org/wiki/Backpropagation
      • https://ru.wikipedia.org/wiki/Метод_обратного_распространения_ошибки

      Введение

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

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

      Описание работы нейронов

      Перцептрон с тремя слоями по три нейрона

      Перцептрон с тремя слоями по три нейрона

      Каждый нейрон (или узел) принимает сигналы от узлов предыдущего слоя и передает на следующий. Каждая связь между нейронами имеет собственный вес.
      Таким образом, входной сигнал узла 1 слоя 1 передается на узел 1 слоя 2 с коэффициентом w_{11}, на узел 2 слоя 2 с коэффициентом w_{12}и так далее. Все сигналы, полученные узлом уровня 2 складываются. Это его входной сигнал. Таким образом сигналы передаются с уровня на уровень, до выхода.

      Для того, чтобы результат был более предсказуемым, используется функция сглаживания, одна из самых популярных — сигмоида y=1/(1+e^-x).

      Сигмоида y=1/(1+e^-x)

      Сигмоида y=1/(1+e^-x)

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

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

      Схема узла

      Схема узла

      Еще раз, для всего слоя:

      А как это решает задачу?

      Итак, как же применить нейросеть для распознавания букв на картинке?

      Входным сигналом для этой картинки 28 на 28 будет последовательность из 784 чисел от 0 до 255, каждое из которых шифрует цвет соответствующего пикселя. Итак, на входном уровне должно быть 784 узла.
      Информация, которую нам необходимо получить на выходе это «какая цифра скорее всего на картинке». Всего 10 вариантов. Значит, на выходном уровне будет 10 узлов. Узел, на котором сигнал будет больше и будет ответом нейросети на задачу — например, для этой картинки в идеале все узлы выходного уровня должны показывать на выходе ноль, а пятый — единицу.
      Добавим еще уровень, чтобы переход не был таким резким. Допустим, нейросеть будет из трех слоев — 784, 100 и 10 узлов. Общепринятого метода выбора точного количества узлов на промежуточных слоях и количества самих промежуточных слоев не существуют — разве что проводить эксперименты, и сравнивать результаты. В нашем случае первый уровень представляет пиксели входного изображения, третий — распознанные цифры а второй каким-то трудноотслеживаемым образом представляет закономерности, подмножества пикселей, которые свойственны разным цифрам.

      Добавим матрицы

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

      Получение сигнала следующего уровня по формуле X = W*I

      Получение сигнала следующего уровня по формуле X = W*I

      В строках же матрицы весов будут веса связей, ведущих в один узел нового слоя, каждый из которых умножается на вес порождающего его узла. Очень изящно! Разумеется, из-за правил перемножения матриц высота конечного столбца будет равна высоте матрицы весов, а высота матрицы входных сигналов — ширине матрицы весов. Для перехода из первого слоя (784 узла) во второй (100 узлов) в матрице весов нашей задачи понадобится таблица в 100 строк и 784 столбца.
      Итак, вся загадка заключается в том, какими именно значениями заполнена матрица весов. Ее заполнение называется тренировкой нейросети. Затем останется лишь опросить нейросеть, то есть решить конкретную задачу:

      1. Подать на вход картинку, то есть столбец из 784 сигналов.

      2. Умножить на него справа таблицу весов 12.

      3. Применить сигмоиду для сглаживания результатов.

      4. На результат справа умножить таблицу весов 23.

      5. Применить сигмоиду.

      6. Взять номер узла с наибольшим значением.

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

      Тренировка. Обратное распространение ошибок

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

      Один из основных существующих подходов — распределять ошибку пропорционально весам связей.

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

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

      Или то же самое, но для нескольких узлов на внешнем слое:

      Обратное распространение ошибки с нескольких слоев

      Обратное распространение ошибки с нескольких слоев

      Ошибка, то есть e, это разница между t — желаемым значением и o — значением на выходном слое: e = t - o.

      Как мы видим, o1 высчитывается из узлов первого слоя с помощью связей w11 и w12, а значит, именно их и нужно корректировать с помощью ошибки этого узла. Новое значение w11 зависит от доли w11 в сумме связей, ведущих к узлу: frac{w11}{w11+w21}. Конечно, для w21 нужно заменить w11 в числителе на w21.

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

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

      Сложим, получим значение ошибки и просто повторим все еще раз. Пример показан ниже:

      Еще раз, словами: Ошибку Oi умножаем на долю связи w_{ki}/sum_i W_i в сумме связи отдельного узла k со всеми узлами следующего уровня, чтобы получить ошибку узла k предыдущего слоя. Затем все ошибки связей из узла k складываем и получаем его собственную ошибку. И так далее.
      Перепишем все вышесказанное в виде матриц:

      Получение ошибки предыдущего слоя

      Получение ошибки предыдущего слоя

      Удобно. Но не до конца. Было бы здорово избавиться от всех этих отличающихся знаменателей. И деления. совсем не удобно делить тысячу узлов на тысячу разных сумм. Было бы здорово как-то все упростить. Тут нам поможет тот факт, что для написания нейросети позволено забыть математику за третий класс на практике у нас будет больше двух узлов в слое. И значение числителя (например 0,2, 0,4 или 0,9) гораздо важнее, чем значение знаменателя (например 9,7, 9,3 или 8,4). Что приводит нас к моему любимому моменту в книге. Нормирующий множитель? Да зачем он нам?

      Здорово упрощенный расчет ошибки скрытого слоя

      Здорово упрощенный расчет ошибки скрытого слоя

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

      Итоговая формула ОРО

      Итоговая формула ОРО

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

      Тренировка. Обновление весов

      Однако, нужно напомнить, что мы дважды применяем сигмоиду по мере расчета веса узла. Кроме того, представим, что на узле ошибка 0,3. Если мы изменим одну связь, ведущую к этому узлу, то изменение других связей может снова все испортить. А так быть не должно. Интуитивно кажется, что каждая связь должна меняться сообразно своей роли в ошибке. При этом эта роль это не просто доля веса, ведь мы дважды применяли сигмоиду!
      Итак, нам нужно свести ошибку каждого узла к нулю. Ошибка зависит от множества переменных, каждая из которых влияет на результат по разному. Звучит как задача для производной!

      Здесь нам пригодится метод градиентного спуска. Если нам известно, что связь Wij влияет на общую ошибку, то просто посчитаем производную и сделаем шаг в направлении нуля.

      Нам нужно минимизировать ошибку. Значит, наш шаг должен вести нас к нулевой O . Нам нужно узнать соответствующую Wij. И выполнить это для каждой связи в узле, а затем для каждого узла в слое.

      Рассмотрим это для нашей многомерной функции: нам нужно узнать такие значения W (такие координаты, только не на двумерной плоскости, а во множестве измерений. Но это не сильно все осложнить), чтобы значение O было минимальным (спуститься в самую глубокую яму на карте).
      Напомню, что нужно делать все более мелкие шаги, чтобы не пройти центр «ямы». Для этого нужен специальный коэфициент, убывающий во время обучения.
      Также вы можете подумать, что легко можно забрести в неправильную «яму», то есть ложный минимум:

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

      Как на самом деле посчитать ошибку

      Для начала, вспомним, что ошибиться можно в обе стороны. А значит, значения ошибок будут как отрицательные, так и положительные. Тогда сумма ошибок может оказаться не тем больше, чем больше ошибки, а просто близкой к нулю. Значит, e = t-o как значение ошибки использовать нельзя. Приходит на ум модуль: e = |t-o|, чтобы избежать отрицательных значений. Однако тогда функция будет вести себя странно в районе нуля. Лучший вариант из всех для оценки ошибки это  e=(t-o)^2.
      Теперь, когда мы исправили проблему подсчета ошибок, попробуем посчитать производную.dE/dWij. Это выражение представляет изменение значения ошибки при изменении веса узла.

      Перепишем функцию оценки ошибки:

      Е это показатель суммы всех ошибок.

      Е это показатель суммы всех ошибок.

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

      Упрощенное представление функции

      Упрощенное представление функции

      Воспользуемся цепным правилом дифференцирования сложных функций:

      Теперь мы можем работать с частями этого уравнения по отдельности:

      frac{dE}{do_k} = frac{d(t_k-o_k)^2}{{do_k}} = -2(t_k-o_k)

      В осталось разобраться со второй частью, а первую подставим в общее уравнение:

      Перепишем выходной сигнал o_kв явном виде:

      Мы заменили выходной сигнал на сумму произведений каждой из связей, ведущих к этому узлу, на вес узла-источника.

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

      Применим это к нашей формуле и получим:

      Обратите внимание на то, что в последней формуле появился сомножитель (Одна из смущающих вещей в редактуре учебника. В этой формуле j под суммой и j в остальных местах, разумеется, разные, хотя оба представляют предыдущий уровень, так что здесь я обозначу индекс под суммой i. j находится в промежутке от 0 до i.):

      o_j=frac{d(sum_iw_{ik}*o_i)}{dw_{jk}}.

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

      frac{d(sum_iw_{ik}*o_i)}{dw_{jk}} = frac{d(w_{jk}*o_j)}{dw_{jk}} = o_j * w_{jk}^0 = o_j.

      Таким образом, окончательный вид функции для изменения узлов предпоследнего слоя это (еще раз, помните, что на картинках j под суммой это не тот же j, что и снаружи):

      Градиент функции ошибки связей с выходным слоем

      Градиент функции ошибки связей с выходным слоем

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

      Градиент функции ошибки связей со скрытым слоем

      Градиент функции ошибки связей со скрытым слоем

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

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

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

      Эту формулу будет удобно использовать в коде.

      Разбор примера обновления коэфициентов

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

      Возьмем конкретно первый узел выходного слоя, где e_1=0,8.Мы хотим обновить весовой коэффициент w_{11} для связи между последними двумя слоями. Вспомним формулу градиента ошибки:

      Вместо (t_k-o_k)подставим нашу ошибку e_1=0,8.

      Сумма sum _jw_{jk}o_jв данном случае равна (2,0*0,4) + (3,0*0,5) = 2,3.

      Сигмоида 1/(1+e^{-2,3}) = 0,909.

      Сигнал o_1=0,4.

      Следовательно, все значение в целом составит -0,8*0,909*(1-0,909)*0,4 = -0,02647.
      Допустим, что коэффициент обучения составляет 0,1. Тогда изменение веса составит -0,1*(-0,02647)=+0,002647. Это и есть тот довесок, который нам нужно добавить в связь w_{11}. Новое значение ее веса составит 2,002647.
      Еще несколько тысяч таких изменений и чаша наша полна.

      Пара слов о подготовке данных

      График нашей сигмоиды

      График нашей сигмоиды

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

      Для работы нейросети необходимо указать начальные значения весовых коэффициентов. Причем, это не могут быть нули или просто одинаковые значения — в таких условиях они получат одинаковые правки и останутся совпадающими после обучения, что явно не даст нам хороших результатов. Остается указать случайные значения в приемлемом диапазоне, например от -1,0 до +1,0. Однако очевидно, что если значения весов в начале обучения будут близки к максимальным, то нейросеть может быстро насытиться. Это рассуждение, подкрепленное наблюдениями, породило эмпирическое правило: весовые коэффициенты должны выбираться из диапазона, приблизительно оцененного обратной величиной корня из количества связей, ведущих к узлу. Если к узлу ведут 3 связи, его вес должен быть случайным значением в промежутке (0;frac{1}{sqrt3}).

      Итоги

      Эта статья, представляющая конспект-пересказ книги Тарика Рашида «Создай свою нейросеть» призвана объяснить некоторые детали того, как проектируется и работает простой перцептрон и обратное распространение ошибок в нем. Я написал ее для того, чтобы охватить всю картину вместе, однако даже после столь внимательного погружения в материал и прояснения каждой его части я не уверен, что смогу написать что-то похожее, например распознавалку знаков, без заглядывания в книгу. Однако, я намного ближе к этому, чем какое-то время назад.
      Я надеюсь, что эта статья поможет таким же как я новичкам в мире нейросетей, кто не понял все аспекты процесса с первого раза и забросил книгу на пару лет.

      Я приветствую критику как от них, так и от всех остальных, касательно фактических ошибок, стиля подачи материала, неточностей, упущений и других проблем статьи, которые, я уверен, найдутся, поскольку раньше я ничего подобного не писал.
      Спасибо вам всем!

      Понравилась статья? Поделить с друзьями:
    • Обратное распространение ошибки и градиентный спуск
    • Обратное распространение ошибки для сверточной нейронной сети
    • Обратное распространение ошибки во времени
    • Обратное распространение ошибки python код
    • Обратно робин бежал еще быстрее есть ли грамматическая ошибка