Matrix Calculus, часть 3: прямой и обратный режим автодифференцирования
Перевод главы 8 конспекта MIT «Matrix Calculus»: автодифференцирование через дуальные числа на примере вавилонского квадратного корня, почему это не символьное дифференцирование, вычислительные графы и произведения по путям, обратный режим, когда какой режим выгоднее и как считать произведения гессиана на вектор «прямым поверх обратного».
Это перевод главы 8 конспекта курса MIT «Matrix Calculus (for Machine Learning and Beyond)». Предыдущие части: производная как линейный оператор и градиенты матричных функций; карта курса — в путеводителе. Перевод распространяется под лицензией CC BY-NC-SA 4.0.
8. Прямой и обратный режим автоматического дифференцирования
Заголовок раздела «8. Прямой и обратный режим автоматического дифференцирования»Когда профессор Эдельман впервые услышал об автоматическом дифференцировании (AD), ему было легко представить, что это такое… но представил он неправильно! В голове у него было прямолинейное символьное дифференцирование, применённое к коду: что-то вроде запуска Mathematica или Maple или даже просто автоматического выполнения того, чему он учился на занятиях по анализу. Например, подставлять функции и их области определения из таблицы вроде этой, из первого курса анализа:
| Производная | Область определения |
|---|---|
А если это не как запуск Mathematica или Maple, то уж точно конечные разности, которые проходят в курсе численных методов (или как мы делали в главе 4).
Оказывается, это определённо не конечные разности: алгоритмы AD в общем случае точные (в точной арифметике, без учёта ошибок округления), а не приближённые. Но и на обычную символьную алгебру это не очень похоже: компьютер на самом деле не строит большое «развёрнутое» символьное выражение, чтобы потом его продифференцировать, как можно было бы делать вручную или в системе компьютерной алгебры. Например, представьте программу, которая вычисляет для матрицы : записать «всё» символьное выражение невозможно, пока программа не запущена и не известно (например, его вводит пользователь), а наивное символьное выражение в любом случае потребовало бы слагаемых. Поэтому системы AD должны справляться с программными конструкциями — циклами, рекурсией, размерами задачи , неизвестными до запуска, — и при этом избегать построения символьных выражений непомерного размера. (Пример, совсем не похожий на формулы, которые дифференцируют на первом курсе, — в разделе 8.1.1.) Разработка систем AD часто оказывается больше делом компиляторов, чем анализа!
8.1. Автоматическое дифференцирование через дуальные числа
Заголовок раздела «8.1. Автоматическое дифференцирование через дуальные числа»(К этой лекции есть Julia-блокнот с результатами разных вычислительных экспериментов; он на странице курса. Выдержки из этих экспериментов приведены ниже.)
Один подход к AD, который можно объяснить относительно просто, — AD в прямом режиме (forward mode): вычисление ведётся в паре с вычислением . Каждое промежуточное значение в программе дополняется ещё одним значением , которое представляет его производную, а правила цепочки протаскивают эти производные через вычисления над значениями программы. Оказывается, это можно рассматривать как замену вещественных чисел (значений ) новым видом «дуальных чисел» (значения и производные) с соответствующими правилами арифметики, как объяснено ниже.
8.1.1. Пример: вавилонский квадратный корень
Заголовок раздела «8.1.1. Пример: вавилонский квадратный корень»Начнём с простого примера — алгоритма для функции квадратного корня, на котором практический метод автоматического дифференцирования стал для профессора Эдельмана и математическим сюрпризом, и вычислительным чудом. Рассмотрим «вавилонский» алгоритм вычисления , известный тысячелетиями (а позже оказавшийся частным случаем метода Ньютона для ): просто повторяйте , пока не сойдётся к с нужной точностью. Каждая итерация — одно сложение и два деления. Для иллюстрации хватит 10 итераций. Вот короткая программа на Julia, реализующая этот алгоритм: начальное приближение 1, затем шагов (по умолчанию ):
julia> function Babylonian(x; N = 10) t = (1+x)/2 # один шаг из t=1 for i = 2:N # оставшиеся N-1 шагов t = (t + x/t) / 2 end return t endЕсли запустить эту функцию для квадратного корня из , видно, что она сходится очень быстро: всего за шага она получает правильный ответ (2) почти с тремя знаками после запятой, а задолго до сходится к 2 в пределах точности компьютерной арифметики (около 16 цифр). На самом деле каждый шаг примерно удваивает число верных цифр:
julia> Babylonian(4, N=1)2.5
julia> Babylonian(4, N=2)2.05
julia> Babylonian(4, N=3)2.000609756097561
julia> Babylonian(4, N=4)2.0000000929222947
julia> Babylonian(4, N=10)2.0Конечно, любой первокурсник знает производную квадратного корня , которую здесь
можно вычислить как 0.5 / Babylonian(x), но мы хотим понять, как получить эту производную автоматически,
прямо из самого вавилонского алгоритма. Если мы поймём, как легко и эффективно провести правило цепочки
через этот алгоритм, то начнём понимать, как AD дифференцирует и куда более сложные программы, для которых
простой формулы производной не известно.
8.1.2. Простой AD в прямом режиме
Заголовок раздела «8.1.2. Простой AD в прямом режиме»Основная идея проведения правила цепочки через программу очень проста: замените каждое число двумя числами — одно хранит значение, другое следит за производной этого значения. Значения вычисляются как и раньше, а производные — по правилу цепочки для элементарных операций вроде и .
В Julia эту идею можно реализовать, определив новый тип чисел — назовём его D, — который хранит значение
val и производную deriv.
julia> struct D <: Number val::Float64 deriv::Float64 end(Подробное объяснение синтаксиса Julia можно найти в других местах, но, надеемся, основная идея понятна,
даже если неясен каждый знак препинания.) Величина x = D(a,b) этого нового типа имеет две компоненты:
x.val = a и x.deriv = b, которыми мы будем представлять значения и производные соответственно.
Код Babylonian использует только две арифметические операции, и , так что нам достаточно
перегрузить встроенные (из Base) определения этих операций в Julia, добавив новые правила для типа D:
julia> Base.:+(x::D, y::D) = D(x.val+y.val, x.deriv+y.deriv) Base.:/(x::D, y::D) = D(x.val/y.val, (y.val*x.deriv - x.val*y.deriv)/y.val^2)Присмотревшись, вы увидите, что значения просто складываются и делятся обычным образом, а производные
вычисляются по правилу суммы (складываем производные входов) и правилу частного соответственно. Нужен ещё
один технический приём: определить правила преобразования (conversion) и продвижения типов (promotion),
которые говорят Julia, как сочетать значения D с обычными вещественными числами в выражениях вроде
или :
julia> Base.convert(::Type{D}, r::Real) = D(r,0) Base.promote_rule(::Type{D}, ::Type{<:Real}) = DЭто просто говорит, что обычное вещественное число сочетается со значением D, сначала превращаясь
в D(r,0): значение равно , а производная — 0 (производная любой константы равна нулю).
С этими определениями можно подставить значение D в нашу неизменённую функцию Babylonian, и она
«волшебным образом» вычислит производную квадратного корня. Попробуем для :
julia> x = 4949
julia> Babylonian(D(x,1))D(7.0, 0.07142857142857142)Видно, что функция правильно вернула значение 7.0 и производную 0.07142857142857142, что действительно
совпадает с квадратным корнем и его производной :
julia> (√x, 0.5/√x)(7.0, 0.07142857142857142)Почему мы подали на вход D(x,1)? Откуда взялась 1? Это просто то, что производная входа по самому
себе равна , — это начальная точка для правила цепочки.
На практике всё это (и многое другое) уже реализовано в пакете Julia ForwardDiff.jl (и во многих похожих пакетах на разных языках). Пакет прячет детали реализации и явно предоставляет функцию для вычисления производной. Например:
julia> using ForwardDiff
julia> ForwardDiff.derivative(Babylonian, 49)0.07142857142857142По сути, однако, это то же самое, что наша маленькая реализация D, только сделанная более общо и изощрённо
(правила цепочки для большего числа операций, поддержка большего числа числовых типов, частные производные
по нескольким переменным и т. д.): как и мы, ForwardDiff дополняет каждое значение вторым числом, следящим
за производной, и протаскивает обе величины через вычисление.
Ту же идею можно было реализовать и специально для вавилонского алгоритма, написав новую функцию
dBabylonian, которая по ходу вычисления следит и за переменной , и за её производной :
julia> function dBabylonian(x; N = 10) t = (1+x)/2 t′ = 1/2 for i = 1:N t = (t+x/t)/2 t′= (t′+(t-x*t′)/t^2)/2 end return t′ end
julia> dBabylonian(49)0.07142857142857142Это ровно те же вычисления, что и вызов Babylonian(D(x,1)) или ForwardDiff.derivative(Babylonian, 49),
но требующие гораздо больше человеческого труда: пришлось бы делать это для каждой программы, которую мы пишем,
вместо того чтобы один раз реализовать новый тип чисел.
Примечание переводчика: в строке для t′ стоит производная частного по : .
Заметьте также, что цикл здесь делает шагов, а в Babylonian — ; на ответ при это не влияет,
алгоритм давно сошёлся.
8.1.3. Дуальные числа
Заголовок раздела «8.1.3. Дуальные числа»Есть приятный алгебраический способ думать о нашем новом типе чисел вместо точки зрения «значение и производная». Помните, как комплексное число строится из двух вещественных : вводится особая новая величина (мнимая единица), удовлетворяющая , и все остальные правила комплексной арифметики следуют отсюда? Аналогично можно считать числом , где — новая «бесконечно малая единица», удовлетворяющая . Такая точка зрения называется дуальными числами.
Из элементарного правила сразу следуют остальные алгебраические правила дуальных чисел:
Коэффициенты при в этих правилах соответствуют правилам суммы/разности, произведения и частного из дифференциального исчисления!
На самом деле это в точности правила, которые мы реализовали выше для типа D. Не хватало только правил
вычитания и умножения, которые теперь можно добавить:
julia> Base.:-(x::D, y::D) = D(x.val - y.val, x.deriv - y.deriv) Base.:*(x::D, y::D) = D(x.val*y.val, x.deriv*y.val + x.val*y.deriv)Ещё приятно добавить правило «красивой печати», чтобы Julia показывала дуальные числа как a + bϵ,
а не как D(a,b):
julia> Base.show(io::IO, x::D) = print(io, x.val, " + ", x.deriv, "ϵ")Как только в Julia реализовано правило умножения дуальных чисел, следует из частного случая и :
julia> ϵ = D(0,1)0.0 + 1.0ϵ
julia> ϵ * ϵ0.0 + 0.0ϵ
julia> ϵ^20.0 + 0.0ϵ(Мы не определяли правило для степеней — как же было вычислено ϵ^2? Ответ: по умолчанию Julia
вычисляет повторным умножением, так что хватило определить правило для .) Теперь производную
вавилонского алгоритма в можно вычислить, как выше:
julia> Babylonian(x + ϵ)7.0 + 0.07142857142857142ϵгде «бесконечно малая часть» — это производная
Приятно, что точка зрения дуальных чисел прямо соответствует нашему понятию производной как линеаризации:
а правило дуальных чисел соответствует отбрасыванию членов высших порядков.
8.2. Наивное символьное дифференцирование
Заголовок раздела «8.2. Наивное символьное дифференцирование»AD в прямом режиме реализует точную аналитическую производную, протаскивая правила цепочки, но это совсем не то, чем многие люди представляют себе AD: символьно выполнить программу, получить гигантское символьное выражение и потом продифференцировать его. Основная беда такого подхода — размер символьных выражений по ходу работы программы быстро взрывается. Посмотрим, как это выглядело бы для вавилонского алгоритма.
Представьте, что мы подаём в код Babylonian «символьную переменную» , выполняем алгоритм и записываем
большое алгебраическое выражение для результата. После одного шага, например, получим . После
двух шагов — , что упрощается до отношения двух многочленов («рациональной функции»):
Продолжать вручную довольно утомительно, но, к счастью, это может сделать компьютер (как показано в Julia-блокноте). Три вавилонские итерации дают
четыре итерации —
а пять итераций — огромное выражение:
Пять итераций (прокрутите формулу вбок)
Заметьте, как быстро они растут — степень многочленов удваивается на каждой итерации! Если теперь взять символьные производные этих функций по обычным правилам анализа и упростить (с помощью компьютера), то производная одной итерации равна , двух итераций —
трёх итераций —
четырёх итераций —
Производная четырёх итераций
а производная пяти итераций — чудовище, которое можно прочитать, только сильно увеличив масштаб:
Производная пяти итераций
Это ужасный способ вычислять производные! (Впрочем, существуют более изощрённые подходы к эффективному символьному дифференцированию, например алгоритм «», которые избегают явных гигантских формул, используя повторяющиеся подвыражения.)
Чтобы было ясно: подход дуальных чисел (без учёта ошибок округления) вычисляет ответ в точности так, как если бы подставил некоторое конкретное в эти безумные выражения, но слова «как если бы» здесь очень важны. Как видите, мы эти выражения не строим и тем более не вычисляем. Мы просто получаем результаты, равные тем значениям, которые получили бы, если бы их построили.
8.3. Автоматическое дифференцирование через вычислительные графы
Заголовок раздела «8.3. Автоматическое дифференцирование через вычислительные графы»Перейдём к автоматическому дифференцированию через вычислительные графы. В этом разделе мы рассматриваем следующий простой мотивирующий пример.
Пример. Определим функции
Найдите и .
Решить задачу можно несколькими способами. Во-первых, конечно, можно вычислить символьно, заметив, что
откуда
Однако можно воспользоваться и вычислительным графом (рисунок ниже), в котором ребро из узла в узел подписано производной .
flowchart LR
X(("x")) -->|"cos x"| A(("a = sin x"))
A -->|"1/y"| B(("b = a/y"))
B -->|"1"| Z(("z = b + x"))
X -->|"1"| Z
Y(("y")) -->|"−a/y²"| BКак найти производные по этому ориентированному ациклическому графу (DAG)? Один взгляд («прямой») — идти по путям от входов к выходам и (слева) умножать по ходу, складывая несколько путей. Например, проделав это для путей из в , получим
Аналогично для путей из в :
и если на рёбрах числовые производные, этот алгоритм работает. Можно пойти и «обратным» путём — идти по путям назад (умножая справа налево) — и получить тот же результат. Заметьте, что ничего волшебного в том, что здесь всё скалярное, нет: можно представить, что это функции того вида, что мы встречаем в этом курсе, и проделать те же вычисления! Принципиально здесь важна только ассоциативность. Однако для векторных функций порядок, в котором перемножаются веса рёбер, жизненно важен (поскольку векторные и матричные функции в общем случае не коммутируют).
На языке теории графов здесь рассматривают «произведения по путям» (path products). Произведение по пути — это произведение весов рёбер вдоль пути. Чтобы вычислить производные с помощью вычислительного графа, нас интересует сумма произведений по всем путям от входов к выходам. Очевидно, нам не особенно важно, в каком порядке обходить пути, лишь бы порядок перемножения был правильным. В этом смысле прямой и обратный режимы автоматического дифференцирования не так уж загадочны.
Посмотрим поближе на реализацию прямого режима. Пусть мы находимся в узле в процессе вычисления производной по вычислительному графу, как на рисунке ниже.
flowchart LR
B1(("B₁")) --> A(("A"))
B2(("B₂")) --> A
B3(("B₃")) --> A
A -->|"∂f(A)/∂A"| F(("f(A)"))Пусть мы знаем произведение по пути по всем рёбрам до ребра из в включительно. Каким будет новое произведение по пути, когда мы сдвинемся вправо от ? Это ! Значит, нужна структура данных, которая отображает
В некотором смысле это ещё один взгляд на дуальные числа: принимаем произведения по путям и выдаём значения. В любом случае мы перегружаем программу, которая легко вычисляет , и добавляем к ней .
Можно спросить, с чего программа начинается: мы описали, как она работает «в середине», а каким должно быть начальное значение? Единственное, при котором метод работает, — . Затем на каждом шаге выполняется указанное выше отображение
и в конце мы получаем производные.
Как теперь объединять стрелки? Иными словами, пусть в двух узлах слева у нас значения и , как на схеме ниже.
flowchart LR
P["(a, p)"] -->|"∂z/∂a"| Z(("z = f(a, b)"))
Q["(b, q)"] -->|"∂z/∂b"| ZЗдесь мы думаем об , не как о числах, а как о переменных. Каким должно быть новое выходное значение? Мы хотим сложить два произведения по путям и получить
То есть наша перегруженная структура данных на самом деле переводит пары и в пару . Разумеется, это обобщается на случай, когда слева в графе много узлов.
Если придумать такую структуру для всех простых вычислений (сложения/вычитания, умножения и деления) и если программе больше ничего не нужно, то всё готово! Вот как структура определяется для сложения/вычитания, умножения и деления.
- Сложение/вычитание (рисунок «граф сложения/вычитания»): входы и дают узел .
- Умножение (рисунок «граф умножения»): входы и дают узел .
- Деление (рисунок «граф деления»): входы и дают узел .
Примечание переводчика: обозначения на этих рисунках оригинала довольно вольные. Смысл такой: вес ребра от к — это (для умножения , для деления ), от к — (для умножения , для деления ); эти веса умножаются на накопленные во входах произведения по путям и складываются. Запись — это просто .
В теории этих трёх графов достаточно, а для более сложных функций можно использовать разложения в ряд Тейлора. Но на практике мы просто добавляем известные производные более сложных функций, чтобы не тратить время на вычисление того, что и так знаем, — например, производной синуса или логарифма.
8.3.1. Обратный режим автоматического дифференцирования на графах
Заголовок раздела «8.3.1. Обратный режим автоматического дифференцирования на графах»В обратном режиме стрелки идут в другую сторону, и в этом разделе мы разберёмся, как это устроено. В прямом режиме всё сводилось к вопросу «от чего мы зависим?», то есть к вычислению производной в правой части схемы выше по функциям в узлах слева. В обратном режиме вопрос скорее такой: «на что мы влияем?» или «на что мы повлияем позже?»
Двигаясь «назад», нужно знать, на какие узлы влияет данный узел. Например, для узла мы хотим знать узлы , на которые влияет, то есть которые от него зависят. Теперь схема выглядит так:
flowchart LR X["(x, ∂z/∂x)"] -.-> A["(a, ∂z/∂a)"] A --> B1["(b₁, ∂z/∂b₁)"] A --> B2["(b₂, ∂z/∂b₂)"] A --> B3["(b₃, ∂z/∂b₃)"] B1 -.-> Z["(z, 1)"] B2 -.-> Z B3 -.-> Z
Итак, в конце у нас есть финальный узел (крайний справа), с которого всё начинается. На этот раз все умножения идут справа налево, поскольку мы в обратном режиме. Наша цель — вычислить узел . Значит, если мы знаем, как заполнить член , то сможем двигаться по этим вычислительным графам справа налево (то есть в обратном режиме). Формула для такова:
где — узлы, на которые влияет узел . Это снова просто правило цепочки из анализа, но можно смотреть на это и как на суммы произведений всех весов в той части графа, на которую влияет .
flowchart LR
X(("x")) -->|"a"| P(("p"))
Y(("y")) -->|"b"| P
P -->|"c"| Q(("q"))
Q -->|"d"| Z(("z"))Почему обратный режим бывает эффективнее прямого? Одна причина — он может сохранять данные и использовать их позже. Возьмём, например, вычислительный граф «источники — сток» выше.
Если , — наши источники, а — сток, мы хотим вычислить сумму произведений весов по путям от источников к стокам. В прямом режиме пришлось бы вычислить пути и , а это четыре умножения (и потом их сложить). В обратном режиме достаточно вычислить и и сложить; заметьте, что обратный режим (поскольку вычисляется только один раз) требует всего 3 умножения. В общем случае так можно эффективнее решать определённые типы задач, например задачу «источники — сток».
8.4. Прямой и обратный режимы: сравнение
Заголовок раздела «8.4. Прямой и обратный режимы: сравнение»Кратко подытожим относительные плюсы и минусы двух подходов к вычислению производных (вручную или с помощью программ AD). С математической точки зрения два подхода — зеркальные отражения друг друга, но с вычислительной они сильно различаются, потому что программы обычно выполняются «вперёд» во времени, от входов к выходам.
Пусть мы дифференцируем функцию , которая отображает скалярных входов (-мерный вход) в скалярных выходов (-мерный выход). Первое ключевое различие прямого и обратного режимов — как вычислительная стоимость растёт с числом (размерностью) входов и выходов:
- Стоимость прямого дифференцирования (от входов к выходам) пропорциональна — числу входов. Это идеально для функций с (мало входов, много выходов).
- Стоимость обратного дифференцирования (от выходов к входам) пропорциональна — числу выходов. Это идеально для функций с (мало выходов, много входов).
До этой главы мы впервые увидели такую зависимость в разделе о стоимости умножения матриц (часть 1, раздел 2.5.1), а затем в методе сопряжённых уравнений (глава 6); в одной из следующих лекций увидим её ещё раз (чувствительность решений ОДУ, глава 9). Случай немногих выходов крайне распространён в крупномасштабной оптимизации (будь то машинное обучение, инженерное проектирование или другие приложения): параметров оптимизации много (), а выход один () — целевая функция (функция потерь), — или несколько выходов: цель и ограничения. Поэтому обратный режим (backpropagation) — основной подход в крупномасштабной оптимизации и таких приложениях, как обучение нейросетей.
Есть, однако, и другие практические соображения:
- Прямое дифференцирование идёт в том же порядке, что и вычисление самой функции, — от входов к выходам. Похоже, поэтому AD в прямом режиме проще реализовать (например, как наша реализация в разделе 8.1) и сделать эффективным.
- Обратное дифференцирование идёт в противоположном обычному вычислению направлении. Из-за этого AD в обратном режиме гораздо сложнее реализовать, и оно добавляет к вычислению функции много накладных расходов на память. Сначала вы вычисляете функцию от входов к выходам, но вы (или система AD) ведёте запись («ленту», tape) всех промежуточных шагов вычисления; затем вы выполняете вычисление в обратном порядке («прокручиваете ленту назад»), обратно распространяя производные.
Из-за этих практических преимуществ, даже в случае многих () входов и одного () выхода, практики говорят нам, что прямой режим оказывается эффективнее, пока не станет достаточно большим (возможно, даже пока , в зависимости от дифференцируемой функции и реализации AD). (Вас может заинтересовать и пост в блоге Криса Ракаукаса Engineering Trade-offs in AD, в основном о реализациях обратного режима.)
Если и ни у одного подхода нет преимущества в масштабировании, обычно предпочитают меньшие накладные расходы и простоту прямого дифференцирования. Такой случай возникает при вычислении явных матриц Якоби для нелинейного поиска корней (глава 6) или матриц вторых производных — гессианов (глава 12), для которых часто используют прямой режим… или даже сочетание прямого и обратного, как обсуждается ниже.
Конечно, прямой и обратный — не единственные варианты. Правило цепочки ассоциативно, так что возможных порядков много (например, начать с обоих концов и встретиться посередине или наоборот). Трудная1 задача, которая часто требует гибридных схем, — вычислить якобианы (или гессианы) за минимальное число операций, используя любую специфичную для задачи структуру (например, разреженность: многие элементы могут быть нулями). Это и другие темы AD обсуждаются гораздо подробнее, чем в этом конспекте, в книге Evaluating Derivatives (2-е изд.) Гриванка и Вальтер (2008).
1 На самом деле необычайно трудная: «NP-полная» (Naumann, 2006).
8.4.1. Прямой поверх обратного: вторые производные
Заголовок раздела «8.4.1. Прямой поверх обратного: вторые производные»Часто при вычислении вторых производных, которые возникают во многих практических приложениях, выгодно сочетать прямое и обратное дифференцирование.
Вычисление гессиана. Рассмотрим, например, функцию , отображающую входов в один скаляр. Первую производную лучше всего вычислять обратным режимом, если (много входов). Но теперь рассмотрим вторую производную — производную функции , которая отображает входов в выходов . Понятно, что — матрица Якоби , которая называется гессианом (в главе 12 он обсуждается гораздо шире). Поскольку у одинаковое число входов и выходов, ни у прямого, ни у обратного режима нет внутреннего преимущества в масштабировании, так что для обычно выбирают прямой режим благодаря его практической простоте, а по-прежнему вычисляют обратным. То есть мы вычисляем обратным режимом, а затем вычисляем , применяя прямое дифференцирование к алгоритму вычисления . Это называется алгоритмом «прямой поверх обратного» (forward-over-reverse).
Ещё более явное применение схемы «прямой поверх обратного» — произведения гессиана на вектор (Hessian–vector products). Во многих приложениях оказывается, что нужен только произведение гессиана на произвольный вектор . В этом случае можно вообще не вычислять (и не хранить) матрицу гессиана явно, а стоимость будет пропорциональна лишь стоимости одного вычисления функции . Приём такой: вспомним (из раздела 2.2.1), что для любой функции линейная операция — это производная по направлению, равносильная производной одной переменной при . Здесь мы просто применяем это правило к функции и получаем формулу произведения гессиана на вектор:
Вычислительно внутреннее вычисление градиента в произвольной точке эффективно делается обратным алгоритмом (adjoint, backpropagation). А внешнюю производную по одному входу лучше всего брать прямым режимом.2 Поскольку гессиан симметричен (это в большой общности обсуждается в главе 12), тот же алгоритм работает и для произведений вектора на гессиан — этим мы воспользуемся в следующем примере.
2 В The Autodiff Cookbook — части документации JAX — этот алгоритм обсуждается в разделе о произведениях гессиана на вектор. Там отмечено, что можно поменять местами производные и и применить режим «обратный поверх прямого», но на практике это, видимо, менее эффективно: «поскольку у прямого режима меньше накладных расходов, чем у обратного, а внешнему оператору дифференцирования приходится дифференцировать большее вычисление, чем внутреннему, держать прямой режим снаружи — лучше всего». Там же предложена ещё одна альтернатива: по тождеству можно применить режим «обратный поверх обратного» и взять градиент , но накладные расходы тут ещё больше.
Скалярные функции от градиентов. Есть ещё одна частая ситуация, в которой прямое и обратное дифференцирование сочетают, но которая выглядит тоньше: дифференцирование скалярной функции от градиента другой скалярной функции. Рассмотрим пример.
Пример. Пусть — скалярная функция входов с градиентом , и пусть — ещё одна такая функция с градиентом . Рассмотрим скалярную функцию и найдём .
Обозначим . По правилу цепочки , но мы хотим избежать явного вычисления большой матрицы гессиана размера . Вместо этого, как обсуждалось выше, воспользуемся тем, что такое произведение вектора на гессиан равносильно (по симметрии гессиана) транспонированному произведению гессиана на вектор , а оно равносильно производной по направлению:
где дифференцирование идёт по одному скаляру . Значит, как и для любого произведения гессиана на вектор, и можно вычислить так:
- Вычислить : вычислить обратным режимом и подставить в .
- Вычислить :
- вычислить обратным режимом;
- реализовать обратным режимом, а затем продифференцировать по прямым режимом при .
Это алгоритм «прямой поверх обратного»: прямой режим эффективно используется для производной по единственному входу в сочетании с обратным режимом для дифференцирования по .
Ниже — пример кода на Julia, реализующий этот процесс «прямой поверх обратного» как раз для такой функции . Здесь прямое дифференцирование по выполняет пакет ForwardDiff.jl из раздела 8.1, а обратное дифференцирование по или — пакет Zygote.jl. Сначала импортируем пакеты и определим простые функции-примеры и , а также вычисление через Zygote:
julia> using ForwardDiff, Zygote, LinearAlgebrajulia> f(x) = 1/norm(x)julia> g(z) = sum(z)^3julia> h(x) = g(Zygote.gradient(f, x)[1])Теперь вычислим схемой «прямой поверх обратного»:
julia> function ∇h(x) ∇f(y) = Zygote.gradient(f, y)[1] ∇g = Zygote.gradient(g, ∇f(x))[1] return ForwardDiff.derivative(α -> ∇f(x + α*∇g), 0) endТеперь подставим случайные числа и сравним с проверкой конечными разностями:
julia> x = randn(5); δx = randn(5) * 1e-8;
julia> h(x)-0.005284687528953334
julia> ∇h(x)5-element Vector{Float64}: -0.006779692698531759 0.007176439898271982 -0.006610264199241697 -0.0012162087082746558 0.007663756720005014
julia> ∇h(x)' * δx # производная по направлению-3.0273434457397667e-10
julia> h(x+δx) - h(x) # проверка конечной разностью-3.0273433933303284e-10Проверка конечной разностью совпадает примерно до 7 значащих цифр — на большее рассчитывать нельзя: код «прямой поверх обратного» работает!
Задача. Распространённый вариант этой процедуры, который часто встречается в машинном обучении, — функция , отображающая входные «данные» и «параметры» в скаляр. Обозначим через и градиенты по и по .
Пусть, как и раньше, есть функция , и определим . Мы хотим вычислить , что потребует «смешанных» производных и по , и по .
Покажите, что можно вычислить как
где . (Принципиально, что так мы никогда не вычисляем матрицу смешанных производных размера .)
Придумайте простые функции-примеры и , реализуйте эту формулу схемой «прямой поверх обратного» на Julia по образцу выше (прямой режим для и обратный — для ) и проверьте результат приближением конечными разностями.
Комментарий переводчика: такие «градиенты от градиентов» встречаются, например, в штрафе на норму градиента (gradient penalty) при обучении GAN и в метаобучении (MAML), где шаг обучения сам дифференцируется по параметрам.
Словарик
Заголовок раздела «Словарик»| EN | RU |
|---|---|
| automatic differentiation (AD) | автоматическое дифференцирование |
| forward mode / reverse mode | прямой режим / обратный режим |
| dual number, infinitesimal unit | дуальное число, бесконечно малая единица |
| operator overloading | перегрузка операторов |
| conversion and promotion rules | правила преобразования и продвижения типов |
| symbolic differentiation | символьное дифференцирование |
| finite differences | конечные разности |
| rational function | рациональная функция |
| computational graph, DAG | вычислительный граф, ориентированный ациклический граф |
| path product | произведение по пути |
| source / sink | источник / сток |
| tape | лента (запись промежуточных шагов) |
| storage overhead | накладные расходы на память |
| Hessian, Hessian–vector product | гессиан, произведение гессиана на вектор |
| forward-over-reverse | «прямой поверх обратного» |
| Jacobian–vector product (jvp), vector–Jacobian product (vjp) | произведение якобиана на вектор, вектора на якобиан |
Назад к карте курса: путеводитель по Matrix Calculus.