Содержание:
Со временем вы сможете намного эффективнее писать обратные проходы, даже для сложных схем и для всего сразу. Давайте немного попрактикуемся в создании обратного распространения ошибки на нескольких примерах. В дальнейшем мы просто будем использовать такие переменные, как a,b,c,x, а их градиенты назовем da,db,dc,dx соответственно. Опять же, мы представляем переменные в качестве «прямого потока», а их градиенты в качестве «обратного потока» вдоль каждой линии. Нашим первым примером был логический элемент *:
В вышеприведенном коде я допускаю, что нам дана переменная dx, которая приходит откуда-то сверху по схеме, пока мы выполняем обратное распространение ошибки (или в ином случае она по умолчанию равна +1). Я выписываю ее, так как я хочу четко показать, как градиенты связанны между собой. Обратите внимание, что в уравнениях логический элемент * выступает в качестве переключателя в процессе обратного прохода. Он запоминает, какими были его исходные значения, и градиенты по каждому значению будут равны другому в процессе обратного прохода. И тогда, конечно, нам придется умножить его на стоящий выше градиент, что представляет собой цепное правило. Вот так выглядит логический элемент + в сжатой форме:
Где 1.0 является локальным градиентом, а умножение представляет собой наше цепное правило. А как же быть с прибавлением трех чисел?:
Вы же понимаете, что происходит? Если вы помните схему обратного потока, логический элемент + просто берет градиент сверху и направляет его в равной степени на все его исходные значения (так как его локальный градиент всегда равен просто 1 для всех его исходных значений, вне зависимости от их фактических значений). Поэтому мы можем сделать это намного быстрее:
Хорошо, а как насчет комбинирования логических элементов?:
Если вы не понимаете, что происходит выше, давайте введем временную переменную q=a*b, после чего вычислим x=q+c, чтобы все проверить. А вот и наш нейрон, поэтому давайте все рассчитаем в два этапа:
Я надеюсь, тут что-то проясняется. А теперь, как насчет такого:
Можно рассмотреть данный пример, как значение a, движущееся в сторону логического элемента *, но линия разделяется и представляет собой оба исходных значения. Это на самом деле просто, так как обратный поток градиентов всегда прибавляется. Другими словами, ничего не меняется:
// более краткая форма выглядит следующим образом:
Я надеюсь вы помните, что производная функции f(a) = a^2 равна 2а, что является именно тем значением, которое мы получаем, если представляем его в виде линии, разделяющейся на два исходных значения логического элемента.
Давайте посмотрим на другой пример:
Хорошо, а теперь давайте немного усложним:
Когда более сложные случаи попадаются на практике, я люблю разделять выражение на удобные в решении части, которые практически всегда состоят из более простых выражений, после чего я связываю их с помощью цепного правила:
Это было несложно. Это уравнения обратного распространения ошибки для всего выражения, и мы выполнили его по кусочкам, а потом выполнили обратное распространение для всех переменных. Опять же обратите внимание на то, что для каждой переменной в процессе прохода вперед у нас была эквивалентная переменная при обратном проходе, которая содержит градиент по отношению к окончательному результату схемы. Вот еще несколько полезных функций и их локальные градиенты, которые могут пригодиться на практике:
Надеюсь, вы понимаете, что мы разбиваем выражения, выполняем проход вперед, после чего для каждой переменной (например, a) мы ищем производную ее градиента da при движении в обратном направлении, по очереди, применяя простые локальные градиенты и связывая их с верхними градиентами. Вот еще один пример:
Здесь очень простое выражение становится трудночитаемым. Функция max передает исходное значение, которое было наибольшим, и игнорирует остальные. При обратном проходе, логический элемент max просто возьмет градиент сверху и направит его к исходному значению, которое фактически протекало через него при проходе вперед. Логический элемент выступает в качестве простого переключателя на основании того, какой исходный элемент имел наибольшее значение при проходе вперед. Прочие исходные значения будут иметь нулевой градиент. Именно это означает ===, так как мы проверяем, какое исходное значение было фактически максимальным, и всего лишь направляем градиент к нему.
Наконец, давайте посмотрим на нелинейность выпрямленного линейного элемента(Rectified Linear Unit или ReLU), о котором вы наверняка слышали. Он используется в нейронных сетях вместо сигмоидной функции. Он просто переступает порог на нуле:
Другими словами, этот логический элемент просто передает значение, если оно больше 0, или останавливает поток и устанавливает его равным нулю. При обратном проходе логический элемент будет передавать градиент сверху, если он активировался в процессе прохода вперед, или, если оригинальное исходное значение было ниже нуля, он остановит поток градиента.
На этом моменте я остановлюсь. Я надеюсь, вы немного разобрались в том, как можно вычислять целые выражения (которые состоят из множества логических элементов) и как можно вычислять обратное распространение ошибки для каждого из них.
Все, что мы проделали в этой главе, сводится к следующему: Мы поняли, что мы можем ввести какое-либо исходное значение через сложную произвольную схему с реальными значениями, подтолкнуть конец схемы с некоторой силой, а обратное распространение ошибки передаст этот толчок на всю схему обратно до исходных значений. Если исходные значения незначительно реагируют вдоль обратного направления их толчка, схема «подастся» немного в сторону изначального направления натяжения. Возможно, это не так уж и очевидно, но такой алгоритм является мощным молотом для обучения машины.
Глава 1: Схемы реальных значений
Часть 1:
Часть 2:
Часть 3:
Часть 4:
Часть 5:
Часть 6:
Введение
Базовый сценарий: Простой логический элемент в схеме
Цель
Стратегия №1: Произвольный локальный поиск
Часть 2:
Стратегия №2: Числовой градиент
Часть 3:
Стратегия №3: Аналитический градиент
Часть 4:
Схемы с несколькими логическими элементами
Обратное распространение ошибки
Часть 5:
Шаблоны в «обратном» потоке
Пример "Один нейрон"
Часть 6:
Становимся мастером обратного распространения ошибки
Глава 2: Машинное обучение
Со временем вы сможете намного эффективнее писать обратные проходы, даже для сложных схем и для всего сразу. Давайте немного попрактикуемся в создании обратного распространения ошибки на нескольких примерах. В дальнейшем мы просто будем использовать такие переменные, как a,b,c,x, а их градиенты назовем da,db,dc,dx соответственно. Опять же, мы представляем переменные в качестве «прямого потока», а их градиенты в качестве «обратного потока» вдоль каждой линии. Нашим первым примером был логический элемент *:
var x = a * b;
// и учитывая градиент по x (dx), мы видим, что при помощи обратного распространения ошибки мы можем рассчитать следующее:
var da = b * dx;
var db = a * dx;
В вышеприведенном коде я допускаю, что нам дана переменная dx, которая приходит откуда-то сверху по схеме, пока мы выполняем обратное распространение ошибки (или в ином случае она по умолчанию равна +1). Я выписываю ее, так как я хочу четко показать, как градиенты связанны между собой. Обратите внимание, что в уравнениях логический элемент * выступает в качестве переключателя в процессе обратного прохода. Он запоминает, какими были его исходные значения, и градиенты по каждому значению будут равны другому в процессе обратного прохода. И тогда, конечно, нам придется умножить его на стоящий выше градиент, что представляет собой цепное правило. Вот так выглядит логический элемент + в сжатой форме:
var x = a + b;
// ->
var da = 1.0 * dx;
var db = 1.0 * dx;
Где 1.0 является локальным градиентом, а умножение представляет собой наше цепное правило. А как же быть с прибавлением трех чисел?:
// давайте вычислим x = a + b + c в два этапа:
var q = a + b; // логический элемент 1
var x = q + c; // логический элемент 2
// обратный проход:
dc = 1.0 * dx; // обратное распространение ошибки первого логического элемента
dq = 1.0 * dx;
da = 1.0 * dq; // обратное распространение ошибки второго логического элемента
db = 1.0 * dq;
Вы же понимаете, что происходит? Если вы помните схему обратного потока, логический элемент + просто берет градиент сверху и направляет его в равной степени на все его исходные значения (так как его локальный градиент всегда равен просто 1 для всех его исходных значений, вне зависимости от их фактических значений). Поэтому мы можем сделать это намного быстрее:
var x = a + b + c;
var da = 1.0 * dx; var db = 1.0 * dx; var dc = 1.0 * dx;
Хорошо, а как насчет комбинирования логических элементов?:
var x = a * b + c;
// учитывая dx, обратное распространение ошибки за один цикл будет выглядеть следующим образом =>
da = b * dx;
db = a * dx;
dc = 1.0 * dx;
Если вы не понимаете, что происходит выше, давайте введем временную переменную q=a*b, после чего вычислим x=q+c, чтобы все проверить. А вот и наш нейрон, поэтому давайте все рассчитаем в два этапа:
// давайте создадим наш нейрон в два этапа:
var q = a*x + b*y + c;
var f = sig(q); // sig – это сигмоидная функция
// а теперь обратный проход, мы получаем df, и:
var dq = (q * (1 - q)) * df;
// а теперь мы связываем его с исходными значениями
var da = x * dq;
var dx = a * dq;
var dy = b * dq;
var db = y * dq;
var dc = 1.0 * dq;
Я надеюсь, тут что-то проясняется. А теперь, как насчет такого:
var x = a * a;
var da = //???
Можно рассмотреть данный пример, как значение a, движущееся в сторону логического элемента *, но линия разделяется и представляет собой оба исходных значения. Это на самом деле просто, так как обратный поток градиентов всегда прибавляется. Другими словами, ничего не меняется:
var da = a * dx; // градиент a из первой ветви
da += a * dx; // и прибавляется к градиенту из второй ветви
// более краткая форма выглядит следующим образом:
var da = 2 * a * dx;
Я надеюсь вы помните, что производная функции f(a) = a^2 равна 2а, что является именно тем значением, которое мы получаем, если представляем его в виде линии, разделяющейся на два исходных значения логического элемента.
Давайте посмотрим на другой пример:
var x = a*a + b*b + c*c;
// мы получаем:
var da = 2*a*dx;
var db = 2*b*dx;
var dc = 2*c*dx;
Хорошо, а теперь давайте немного усложним:
var x = Math.pow(((a * b + c) * d), 2); // pow(x,2) возводит в квадрат исходное значение JS
Когда более сложные случаи попадаются на практике, я люблю разделять выражение на удобные в решении части, которые практически всегда состоят из более простых выражений, после чего я связываю их с помощью цепного правила:
var x1 = a * b + c;
var x2 = x1 * d;
var x = x2 * x2; // это идентично вышеуказанному выражению для x
// а теперь с помощью обратного распространения ошибки идем назад:
var dx2 = 2 * x2 * dx; // обратное распространение ошибки в x2
var dd = x1 * dx2; // обратное распространение ошибки в d
var dx1 = d * dx2; // обратное распространение ошибки в x1
var da = b * dx1;
var db = a * dx1;
var dc = 1.0 * dx1; // готово!
Это было несложно. Это уравнения обратного распространения ошибки для всего выражения, и мы выполнили его по кусочкам, а потом выполнили обратное распространение для всех переменных. Опять же обратите внимание на то, что для каждой переменной в процессе прохода вперед у нас была эквивалентная переменная при обратном проходе, которая содержит градиент по отношению к окончательному результату схемы. Вот еще несколько полезных функций и их локальные градиенты, которые могут пригодиться на практике:
var x = 1.0/a; // деление
var da = -1.0/(a*a);
Так может выглядеть деление на практике:
var x = (a + b)/(c + d);
// давайте разложим его поэтапно:
var x1 = a + b;
var x2 = c + d;
var x3 = 1.0 / x2;
var x = x1 * x3; // эквивалент вышеуказанного значения
// а теперь обратное распространение ошибки, опять в обратном порядке:
var dx1 = x3 * dx;
var dx3 = x1 * dx;
var dx2 = (-1.0/(x2*x2)) * dx3; // локальные градиент, как показано выше, и цепное правило
var da = 1.0 * dx1; // и, наконец, к исходным переменным
var db = 1.0 * dx1;
var dc = 1.0 * dx2;
var db = 1.0 * dx2;
Надеюсь, вы понимаете, что мы разбиваем выражения, выполняем проход вперед, после чего для каждой переменной (например, a) мы ищем производную ее градиента da при движении в обратном направлении, по очереди, применяя простые локальные градиенты и связывая их с верхними градиентами. Вот еще один пример:
var x = Math.max(a, b);
var da = a === x ? 1.0 * dx : 0.0;
var db = b === x ? 1.0 * dx : 0.0;
Здесь очень простое выражение становится трудночитаемым. Функция max передает исходное значение, которое было наибольшим, и игнорирует остальные. При обратном проходе, логический элемент max просто возьмет градиент сверху и направит его к исходному значению, которое фактически протекало через него при проходе вперед. Логический элемент выступает в качестве простого переключателя на основании того, какой исходный элемент имел наибольшее значение при проходе вперед. Прочие исходные значения будут иметь нулевой градиент. Именно это означает ===, так как мы проверяем, какое исходное значение было фактически максимальным, и всего лишь направляем градиент к нему.
Наконец, давайте посмотрим на нелинейность выпрямленного линейного элемента(Rectified Linear Unit или ReLU), о котором вы наверняка слышали. Он используется в нейронных сетях вместо сигмоидной функции. Он просто переступает порог на нуле:
var x = Math.max(a, 0)
// обратное распространение ошибки через данный логический элемент будет иметь следующий вид:
var da = a > 0 ? 1.0 * dx : 0.0;
Другими словами, этот логический элемент просто передает значение, если оно больше 0, или останавливает поток и устанавливает его равным нулю. При обратном проходе логический элемент будет передавать градиент сверху, если он активировался в процессе прохода вперед, или, если оригинальное исходное значение было ниже нуля, он остановит поток градиента.
На этом моменте я остановлюсь. Я надеюсь, вы немного разобрались в том, как можно вычислять целые выражения (которые состоят из множества логических элементов) и как можно вычислять обратное распространение ошибки для каждого из них.
Все, что мы проделали в этой главе, сводится к следующему: Мы поняли, что мы можем ввести какое-либо исходное значение через сложную произвольную схему с реальными значениями, подтолкнуть конец схемы с некоторой силой, а обратное распространение ошибки передаст этот толчок на всю схему обратно до исходных значений. Если исходные значения незначительно реагируют вдоль обратного направления их толчка, схема «подастся» немного в сторону изначального направления натяжения. Возможно, это не так уж и очевидно, но такой алгоритм является мощным молотом для обучения машины.