CS231n: обратное распространение ошибки — интуиция, цепное правило и поток градиентов (перевод конспекта)
Полный перевод конспекта Stanford CS231n «Backpropagation, Intuitions»: интерпретация градиента, цепное правило на вычислительных схемах, локальность backprop, сигмоидный нейрон, поэтапные вычисления, накопление градиентов на развилках, шаблоны add/max/mul, градиенты матричных операций.
Введение
Заголовок раздела «Введение»Мотивация. В этом разделе мы выработаем интуитивное понимание обратного распространения ошибки (backpropagation) — способа вычислять градиенты выражений рекурсивным применением цепного правила. Понимать этот процесс и его тонкости критически важно, чтобы эффективно разрабатывать, проектировать и отлаживать нейронные сети.
Постановка задачи. Основная задача раздела такова: дана функция , где — вектор входов, и мы хотим вычислить градиент в точке (то есть ).
Мотивация. Напомним, что главная причина интереса к этой задаче в том, что для нейронных сетей — это функция потерь (), а входы — обучающие данные и веса нейронной сети. Например, функцией потерь может быть потеря SVM, а входами — и обучающие данные , и веса со сдвигами . Заметьте, что (как обычно в машинном обучении) обучающие данные мы считаем заданными и фиксированными, а веса — переменными, которыми управляем. Поэтому, хотя обратным распространением легко вычислить и градиент по входным примерам , на практике обычно вычисляют только градиент по параметрам (например, ), чтобы с его помощью обновить параметры. Однако, как мы увидим позже, градиент по тоже бывает полезен, например для визуализации и интерпретации того, что делает нейронная сеть.
Если вы уже уверенно выводите градиенты цепным правилом, мы всё равно советуем хотя бы пробежать этот раздел: в нём развивается редко излагаемый взгляд на backprop как на обратный поток в схемах с вещественными значениями, и любые идеи отсюда пригодятся вам на протяжении всего курса.
Простые выражения и интерпретация градиента
Заголовок раздела «Простые выражения и интерпретация градиента»Начнём с простого, чтобы выработать обозначения и соглашения для более сложных выражений. Рассмотрим функцию умножения двух чисел . Частные производные по каждому входу находятся простым дифференцированием:
Интерпретация. Помните, что сообщают производные: скорость изменения функции по данной переменной в бесконечно малой окрестности конкретной точки:
Техническое замечание: знак дроби в левой части, в отличие от правой, — не деление. Эта запись означает, что оператор применяется к функции и возвращает другую функцию (производную). Выражение выше удобно понимать так: при очень малом функция хорошо приближается прямой, а производная — её наклон. Иначе говоря, производная по каждой переменной сообщает чувствительность всего выражения к значению этой переменной. Например, если , то , а производная по равна . Это значит: если чуть-чуть увеличить эту переменную, всё выражение уменьшится (из-за знака минус), причём в три раза сильнее. Это видно, если переписать уравнение выше: . Аналогично, поскольку , увеличение на очень малое тоже увеличит выход функции (знак плюс), причём на .
Производная по каждой переменной сообщает чувствительность всего выражения к её значению.
Как уже говорилось, градиент — вектор частных производных, так что . Хотя формально градиент — вектор, для простоты мы часто будем говорить «градиент по x» вместо формально правильного «частная производная по x».
Можно вывести и производные для сложения:
то есть производная и по , и по равна единице независимо от их значений. Это логично: увеличение любого из увеличит выход , и скорость роста не зависит от самих значений (в отличие от умножения). Последняя функция, которой мы будем много пользоваться, — операция max:
То есть (суб)градиент равен 1 для большего входа и 0 для другого. Интуитивно: если входы , то максимум равен 4, и функция нечувствительна к . Если увеличить на крошечное , функция по-прежнему выдаст 4, поэтому градиент нулевой: эффекта нет. Конечно, если изменить сильно (например, больше чем на 2), значение изменится, но производные ничего не говорят о влиянии таких больших изменений входов: они информативны только для крошечных, бесконечно малых изменений, на что указывает в определении.
Составные выражения и цепное правило
Заголовок раздела «Составные выражения и цепное правило»Перейдём к более сложным выражениям из нескольких вложенных функций, например . Это выражение ещё достаточно простое, чтобы продифференцировать его напрямую, но мы подойдём к нему особым образом, который поможет понять интуицию обратного распространения. Заметим, что выражение раскладывается на два: и . Производные каждого мы уже умеем считать, как в предыдущем разделе. — просто произведение и , так что , а — сумма и , так что . Однако градиент по промежуточному значению нам сам по себе не нужен: значение бесполезно. В конечном счёте нас интересует градиент по её входам . Цепное правило говорит, что правильный способ «сцепить» эти выражения для градиентов — умножение. Например, . На практике это просто произведение двух чисел, хранящих два градиента. Посмотрим на примере:
# задаём входыx = -2; y = 5; z = -4
# прямой проходq = x + y # q становится 3f = q * z # f становится -12
# обратный проход (обратное распространение) в обратном порядке:# сначала через f = q * zdfdz = q # df/dz = q, поэтому градиент по z равен 3dfdq = z # df/dq = z, поэтому градиент по q равен -4dqdx = 1.0dqdy = 1.0# теперь через q = x + ydfdx = dfdq * dqdx # Это умножение и есть цепное правило!dfdy = dfdq * dqdyОстаются градиенты по переменным [dfdx, dfdy, dfdz], которые сообщают чувствительность f к переменным x, y, z!
Это простейший пример обратного распространения. Дальше мы будем пользоваться более краткой записью без префикса df.
Например, будем писать просто dq вместо dfdq и всегда подразумевать, что градиент берётся от итогового выхода.
Это вычисление удобно изобразить в виде схемы:
flowchart LR
X["x<br/>значение −2<br/>градиент −4"] --> ADD(("+"))
Y["y<br/>значение 5<br/>градиент −4"] --> ADD
ADD -->|"q: значение 3, градиент −4"| MUL(("×"))
Z["z<br/>значение −4<br/>градиент 3"] --> MUL
MUL --> F["f<br/>значение −12<br/>градиент 1"]Интуитивное понимание обратного распространения
Заголовок раздела «Интуитивное понимание обратного распространения»Заметьте, что обратное распространение — красиво локальный процесс. Каждый вентиль (gate) схемы получает какие-то входы и сразу может вычислить две вещи: 1) своё выходное значение и 2) локальный градиент своего выхода по своим входам. Вентили делают это совершенно независимо, ничего не зная о деталях всей схемы, в которую встроены. Однако после прямого прохода, во время обратного распространения, вентиль в итоге узнаёт градиент итогового выхода всей схемы по своему выходному значению. Цепное правило говорит, что вентиль должен взять этот градиент и умножить на него каждый градиент, который он обычно вычисляет для своих входов.
Это дополнительное умножение (для каждого входа) по цепному правилу превращает одиночный и сравнительно бесполезный вентиль в шестерёнку сложной схемы — например, целой нейронной сети.
Разберём, как это работает, снова на примере. Вентиль сложения получил входы и вычислил выход 3. Поскольку он вычисляет сложение, его локальный градиент по обоим входам равен . Остальная схема вычислила итоговое значение . При обратном проходе, когда цепное правило рекурсивно применяется по схеме назад, вентиль сложения (вход вентиля умножения) узнаёт, что градиент по его выходу равен . Если очеловечить схему и считать, что она хочет выдать значение побольше (это помогает интуиции), то можно сказать, что схема «хочет», чтобы выход вентиля сложения был меньше (из-за знака минус), причём с силой 4. Чтобы продолжить рекурсию и сцепить градиент, вентиль сложения берёт этот градиент и умножает на него все локальные градиенты по своим входам (градиенты по x и y становятся ). Заметьте, что это даёт нужный эффект: если x, y уменьшатся (в ответ на свой отрицательный градиент), выход вентиля сложения уменьшится, а это, в свою очередь, увеличит выход вентиля умножения.
Итак, обратное распространение можно понимать как общение вентилей друг с другом (через сигнал градиента): хотят ли они, чтобы их выходы увеличились или уменьшились (и насколько сильно), чтобы итоговый выход стал больше.
Модульность: пример с сигмоидой
Заголовок раздела «Модульность: пример с сигмоидой»Введённые выше вентили довольно произвольны. Вентилем может служить любая дифференцируемая функция; можно объединять несколько вентилей в один или раскладывать функцию на несколько вентилей — как удобнее. Рассмотрим ещё одно выражение, иллюстрирующее это:
Как мы увидим позже, это выражение описывает двумерный нейрон (с входами x и весами w) с сигмоидной функцией активации. Но пока будем считать его просто функцией от входов w, x к одному числу. Функция состоит из нескольких вентилей. Кроме уже описанных (сложение, умножение, max), есть ещё четыре:
Функции сдвигают вход на константу и масштабируют его на константу соответственно. Формально это частные случаи сложения и умножения, но мы вводим их как (новые) унарные вентили, поскольку градиенты по константам нам не нужны. Полная схема выглядит так:
flowchart LR
W0["w0: 2.00 / −0.20"] --> M1(("×"))
X0["x0: −1.00 / 0.39"] --> M1
W1["w1: −3.00 / −0.39"] --> M2(("×"))
X1["x1: −2.00 / −0.59"] --> M2
M1 -->|"−2.00 / 0.20"| A1(("+"))
M2 -->|"6.00 / 0.20"| A1
A1 -->|"4.00 / 0.20"| A2(("+"))
W2["w2: −3.00 / 0.20"] --> A2
A2 -->|"1.00 / 0.20"| N(("×−1"))
N -->|"−1.00 / −0.20"| E(("exp"))
E -->|"0.37 / −0.53"| P(("+1"))
P -->|"1.37 / −0.53"| R(("1/x"))
R --> OUT["выход: 0.73 / 1.00"]В этом примере видна длинная цепочка применений функций к результату скалярного произведения w и x. Функция, которую реализуют эти операции, называется сигмоидой . Оказывается, производная сигмоиды по входу упрощается, если её вывести (после забавного трюка — прибавить и вычесть 1 в числителе):
Градиент удивительно упрощается. Например, при прямом проходе выражение с сигмоидой получает вход 1,0 и вычисляет выход 0,73. Вывод выше показывает, что локальный градиент равен просто — как и вычислила схема (см. рисунок выше), только теперь одним простым и эффективным выражением (и с меньшими численными проблемами). Поэтому в любом реальном приложении очень полезно объединять эти операции в один вентиль. Посмотрим на backprop для этого нейрона в коде:
w = [2,-3,-3] # пусть веса и данные случайныеx = [-1, -2]
# прямой проходdot = w[0]*x[0] + w[1]*x[1] + w[2]f = 1.0 / (1 + math.exp(-dot)) # сигмоида
# обратный проход через нейрон (обратное распространение)ddot = (1 - f) * f # градиент по переменной dot — по выведенной формуле градиента сигмоидыdx = [w[0] * ddot, w[1] * ddot] # распространяем в xdw = [x[0] * ddot, x[1] * ddot, 1.0 * ddot] # распространяем в w# готово! у нас есть градиенты по входам схемыСовет по реализации: поэтапное обратное распространение. Как показано в коде выше, на практике всегда полезно
разбивать прямой проход на этапы, через которые легко провести обратное распространение. Например, здесь мы завели
промежуточную переменную dot, хранящую результат скалярного произведения w и x. При обратном проходе мы
последовательно (в обратном порядке) вычисляем соответствующие переменные (например, ddot, а в итоге dw, dx),
хранящие градиенты по этим переменным.
Смысл раздела в том, что детали выполнения обратного распространения и то, какие части прямой функции считать вентилями, — вопрос удобства. Полезно знать, у каких частей выражения простые локальные градиенты, чтобы сцеплять их с наименьшими затратами кода и усилий.
Backprop на практике: поэтапные вычисления
Заголовок раздела «Backprop на практике: поэтапные вычисления»Посмотрим на ещё одном примере. Пусть есть функция вида:
Сразу оговоримся: функция совершенно бесполезна, и непонятно, зачем вообще вычислять её градиент, — разве что она хороший пример обратного распространения на практике. Очень важно подчеркнуть: если взяться дифференцировать её по или напрямую, получатся очень большие и сложные выражения. Но делать это совершенно не нужно: нам не требуется явная формула для градиента — достаточно знать, как его вычислить. Вот как можно организовать прямой проход для такого выражения:
x = 3 # пример значенийy = -4
# прямой проходsigy = 1.0 / (1 + math.exp(-y)) # сигмоида в числителе #(1)num = x + sigy # числитель #(2)sigx = 1.0 / (1 + math.exp(-x)) # сигмоида в знаменателе #(3)xpy = x + y #(4)xpysqr = xpy**2 #(5)den = sigx + xpysqr # знаменатель #(6)invden = 1.0 / den #(7)f = num * invden # готово! #(8)Уф, к концу выражения прямой проход вычислен. Заметьте: мы организовали код так, что в нём много промежуточных
переменных, и каждая — простое выражение, локальные градиенты которого мы уже знаем. Поэтому вычислить обратный проход
легко: идём назад, и для каждой переменной прямого прохода (sigy, num, sigx, xpy, xpysqr, den, invden) заводим
такую же переменную, но с префиксом d, — она будет хранить градиент выхода схемы по этой переменной. Кроме того,
каждый кусок нашего backprop вычисляет локальный градиент выражения и сцепляет его умножением с градиентом по этому
выражению. Для каждой строки отмечено, к какой части прямого прохода она относится:
# backprop f = num * invdendnum = invden # градиент по числителю #(8)dinvden = num #(8)# backprop invden = 1.0 / dendden = (-1.0 / (den**2)) * dinvden #(7)# backprop den = sigx + xpysqrdsigx = (1) * dden #(6)dxpysqr = (1) * dden #(6)# backprop xpysqr = xpy**2dxpy = (2 * xpy) * dxpysqr #(5)# backprop xpy = x + ydx = (1) * dxpy #(4)dy = (1) * dxpy #(4)# backprop sigx = 1.0 / (1 + math.exp(-x))dx += ((1 - sigx) * sigx) * dsigx # Обратите внимание на += ! См. ниже #(3)# backprop num = x + sigydx += (1) * dnum #(2)dsigy = (1) * dnum #(2)# backprop sigy = 1.0 / (1 + math.exp(-y))dy += ((1 - sigy) * sigy) * dsigy #(1)# готово! уфОбратите внимание на несколько вещей.
Кэшируйте переменные прямого прохода. Для обратного прохода очень полезно иметь некоторые переменные, использованные при прямом. На практике код стоит организовать так, чтобы эти переменные кэшировались и были доступны при обратном распространении. Если это слишком сложно, можно (но расточительно) вычислить их заново.
Градиенты складываются на развилках. Прямое выражение использует переменные x, y несколько раз, поэтому при
обратном распространении нужно аккуратно использовать += вместо =, чтобы накапливать градиент по этим переменным
(иначе мы бы его перезаписали). Это следует из цепного правила для функций многих переменных: если переменная
ветвится в разные части схемы, градиенты, текущие к ней обратно, складываются.
Шаблоны обратного потока
Заголовок раздела «Шаблоны обратного потока»Интересно, что во многих случаях обратный поток градиента можно интерпретировать интуитивно. Например, три самых распространённых вентиля нейронных сетей (сложение, умножение, max) имеют очень простые интерпретации того, как они ведут себя при обратном распространении. Рассмотрим пример схемы:
flowchart LR
X["x: 3.00 / −8.00"] --> MUL(("×"))
Y["y: −4.00 / 6.00"] --> MUL
Z["z: 2.00 / 2.00"] --> MAX(("max"))
W["w: −1.00 / 0.00"] --> MAX
MUL -->|"−12.00 / 2.00"| ADD(("+"))
MAX -->|"2.00 / 2.00"| ADD
ADD -->|"−10.00 / 2.00"| TWO(("×2"))
TWO --> OUT["выход: −20.00 / 1.00"]Глядя на схему, видим:
Вентиль сложения всегда берёт градиент по своему выходу и раздаёт его поровну всем входам, независимо от их значений при прямом проходе. Это следует из того, что локальный градиент сложения — просто , так что градиенты по всем входам в точности равны градиенту по выходу: он умножается на 1,0 и не меняется. В примере вентиль + передал градиент 2,00 обоим входам — поровну и без изменений.
Вентиль max направляет градиент. В отличие от сложения, раздающего градиент без изменений всем входам, max передаёт градиент (без изменений) ровно одному входу — тому, у которого при прямом проходе было наибольшее значение. Это потому, что локальный градиент max равен 1,0 для наибольшего значения и 0,0 для всех остальных. В примере max направил градиент 2,00 переменной z, значение которой больше, чем у w, а градиент по w остался нулевым.
Вентиль умножения интерпретировать чуть сложнее. Его локальные градиенты — входные значения (но переставленные местами), и по цепному правилу они умножаются на градиент по выходу. В примере градиент по x равен , то есть .
Неинтуитивные эффекты и их последствия. Если один вход вентиля умножения очень мал, а другой очень велик, вентиль поведёт себя немного неинтуитивно: малому входу он назначит относительно огромный градиент, а большому — крошечный. В линейных классификаторах веса скалярно умножаются () на входы, а значит, масштаб данных влияет на величину градиента по весам. Например, если при предобработке умножить все входные примеры на 1000, градиент по весам станет в 1000 раз больше, и, чтобы это компенсировать, придётся во столько же раз уменьшить скорость обучения. Вот почему предобработка так важна, иногда в неочевидных отношениях! Интуитивное понимание того, как текут градиенты, помогает отлаживать такие случаи.
Градиенты векторизованных операций
Заголовок раздела «Градиенты векторизованных операций»Разделы выше касались отдельных переменных, но все идеи напрямую переносятся на операции с матрицами и векторами. Нужно лишь внимательнее следить за размерностями и транспонированием.
Градиент произведения матриц. Пожалуй, самая хитрая операция — произведение матрицы на матрицу (обобщающее все произведения матрицы на вектор и вектора на вектор):
# прямой проходW = np.random.randn(5, 10)X = np.random.randn(10, 3)D = W.dot(X)
# пусть сверху по схеме пришёл градиент по DdD = np.random.randn(*D.shape) # той же формы, что и DdW = dD.dot(X.T) #.T даёт транспонированную матрицуdX = W.T.dot(dD)Совет: пользуйтесь анализом размерностей! Выражения для dW и dX не нужно запоминать: их легко вывести заново по
размерностям. Например, мы знаем, что градиент по весам dW должен иметь тот же размер, что и W, и должен зависеть
от произведения X и dD (как и в случае, когда X, W — числа, а не матрицы). Всегда есть ровно один способ
получить это так, чтобы размерности сошлись. Например, X имеет размер [10 × 3], dD — [5 × 3]; если нужен dW,
а W имеет форму [5 × 10], то единственный способ — dD.dot(X.T), как показано выше.
Работайте с маленькими явными примерами. Поначалу выводить градиенты некоторых векторизованных выражений бывает трудно. Наш совет: явно выпишите минимальный векторизованный пример, выведите градиент на бумаге, а затем обобщите закономерность до эффективной векторизованной формы.
Эрик Лёрнд-Миллер (Erik Learned-Miller) написал более подробный документ о взятии производных по матрицам и векторам, который может вам пригодиться: он здесь. Ещё подробнее — в нашем переводе конспекта MIT «Матричное исчисление».
- Мы выработали интуицию о том, что означают градиенты, как они текут по схеме назад и как сообщают, какая часть схемы должна увеличиться или уменьшиться и с какой силой, чтобы итоговый выход стал больше.
- Мы обсудили важность поэтапных вычислений для практической реализации обратного распространения. Функцию всегда стоит разбивать на модули, для которых легко вывести локальные градиенты, а затем сцеплять их цепным правилом. Важно, что эти выражения почти никогда не нужно выписывать на бумаге и дифференцировать символьно целиком: явная математическая формула для градиента по входным переменным вам не нужна. Поэтому разложите выражения на этапы так, чтобы каждый этап можно было продифференцировать независимо (этапами будут умножения матрицы на вектор, операции max, суммы и т. д.), а затем проводите обратное распространение через переменные шаг за шагом.
В следующем разделе мы начнём определять нейронные сети, и обратное распространение позволит эффективно вычислять градиент функции потерь по их параметрам. Иначе говоря, теперь мы готовы обучать нейронные сети, и концептуально самая трудная часть курса позади! До свёрточных сетей (перевод конспекта) останется лишь небольшой шаг.
Литература
Заголовок раздела «Литература»Словарик
Заголовок раздела «Словарик»| EN | RU |
|---|---|
| backpropagation | обратное распространение ошибки |
| chain rule, multivariable chain rule | цепное правило, цепное правило для функций многих переменных |
| circuit, gate | схема, вентиль |
| forward pass, backward pass | прямой проход, обратный проход |
| local gradient | локальный градиент |
| staged computation | поэтапные вычисления |
| gradients add up at forks | градиенты складываются на развилках |
| dimension analysis | анализ размерностей |