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

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

Здравствуйте и вновь добро пожаловать на занятия по теме «Обработка данных: глубокое обучение на языке Python».

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

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

J = - \sum_{n} t_n \;log \; y_n +(1-t_n) log (1- y_n).

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

Найдём производную от J по w:

w\leftarrow w - \eta \frac {\partial J}{\partial w},

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

w\leftarrow w + \eta \frac {\partial L}{\partial w}.

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

\frac{df}{{dt}} = \frac{\partial f}{\partial x} \frac{dx}{dt} + \frac{\partial f}{\partial y} \frac {dy}{dt}.

В случае вектора xk(t), имеющего k компонент и зависящего от t, мы, как можно догадаться, делаем то же самое, но с использованием суммирования:

\frac{df}{{dt}} = \sum_{k} \frac{\partial f}{\partial x_k} \frac {dx_k}{dt}.

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

P = \prod_{{n=1}}^{N} \prod_{k=1}^{6} G ^{nt^n_k}_{k},

где t принимает значение 0 или 1 в зависимости от того, выпал ли кубик нужной гранью или нет.

В нейронной сети всё совершенно так же, только вместо 6 граней у нас есть k классов:

P (Y|X) = \prod_{{n=1}}^{N} \prod_{k=1}^{K} y ^{nt^n_k}_{k}.

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

Найдём логарифм функции правдоподобия:

L = \sum_{n} \;\sum_{k} t^n_k \;log \;y^n_k.

Итак, у нас есть целевая функция. Что же нам с ней делать? Суть та же, что и в случае логистической регрессии – нам надо найти производную.

В случае нейронной сети с одним скрытым слоем у нас есть два набора весовых коэффициентов. Обозначим их через W и V. Предположим также, что размерности слоёв равны D, M и K. Нам необходимо найти производные  \frac{\partial J}{{\partial V_{mk}}} и \frac{\partial J}{{\partial W_{dm}}}. Поскольку у нас метод именно обратного распространения, то сначала мы найдём \frac{\partial J}{{\partial V_{mk}}} , потому что она «справа», а затем обратно распространим ошибку и найдём \frac{\partial J}{{\partial W_{dm}}}. Сделать всё это мы можем при помощи цепного правила:

\frac{\partial J}{{xV_{mk}}} = \sum_{n} \; \sum_{k'} t^n_k' \frac {1}{y^n_k'} \frac {\partial y^n_k'} {\partial V_{mk}}.

Обратите внимание, что в правой части уравнения стоит k, поскольку это переменная суммирования и вовсе не то же самое, что и k, стоящее в левой части.

Итак, мы применили цепное правило. Вопрос теперь в том, как нам найти  Другой насущный вопрос – как найти производную функции софтмакс? Давайте сначала выпишем уравнение софтмакс:

y_k = \frac {e^{a_k}} {{\sum_j} \;e^{a_j}},

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

a_k = V^T_k z.

Её можно переписать и в скалярной форме с помощью суммы:

a_k = \sum_m V_{mk}Z_m.

Теперь найдём производную от софтмакс, а о скалярном произведении побеспокоимся чуть позже. Получим:

\frac{\partial y_k}{{\partial a_k}} = y_k (1 - y_k), if \;k = k'

\frac{\partial y_k}{{\partial a_k}} = -y_k y_k, if \;k \neq k'

Мы можем всё это совместить с помощью символа Кронекера. Напомним его определение:

\delta_{ij} = 1,if \; i=j

\delta_{ij} = 0,if \; i \neq j.

Тогда можем записать:

\frac{\partial y_k}{\partial a_k} = y_k (\delta_{kk} - y_k).

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

\frac{\partial a_k}{\partial V m_k} = z_m.

Объединяя всё это, можем записать:

\frac{\partial J}{\partial V_{m_k}} = \sum_n (t^n_k - y^n_k)z_m.

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

Метод обратного распространения ошибки. От чего зависит изменение весовых коэффициентов

В этой лекции мы продолжим наш разговор о методе обратного распространения ошибки.

Мы должны найти производную, касающуюся весовых коэффициентов, связывающих входной слой со скрытым, то есть \frac{\partial J}{{\partial V_{mk}}} .

Для этого используем методику, аналогичную прошлой:

\frac{\partial J}{\partial W_{dm}} = \sum_{n} \; \sum_{k} (t^n_k - y^n_k) V_{mk}.

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

 \sum_k (t^n_k - y^n_k) V_{mk} = \frac{\partial J}{\partial z_m}.

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

\frac{\partial J}{\partial W_{dm}} = \sum_{n} \; \sum_{k} (t^n_k - y^n_k) V_{mk} \;Z^n_m (1 - Z^n_m) x^n_d,

где z_m = \sigma (W^T_m X).

При этом

z^n_m (1 - z^n_m) x^n_d = \frac{\partial z_m}{\partial W_{dm}}.

Обратите внимание, что индексы, находящиеся в левой части уравнения, отличаются от индексов, находящихся в правой части, а суммирование у нас идёт по n и k.

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

W_{dm} = \sum_{k} (t_k, y_k, V, z_m,x_d).

Именно это и имеется в виду, когда говорят, что ошибка обратно распространяется.

Метод обратного распространения ошибки. Рекурсивность

На этом занятии мы рассмотрим более глубокие сети, чем сети с одним скрытым слоем, которые мы рассматривали ранее, и покажем рекурсивность метода обратного распространения. Нарисуем более глубокую сеть и пусть K будет у нас исходящим слоем, S, R, Q – скрытыми слоями, D – входным слоем. Весовые коэффициенты обозначим через w3, w2, w1 и w0. Мы уже знаем, как описать последний слой весовых коэффициентов. Не забывайте, что у нас метод обратного распространения, поэтому мы двигаемся справа налево. Скрытые слои обозначим через z1, z2 и z3, а входные и исходящие переменные обозначим, как обычно, через x и y.

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

\frac{\partial J}{\partial w^3_{sk}} = (t_k - y_k) z^3_s.

Затем мы применяем обратное распространение до w2. Мы уже знаем, что тут появится суммирование:

\frac{\partial J}{\partial w^2_{rs}} = \sum_k (t_k - y_k) w^3_{sk} z^3_s (1 - z^3_s) z^2_r.

Мы видим повтор – выражение  было и в предыдущем уравнении.

Далее найдём производную по w1. Теперь у нас будет суммирование и по s:

\frac{\partial y}{\partial w^1_{qr}} = \sum_k \sum_s (t_k - y_k) w^3_{sk} z^3_s (1 - z^3_s) w^2_{rs} z^2_r (1 - z^2_r) z^1_q.

Фактически одинаковая картина будет повторяться по мере обратного распространения. В каждом слое будет всё больше и больше повторений – сначала затем  и так далее.

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

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

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

import numpy as np

import matplotlib.pyplot as plt

def forward(X, W1, b1, W2, b2):

Z = 1 / (1 + np.exp(-X.dot(W1) – b1))

A = Z.dot(W2) + b2

expA = np.exp(A)

Y = expA / expA.sum(axis=1, keepdims=True)

return Y, Z

def classification_rate(Y, P):

n_correct = 0

n_total = 0

for i in xrange(len(Y)):

n_total += 1

if Y[i] == P[i]:

n_correct += 1

return float(n_correct) / n_total

Теперь определим основную функцию. Её задача будет состоять в создании данных. Это очень похоже на то, что мы уже делали, но с некоторыми дополнениями. Так, мы введём переменную N, представляющую длину Y, а также преобразуем целевые переменные в показательные, поскольку они равны либо 0, либо 1, тогда как Y представляет собой классы от 0 до K-1. Кроме того, на случай, если вы уже подзабыли, как выглядят наши данные, мы вновь отобразим их графически.

def main():

Nclass = 500

D = 2

M = 3

K = 3

X1 = np.random.randn(Nclass, D) + np.array([0, -2])

X2 = np.random.randn(Nclass, D) + np.array([2, 2])

X3 = np.random.randn(Nclass, D) + np.array([-2, 2])

X = np.vstack([X1, X2, X3])

Y = np.array([0]*Nclass + [1]*Nclass + [2]*Nclass)

N = len(Y)

T = np.zeros((N, K))

for i in xrange(N):

T[i, Y[i]] = 1

plt.scatter(X[:,0], X[:, 1], c=Y, s=100, alpha=0.5)

plt.show()

if __name__ == ‘__main__’:

main()

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

W1 = np.random.randn(D, M)

b1 = np.random.randn(M)

W2 = np.random.randn(M, K)

b2 = np.random.randn(K)

Коэффициент обучения установим равным 10-7, а количество циклов – равным 100 000. Обратите внимание, что теперь функция forward возвращает не только исходящую переменную, но и значения в скрытом слое. После каждых 100 циклов мы будем рассчитывать значение функции затрат – мы ещё не написали код для этой функции, но напишем, – а также будем выводить прогноз. Всё это будет использовано для расчёта коэффициента классификации.

learning_rate = 10e-7

costs = []

for epoch in xrange(100000):

output, hidden = forward(X, W1, b1, W2, b2)

if epoch % 100 == 0:

c = cost(T, output)

P = np.argmax(output, axis=1)

r = classification_rate(Y, P)

print ‘’cost:’’, c, ‘’classification_rate:’’, r

costs.append(c)

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

W2 += learning_rate * derivative_w2(hidden, T, output)

b2 += learning_rate * derivative_b2(T, output)

W1 += learning_rate * derivative_w1(X, hidden, T, output, W2)

b1+= learning_rate * derivative_b2(T, output, W2, hidden)

И наконец, когда всё это уже сделано, мы можем вывести на экран значение функции затрат.

plt.plit(costs)

plt.show()

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

def cost(T, Y):

tot = T * np.log(Y)

return tot.sum

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

def derivative_w2(Z, T, Y):

N, K = T.shape

M = Z.shape[1]

ret1 = np.zeros((M, K))

for n in xrange(N):

for m in xrange(M):

for k in xrange(K):

ret1[m,k] += (T[n,k] – Y[n,k])*Z[n,m]

return ret1

Далее напишем функцию для нахождения производных относительно b2 и W1.

def derivative_b2(T, Y):

return (T – Y).sum(axis=0)

def derivative_w1(X, Z, T, Y, W2):

N, D = X.shape

M, K = W2.shape

ret1 = np.zeros((D, M))

for n in xrange(N):

for k in xrange(K):

for m in xrange(M):

for d in xrange(D):

ret1[d,m] += (T[n,k] – Y[n,k])*W2[m,k]*Z[n,m]*(1 – Z[n.m])*X[n,d]

return ret1

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

def derivative_b1(T, Y, W2, Z):

return ((T – Y).dot(W2.T) * Z *(1 – Z)).sum(axis=0)

Запустим программу.

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

def derivative_w2(Z, T, Y):

N, K = T.shape

M = Z.shape[1]

ret1 = np.zeros((M, K))

for n in xrange(N):

for m in xrange(M):

for k in xrange(K):

ret1[m,k] += (T[n,k] – Y[n,k])*Z[n,m]

ret2 = np.zeros((M, K))

for n in xrange(N):

for k in xrange(K):

ret2[:,k] += (T[n,k] – Y[n,k])*Z[n,:]

assert(np.abs(ret1 – ret2).sum() < 10e-10)

return ret1

Запустим программу ещё раз. Получается медленно, поэтому уберём всю часть с циклами.

def derivative_w2(Z, T, Y):

N, K = T.shape

M = Z.shape[1]

ret2 = np.zeros((M, K))

for n in xrange(N):

for k in xrange(K):

ret2[:,k] += (T[n,k] – Y[n,k])*Z[n,:]

ret3 = np.zeros((M, K))

for n in xrange(N):

ret3 += np.outer(Z[n], T[n] – Y[n])

assert(np.abs(ret2 – ret3).sum() < 10e-10)

return ret3

Попробуем снова. Всё работает. Но мы можем ещё более упростить код.

def derivative_w2(Z, T, Y):

N, K = T.shape

M = Z.shape[1]

ret3 = np.zeros((M, K))

for n in xrange(N):

ret3 += np.outer(Z[n], T[n] – Y[n])

ret4 = Z.T.dot(T – Y)

assert(np.abs(ret4 – ret3).sum() < 10e-10)

return ret3

Попробуем ещё раз. Всё работает, и даже, кажется быстрее. Но мы можем всё переписать ещё проще.

def derivative_w2(Z, T, Y):

N, K = T.shape

M = Z.shape[1]

return Z.T.dot(T – Y)

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

def derivative_w1(X, Z, T, Y, W2):

dZ = (T – Y).dot(W2.T) * Z * (1 – Z)).sum(axis=0)

return X.T.dot(dZ)

Запустим программу ещё раз. Заметьте, насколько увеличилась скорость её выполнения.

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

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

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

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

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

\delta^3 (k) = y(k) - t (k),

который мы рассматриваем как ошибку в узле. В этом заключается первый вопрос – почему именно он является ошибкой? Почему не какое-то другое выражение? Объяснение недостаточно чёткое.

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

\triangle V (j, k) = z(j)\delta^3 (k).

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

Следующее – собственно метод обратного распространения ошибок. Мы видим, что ошибка распространяется обратно, поскольку δ2(j) предыдущего слоя зависит от δ3(k) предыдущего слоя:

\delta^2 (j) = \sum_k [y(k) - t(k)] V (j,k) = \sum_k \delta^3 (k) V (j,k).

Но, опять же, почему именно так? У вас не получится разобраться, просто приняв всё на веру.

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

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

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

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

Краткие итоги раздела обучения

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

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

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

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

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

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

Понравилась статья? Поделить с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: