2.5. Автоматическое дифференцирование¶
Вспомним из Section 2.4, что вычисление производных — ключевой этап во всех алгоритмах оптимизации, которые мы будем использовать для обучения глубоких сетей. Хотя сами вычисления просты, их выполнение вручную может быть утомительным и чреватым ошибками, причём эти проблемы лишь усугубляются по мере усложнения наших моделей.
К счастью, все современные фреймворки для глубокого обучения берут эту работу на себя, предлагая автоматическое дифференцирование (часто сокращенно autograd). По мере прохождения данных через последовательные функции фреймворк строит вычислительный граф, который отслеживает, как каждое значение зависит от других. Для вычисления производных автоматическое дифференцирование проходит по этому графу в обратном направлении, применяя цепное правило. Вычислительный алгоритм применения цепного правила таким образом называется обратным распространением ошибки.
Хотя библиотеки автоматического дифференцирования стали популярны в последние десять лет, их история гораздо глубже. Действительно, самые ранние упоминания об автоматическом дифференцировании относятся к периоду более полувековой давности (Wengert, 1964). Основные идеи, лежащие в основе современного обратного распространения ошибки, были изложены в докторской диссертации 1980 года (Speelpenning, 1980) и далее развиты в конце 1980-х годов (Griewank, 1989). Хотя обратное распространение ошибки стало методом по умолчанию для вычисления градиентов, это не единственный вариант. Например, язык программирования Julia использует прямое распространение (Revels et al., 2016). Прежде чем изучать различные методы, давайте сначала освоим пакет autograd.
import torch
2.5.1. Простая функция¶
Предположим, что нас интересует дифференцирование функции
:math:`y = 2mathbf{x}^{top}mathbf{x}` по столбцовому вектору
:math:`mathbf{x}`. Для начала присвоим x начальное значение.
x = torch.arange(4.0)
x
Прежде чем вычислить градиент \(y\) по \(\mathbf{x}\), нам нужно место для его хранения. Как правило, мы избегаем выделения новой памяти при каждом вычислении производной, поскольку глубокое обучение требует многократного вычисления производных по одним и тем же параметрам, и мы можем столкнуться с нехваткой памяти. Обратите внимание, что градиент функции, принимающей скалярные значения, по вектору \(\mathbf{x}\) сам является вектором той же формы, что и \(\mathbf{x}\).
# Can also create x = torch.arange(4.0, requires_grad=True)
x.requires_grad_(True)
x.grad # The gradient is None by default
Теперь мы вычисляем нашу функцию x и присваиваем результат y.
y = 2 * torch.dot(x, x)
y
Теперь мы можем вычислить градиент y по x, вызвав его метод
backward. Затем мы можем получить доступ к градиенту через атрибут
grad объекта x.
y.backward()
x.grad
Мы уже знаем, что градиент функции \(y = 2\mathbf{x}^{\top}\mathbf{x}\) по \(\mathbf{x}\) должен быть равен \(4\mathbf{x}\). Теперь мы можем убедиться, что автоматическое вычисление градиента и ожидаемый результат совпадают.
x.grad == 4 * x
Теперь вычислим другую функцию от x и найдём её градиент. Обратите
внимание, что PyTorch не сбрасывает автоматически буфер градиентов при
вычислении нового градиента. Вместо этого новый градиент прибавляется к
уже сохранённому. Такое поведение оказывается полезным, когда мы хотим
оптимизировать сумму нескольких целевых функций. Чтобы сбросить буфер
градиентов, мы можем вызвать x.grad.zero_() следующим образом:
x.grad.zero_() # Reset the gradient
y = x.sum()
y.backward()
x.grad
2.5.2. Обратное распространение для нескалярных переменных¶
Когда y является вектором, наиболее естественным представлением
производной y по вектору x является матрица, называемая
Якобианом, которая содержит частные производные каждой компоненты
y по каждой компоненте x. Аналогично, для тензоров более
высокого порядка y и x результатом дифференцирования может быть
тензор ещё более высокого порядка.
Хотя матрицы Якоби действительно встречаются в некоторых продвинутых
техниках машинного обучения, чаще всего мы хотим просуммировать
градиенты каждой компоненты y по всему вектору x, в результате
чего получаем вектор той же формы, что и x. Например, у нас часто
есть вектор, представляющий значения функции потерь, вычисленные
отдельно для каждого примера в пакете обучающих примеров. В этом
случае нам нужно просто просуммировать градиенты, вычисленные по
отдельности для каждого примера.
Поскольку фреймворки глубокого обучения по-разному интерпретируют
градиенты нескалярных тензоров, PyTorch предпринимает определённые шаги,
чтобы избежать путаницы. Вызов backward для нескалярного тензора
вызывает ошибку, если мы не указываем PyTorch, как свести объект к
скаляру. Более формально, нам необходимо предоставить некоторый вектор
\(\mathbf{v}\), такой чтобы backward вычислял
\(\mathbf{v}^\top \partial_{\mathbf{x}} \mathbf{y}\), а не
\(\partial_{\mathbf{x}} \mathbf{y}\). Этот следующий момент может
показаться запутанным, но по причинам, которые прояснятся позже, этот
аргумент (представляющий \(\mathbf{v}\)) называется gradient.
Более подробное описание см. в статье Яна Чжана в
Medium.
x.grad.zero_()
y = x * x
y.backward(gradient=torch.ones(len(y))) # Faster: y.sum().backward()
x.grad
2.5.3. Отделение вычислений¶
Иногда нам нужно вынести некоторые вычисления за пределы
отслеживаемого графа вычислений. Например, предположим, что мы
используем входные данные для создания вспомогательных промежуточных
величин, градиенты по которым вычислять не требуется. В этом случае
необходимо отсоединить соответствующий граф вычислений от конечного
результата. Следующий простой пример проясняет эту ситуацию:
предположим, у нас есть z = x * y и y = x * x, но мы хотим
сосредоточиться на прямом влиянии x на z, а не на влиянии,
передаваемом через y. В этом случае мы можем создать новую
переменную u, которая принимает то же значение, что и y, но у
которой происхождение (способ создания) стерто. Таким образом, u
не имеет предков в графе, и градиенты не проходят через u к x.
Например, вычисление градиента от z = x * u даст результат u, а
не 3 * x * x, как можно было бы ожидать, учитывая, что
z = x * x * x.
x.grad.zero_()
y = x * x
u = y.detach()
z = u * x
z.sum().backward()
x.grad == u
Обратите внимание, что хотя эта процедура отделяет предков y от
графа, ведущего к z, вычислительный граф, ведущий к y,
сохраняется, и поэтому мы можем вычислить градиент y по x.
x.grad.zero_()
y.sum().backward()
x.grad == 2 * x
2.5.4. Градиенты и управляющие конструкции Python¶
До сих пор мы рассматривали случаи, когда путь от входа к выходу был
чётко определен с помощью функции, такой как z = x * x * x.
Программирование предоставляет нам гораздо больше свободы в том, как
вычислять результаты. Например, мы можем сделать их зависимыми от
вспомогательных переменных или от выбора условий на основе промежуточных
результатов. Одним из преимуществ использования автоматического
дифференцирования является то, что даже если построение вычислительного
графа для функции требовало прохождения через лабиринт управляющих
конструкций Python (например, условных операторов, циклов и произвольных
вызовов функций), мы все равно можем вычислить градиент полученной
переменной. Чтобы проиллюстрировать это, рассмотрим следующий фрагмент
кода, где количество итераций цикла while и выполнение оператора
if зависят от значения входа a.
def f(a):
b = a * 2
while b.norm() < 1000:
b = b * 2
if b.sum() > 0:
c = b
else:
c = 100 * b
return c
Ниже мы вызываем эту функцию, передавая в качестве входных данных
случайное значение. Поскольку входные данные являются случайной
величиной, мы не знаем, какой вид примет вычислительный граф. Однако при
каждом выполнении f(a) для конкретного входа мы получаем конкретный
вычислительный граф и можем затем запустить backward.
a = torch.randn(size=(), requires_grad=True)
d = f(a)
d.backward()
Хотя наша функция f, ради демонстрации, несколько искусственна, её
зависимость от входа довольно проста: это линейная функция от a с
кусочно-заданным масштабом. Следовательно, f(a) / a — это вектор с
постоянными значениями, и, более того, f(a) / a должно совпадать с
градиентом f(a) по a.
a.grad == d / a
Динамический контроль потока очень часто встречается в глубоком обучении. Например, при обработке текста вычислительный граф зависит от длины входных данных. В таких случаях автоматическое дифференцирование становится жизненно важным для статистического моделирования, поскольку вычислить градиент априори невозможно.
2.5.5. Обсуждение¶
Теперь вы убедились в мощи автоматического дифференцирования. Разработка библиотек для автоматического и эффективного вычисления производных стала мощным катализатором продуктивности специалистов в области глубокого обучения, освободив их от рутинных задач и позволив сосредоточиться на более важных аспектах. Кроме того, autograd позволяет нам проектировать масштабные модели, для которых вычисление градиентов вручную с помощью ручки и бумаги заняло бы неоправданно много времени. Интересно, что, хотя мы используем autograd для оптимизации моделей (в статистическом смысле), оптимизация самих библиотек autograd (в вычислительном смысле) является богатой и важной темой для разработчиков фреймворков. В этой области применяются инструменты компиляции и манипуляции графами, что позволяет вычислять результаты наиболее эффективным образом с точки зрения скорости и использования памяти.
На данный момент запомните следующие основы: (i) прикрепите градиенты к тем переменным, по которым мы хотим вычислить производные; (ii) запишите вычисление целевого значения; (iii) выполните функцию обратного распространения ошибки; и (iv) получите доступ к полученному градиенту.
2.5.6. Упражнения¶
Почему вычисление второй производной обходится значительно дороже, чем вычисление первой производной?
После запуска функции обратного распространения ошибки запустите её снова и посмотрите, что произойдет. Исследуйте.
В примере с управлением потоком, где мы вычисляем производную
dпоa, что произойдет, если мы заменим переменнуюaслучайным вектором или матрицей? В этом случае результат вычисленияf(a)больше не будет скаляром. Что произойдет с результатом? Как мы можем это проанализировать?Пусть \(f(x) = \sin(x)\). Постройте график функции \(f\) и её производной \(f'\). Не используйте тот факт, что \(f'(x) = \cos(x)\), а вместо этого примените автоматическое дифференцирование для получения результата.
Пусть \(f(x) = ((\log x^2) \cdot \sin x) + x^{-1}\). Запишите граф зависимостей, показывающий путь от \(x\) до \(f(x)\).
Используя цепное правило, вычислите производную \(\frac{df}{dx}\) указанной функции, разместив каждый член на графе зависимостей, который вы построили ранее.
Имея граф и результаты промежуточных производных, у вас есть несколько вариантов вычисления градиента. Вычислите результат один раз, двигаясь от \(x\) к \(f\), и один раз, двигаясь от \(f\) к \(x\). Путь от \(x\) к \(f\) обычно называют прямым дифференцированием, тогда как путь от \(f\) к \(x\) — обратным дифференцированием.
Когда может потребоваться использовать прямое дифференцирование, а когда — обратное? Подсказка: рассмотрите объём промежуточных данных, возможность распараллеливания вычислений, а также размеры задействованных матриц и векторов.