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

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

Операция, изображенная выше, имеет два матричных входа: W и X с размерами [d, m] и [d, n] соответственно. Из основ линейной алгебры мы знаем, что форма вывода Z должна быть [m, n]. Все идет нормально.

Пришло время посмотреть, как градиенты текут в обратном направлении по графику.

Первое наблюдение, которое необходимо сделать, состоит в том, что градиент нашей функции стоимости J (какой бы ни была сама функция на самом деле) по отношению к матрице любой формы всегда имеет ту же форму, что и эта матрица. Это может показаться очевидным, но это очень важно, так что вы увидите, как я упомяну об этом пару раз в этом посте. Чтобы рассчитать ∂J/∂W и ∂J/∂X, теперь нам нужно выполнить два шага. :

  1. Вычислить локальные градиенты, например ∂WX/∂W и ∂WX /∂X,
  2. Примените цепное правило, т. е. умножьте локальные градиенты на восходящий градиент ∂J/∂Z.

Сначала сосредоточимся на первом шаге. Как оказалось, основные правила одномерного исчисления произведения, которые вы выучили в школе, по-прежнему применимы и здесь, хотя и с небольшой поправкой — векторы и матрицы можно перемножать несколькими способами. Еще в школе вы, возможно, узнали, что производная функции f(x)=ax равна fʹ(x)=a. Не беспокоясь о выводе этого с использованием формального определения, результат имеет интуитивно понятный смысл. Помните, что fʹ(x) сообщает нам, как изменится значение f, если мы изменим x на бесконечно малую величину. В приведенном выше случае увеличение x на h увеличит f(x) в ah раз. Как упоминалось выше, в линейной алгебре существует (по крайней мере) два способа вычисления произведения двух векторов (или матриц): поэлементное произведение uv и скалярное произведение uv. Хотя математически эти два метода совершенно различны, аналогия с одномерным исчислением распространяется на них обоих. На самом деле, в случае поэлементного произведения это выглядит примерно так же:

∂(uv)/v=u

∂(uv)/u=v

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

∂(uv)/v=u

∂(uv)/u=v

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

Для второго шага вычисления давайте воспользуемся примером кода Python/numpy.

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

Следующим важным битом является строка 12, где мы выполняем скалярное произведение (начиная с Python 3.5 мы можем использовать для этого оператор @) и добавляем термин смещения b. Поскольку b имеет размеры [m,1] и WX равно [m,n], numpy будет транслировать bна [ m,n] путем размещения m копий b рядом друг с другом. Хотя операция не вызывает затруднений, у нее есть свои разветвления, когда дело доходит до расчета градиента, так что обратите на это внимание.

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

Давайте теперь посмотрим на код для обратного прохода.

Градиент целевой функции относительно H в строке 2 является прямым — одномерное правило (xⁿ)ʹ = nx ⁿ⁻¹ из школы. Мы также знаем, что формы H и ∂J/∂H должны быть одинаковыми, что тривиально выполняется в данном случае.

Чтобы вычислить ∂J/∂Zв строке 5, нам нужно умножить локальный градиент ReLU на восходящий градиент ∂J/∂H . Опять же, размеры Z и ∂J/∂Zдолжны быть одинаковыми, т. е. [м ,n] и есть только один способ добиться этого — поэлементное произведение. Также обратите внимание, что если m=n, то будет много других способов умножения двух (например, скалярное произведение, также с одним или обоими транспонированными), так что размеры работают, но эти было бы неправильно. Здесь нам очень помогло разделение m и n.

В строке 8 нам нужно умножить локальный градиент на восходящий градиент ∂J/∂Z. Можно представить себе несколько способов умножения этих двух значений — используя нотацию Python, это может быть dZ*X, dZ@X, dZ.T@X, dZ.T@. XT, X@dZ или X@dZ.T). Если вы знаете ответ, значит, все в порядке, но если нет, то на самом деле будет работать только один из этих способов (например, в Python не возникает исключение), и анализ измерений может нам об этом сказать. какой именно. Размерность ∂J/∂W должна быть такой же, как у W, т.е. [ д, м]. Размерность X равна [d,n], а размерность ∂J/∂Zравно [m,n]. Таким образом, единственный способ умножить два и получить результат с размерами [d,m] — это X@dZ.T(т.е. [d,n]умножить на [n,m]). Практически тот же аргумент применим к строке 9.

Последний интересный бит — строка 10. Форма ∂J/∂bдолжна быть [m,1] и нам нужно добраться туда каким-то образом манипулируя ∂J/∂Z([m,n]). Здесь вступает в игру замечание о трансляции b — мы эффективно использовали n копий b в прямом проходе, поэтому теперь нам нужно суммировать вклад каждой из этих реплик вместе. Вот и все градиенты без расширения нотации до явных сумм (беспорядочных и подверженных ошибкам) ​​и без необходимости запоминать, какой из многих возможных способов умножения двух матриц использовать с восходящим градиентом.