Автоматическое дифференцирование
Учим наш класс Value самому считать производные — каждая операция знает свою маленькую производную, граф обходится в правильном порядке, а вклады нескольких путей складываются (+=). Ровно так работает loss.backward() в PyTorch.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В прошлом уроке Value научился запоминать граф. Теперь научим его главному — самому считать производные.
Мы напишем метод backward(). Вызовите его у результата, и в поле .grad каждого узла графа окажется
. Это обратное распространение ошибкиобратное распространение ошибкиEN backpropagation из урока 3.1.2, но автоматический и для
любого графа: хоть нейрон, хоть трансформер.
Механизм состоит из трёх идей. Каждая занимает несколько строк кода, и каждая встречается в PyTorch:
- Локальная производная: каждая операция знает только свою производную.
- Топологический порядок: узлы обходят от результата к листьям, причём каждый — только после всех его «потребителей».
- Накопление (
+=): если узел используется несколько раз, вклады складываются.
Обратный проход руками
Заголовок раздела «Обратный проход руками»Возьмём выражение из видео Карпатого:
Вперёд: , , . Назад идём от , на каждом шаге умножая градиент сверху на локальную производную (цепное правилоцепное правилоEN chain ruleПроизводная композиции — произведение производных звеньев — (f(g(x)))′ = f′(g(x))·g′(x). Математическая основа backprop.):
| Узел | Как считаем | grad |
|---|---|---|
| 1 | ||
| , умножить на 1 | 4 | |
| , умножить на 1 | ||
| сложение передаёт градиент как есть: | ||
| то же самое | ||
| , умножить на | 6 | |
| , умножить на |
Проверим смысл одного числа. означает, что при значение вырастет примерно на . Так и есть: .
Локальная производная: каждый узел знает только себя
Заголовок раздела «Локальная производная: каждый узел знает только себя»Заметьте: чтобы посчитать градиент узла, нужны только градиент результата (out.grad) и локальная производная
операции. Остальной граф не важен. Поэтому каждая операция может сама сказать, как распределить градиент между
своими входами:
| Операция | Вперёд | Что добавить к градиентам входов |
|---|---|---|
| сложение | : out.grad, : out.grad | |
| умножение | : b.data * out.grad, : a.data * out.grad | |
| степень | out.grad | |
| tanh | out.grad | |
| exp | out.grad (это уже out.data) | |
| ReLU | out.grad, если , иначе 0 |
Полезные метафоры из курса CS231n: сложение раздаёт градиент обоим входам без изменений, умножение меняет местами: вход получает градиент, умноженный на , и наоборот. ReLU — маршрутизатор: пропускает градиент только через активный вход.
Код: замыкание _backward
Заголовок раздела «Код: замыкание _backward»Каждая операция при создании результата кладёт в него маленькую функцию _backward. Она умеет передать
out.grad своим детям — входам операции:
def __mul__(self, other): other = other if isinstance(other, Value) else Value(other) out = Value(self.data * other.data, (self, other), "*")
def _backward(): # вызовется позже, во время обратного прохода self.grad += other.data * out.grad other.grad += self.data * out.grad
out._backward = _backward # результат «помнит», как передать градиент назад return out
def tanh(self): t = math.tanh(self.data) out = Value(t, (self,), "tanh")
def _backward(): self.grad += (1 - t**2) * out.grad
out._backward = _backward return out_backward — это замыкание (closure) из урока 0.2.2: функция,
которая «захватила» переменные self, other, out и t из места, где её создали. Вызовем её через сто строк кода,
а она всё ещё будет знать, чьи градиенты обновлять. Обратите внимание: строка out._backward = _backward функцию
не вызывает (скобок нет), а только кладёт её в поле узла — «на потом». У листьев _backward пустой
(lambda: None — функция, которая ничего не делает): передавать градиент им некому.
Сверим код с таблицей на примере при , . Пусть к моменту вызова c._backward() в
c.grad лежит 4. Тогда к a.grad прибавится b.data * c.grad , а к b.grad — a.data * c.grad
.
Почему внутри +=, а не =, — главный вопрос урока. Ответим на него ниже.
В каком порядке обходить граф
Заголовок раздела «В каком порядке обходить граф»У узла нельзя вызвать _backward, пока в n.grad не собрались вклады от всех узлов, которые используют .
Иначе узел передаст дальше неполный градиент.
Простой обход «в ширину» — по уровням: сначала корень, потом все его дети, потом дети детей и так далее — эту гарантию не даёт. Пример: , и
flowchart LR
x["x = 3"] --> a(("a = x·x"))
a --> m2(("·2"))
m2 --> m3(("·3"))
m3 --> L(("+ = L"))
a --> LОбход в ширину от дойдёт до по короткому пути раньше, чем по длинному. В этот момент в лежит только
вклад 1 из 7. Если сразу вызвать a._backward(), то получит вместо , а
поздний вклад 6 так и останется в a.grad, не дойдя до .
Решение — топологическая сортировкатопологическая сортировкаEN topological sortПорядок узлов ориентированного графа без циклов, в котором каждый узел идёт после всех, от кого зависит. backward() обходит его в обратную сторону.: упорядочить узлы так, чтобы каждый шёл
после всех своих детей. Бытовая аналогия — порядок одевания: носки раньше ботинок, рубашка раньше пиджака. Любой
список, где каждая вещь стоит после всего, что надевается под неё, — топологический порядок. Если пройти такой список
задом наперёд, получится «от результата к листьям, и потребители всегда раньше». Строится он тем же обходом в глубину,
что trace из прошлого урока, с одним отличием: узел добавляется в список, только когда
добавлены все его дети.
def backward(self): topo, visited = [], set()
def build(v): if v not in visited: visited.add(v) for child in v._prev: build(child) topo.append(v) # v попадает в список ПОСЛЕ всех своих детей
build(self) self.grad = 1.0 # dL/dL = 1 for v in reversed(topo): # от корня к листьям v._backward()Для примера выше (не считая листьев-констант 2 и 3) topo будет таким: [x, a, ·2, ·3, L]. Задом наперёд: , , , , . Проследим
градиенты по этому порядку:
| Шаг | Вызываем _backward у | Что происходит | Градиенты после шага |
|---|---|---|---|
| 1 | сложение раздаёт 1 обоим входам | (·3).grad = 1, a.grad = 1 | |
| 2 | умножение на 3 | (·2).grad = 3 | |
| 3 | умножение на 2 | a.grad = 1 + 6 = 7 | |
| 4 | — оба входа умножения: | x.grad = 42 |
К моменту обработки (шаг 4) оба вклада, 1 и 6, уже на месте, и получает правильные 42.
Накопление градиентов: почему +=
Заголовок раздела «Накопление градиентов: почему +=»Вернёмся к +=. Возьмём самый короткий пример: . Производная . Посмотрим, что
делает _backward сложения, где self и other — один и тот же объект a:
self.grad = out.grad # a.grad = 1other.grad = out.grad # a.grad = 1 — перезаписали! Ответ 1 вместо 2С += получается . Это не трюк, а многомерное цепное правило: если величина влияет на результат
по нескольким путям, её производная — сумма производных по всем путям:
Читается так: перечислите все пути по стрелкам от до ; вдоль каждого пути перемножьте локальные производные ( — знак произведения из урока 1.0.3); сложите результаты по всем путям ().
Это то же правило ветвления, что в уроке 1.2.2: там вы складывали
вклады двух веток руками, а теперь это делает +=. В примере с это . Карпатый в видео сначала пишет = и ловит ровно этот баг на a + a.
Это хорошее напоминание: автоград из двадцати строк легко сделать почти правильным.
Накопление градиентовнакопление градиентовEN gradient accumulationГрадиенты складываются (+=), а не перезаписываются. Внутри графа так учитываются все пути к узлу; на уровне обучения — шаг оптимизатора делают раз в несколько микробатчей. — не экзотика. В нейросетях узлы, которые используются многократно, повсюду:
- Веса и батч. Одна матрица применяется к каждому примеру батча. Её градиент — сумма вкладов примеров. Поэтому в уроке 3.1.2 получилось : матричное умножение суммирует по примерам.
- Позиции в трансформере. Одни и те же , , и веса FFN применяются к каждому токену последовательности. Градиент собирается со всех позиций.
- Остаточные связи. В вход идёт по двум путям, и градиент к нему приходит по обоим (урок 3.4).
- Общие эмбеддинги. Одна матрица служит и входным слоем, и выходным (китайский угол ниже).
Обратная сторона: градиенты надо обнулять
Заголовок раздела «Обратная сторона: градиенты надо обнулять»+= означает, что .grad копит всё, что в него когда-либо добавили. В PyTorch то же самое: .grad листьев
накапливается от вызова к вызову. Посмотрим на это в PyTorch (подробно он будет в модуле 3.3; если он ещё не
установлен, просто сверьте код с выводом под ним):
import torch
a = torch.tensor(3.0, requires_grad=True)(a + a).backward()print(a.grad.item()) # 2.0 — PyTorch тоже складывает вклады
a = torch.tensor(3.0, requires_grad=True)y = a * ay.backward(retain_graph=True)y.backward()print(a.grad.item()) # 6 + 6 = 12, а не 6
try: # try/except ловит ошибку (урок 0.2.4), чтобы показать её текст z = torch.tanh(a) z.backward() z.backward() # граф уже освобождёнexcept RuntimeError as err: print(str(err).split("(")[0].strip()) # печатаем только начало длинного сообщения2.012.0Trying to backward through the graph a second timeПоэтому в цикле обучения перед каждым backward() градиенты обнуляют (zero_grad). Иначе шаг делается по сумме
градиентов всех прошлых шагов. Этим займёмся в следующем уроке. Заодно видно отличие от micrograd: PyTorch после
backward() освобождает граф. Хотите пройти его второй раз — попросите retain_graph=True.
Проверяем автоград
Заголовок раздела «Проверяем автоград»Соберём сигмоиду из примитивов — exp, +, ** и умножения на — и сверим автоград с формулой
из уроков 1.2.1 и 2.2. Для этого кода нужен класс Value с методом
backward() — его вы допишете в задании ниже, так что вернитесь сюда после практики:
w, x, b = Value(1.5), Value(-2.0), Value(0.5)z = w * x + b # z = −2.5s = 1 / (1 + (-z).exp()) # сигмоида, собранная из примитивовs.backward()print(f"s = {s.data:.4f}")print(f"autograd: ds/dw = {w.grad:.4f}")print(f"формула σ(1 − σ)·x = {s.data * (1 - s.data) * x.data:.4f}")s = 0.0759autograd: ds/dw = -0.1402формула σ(1 − σ)·x = -0.1402Автоград прошёл через семь операций (умножение, сложение, минус — то есть умножение на , exp, ещё сложение,
степень и умножение на 1 из __rtruediv__) и получил то же число, что аналитическая формула. Мы не писали правило для сигмоиды: оно само сложилось из локальных правил.
В этом сила автограда. Сверка с PyTorch на нейроне из прошлого урока:
import torch
x1 = torch.tensor(2.0, dtype=torch.float64, requires_grad=True)x2 = torch.tensor(0.0, dtype=torch.float64, requires_grad=True)w1 = torch.tensor(-3.0, dtype=torch.float64, requires_grad=True)w2 = torch.tensor(1.0, dtype=torch.float64, requires_grad=True)b = torch.tensor(6.8813735870195432, dtype=torch.float64, requires_grad=True)o = torch.tanh(x1 * w1 + x2 * w2 + b)o.backward()print([round(t.grad.item(), 4) for t in (x1, w1, x2, w2, b)])[-1.5, 1.0, 0.5, 0.0, 0.5]Ровно те числа, которые даст ваш Value после задания. Сверьте: — это градиент и
. Дальше умножения меняют местами: получает , а получает . У
градиент ноль, потому что : вес, умноженный на нулевой вход, на выход не влияет.
Практика
Заголовок раздела «Практика»Допишите локальные производные для *, **, tanh, exp, relu и метод backward(). Сложение дано как образец.
Тесты проверят нейрон из урока, повторное использование узлов, порядок обхода на примере с , численные
производные и длинную цепочку из 200 операций.
Каждое _backward — одна-две строки по таблице «Локальная производная». Шаблон всегда один:
вход.grad += (локальная производная) * out.grad. Кнопка «Запустить» печатает градиенты нейрона из урока; правильный
ответ: x1 -1.5, w1 1.0, x2 0.5, w2 0.0, b 0.5.
import math
class Value:
def __init__(self, data, _children=(), _op=""):
self.data = data
self.grad = 0.0
self._backward = lambda: None # у листьев нечего распространять
self._prev = set(_children)
self._op = _op
def __repr__(self):
return f"Value(data={self.data}, grad={self.grad})"
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
# готовый образец: сложение «раздаёт» out.grad обоим слагаемым.
# Обратите внимание: +=, а не = (узел может участвовать в графе несколько раз)
self.grad += out.grad
other.grad += out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), "*")
def _backward():
# TODO: d(a·b)/da = b, d(a·b)/db = a; умножьте на out.grad и накопите в self.grad и other.grad
pass
out._backward = _backward
return out
def __pow__(self, k):
out = Value(self.data**k, (self,), f"**{k}")
def _backward():
# TODO: d(x^k)/dx = k · x^(k−1)
pass
out._backward = _backward
return out
def tanh(self):
t = math.tanh(self.data)
out = Value(t, (self,), "tanh")
def _backward():
# TODO: d tanh(x)/dx = 1 − tanh(x)², значение tanh уже лежит в t
pass
out._backward = _backward
return out
def exp(self):
out = Value(math.exp(self.data), (self,), "exp")
def _backward():
# TODO: (e^x)' = e^x — и оно уже посчитано в out.data
pass
out._backward = _backward
return out
def relu(self):
out = Value(self.data if self.data > 0 else 0.0, (self,), "relu")
def _backward():
# TODO: градиент проходит, только если вход был > 0
pass
out._backward = _backward
return out
def backward(self):
"""Обратный проход от этого узла (корня, обычно loss).
1) Постройте топологический порядок: рекурсивно обойдите детей (v._prev)
и добавляйте узел в список ПОСЛЕ всех его детей; посещённые храните в set.
2) Положите self.grad = 1.0.
3) Пройдите список в обратном порядке и у каждого узла вызовите v._backward().
"""
# TODO: ваш код
pass
# Производные операции — через уже готовые (здесь ничего менять не нужно)
def __neg__(self):
return self * -1
def __radd__(self, other):
return self + other
def __sub__(self, other):
return self + (-other)
def __rsub__(self, other):
return other + (-self)
def __rmul__(self, other):
return self * other
def __truediv__(self, other):
return self * other**-1
def __rtruediv__(self, other):
return other * self**-1
# Проверьте себя кнопкой «Запустить». Ожидается: x1 -1.5, w1 1.0, x2 0.5, w2 0.0, b 0.5
x1, x2, w1, w2 = Value(2.0), Value(0.0), Value(-3.0), Value(1.0)
b = Value(6.8813735870195432)
o = (x1 * w1 + x2 * w2 + b).tanh()
o.backward()
print(f"o = {o.data:.4f}")
for name, v in [("x1", x1), ("w1", w1), ("x2", x2), ("w2", w2), ("b", b)]:
print(name, round(v.grad, 4))
Проверь себя
Заголовок раздела «Проверь себя»- Каждая операция знает только свою локальную производную и хранит её в замыкании
_backward. Сложение раздаёт градиент, умножение меняет местами, tanh масштабирует на . backward(): топологическая сортировка, затемgrad = 1в корне и_backward()у узлов в обратном порядке. Так каждый узел передаёт дальше полный градиент.+=— это многомерное цепное правило: вклады всех путей складываются. Отсюда суммы по батчу, по позициям, по двум ветвям остаточной связи и по двум ролям общих эмбеддингов.- Обратная сторона
+=:.gradкопит всё, поэтому перед каждым обратным проходом его обнуляют. PyTorch ведёт себя так же.
Дальше: соберём из Value нейрон, слой и MLP, напишем цикл обучения с zero_grad и научим сеть различать две
«луны» с точностью выше 95%.