В прошлом уроке мы построили граф вычислений: узлы Value хранят число и список «родителей», а операции +, *, ** связывают их в дерево. Но граф сам по себе не учит сеть ничему, обучение начинается там, где мы умеем сказать, насколько каждое входное число повлияло на результат. В этом уроке мы разберём, как micrograd вычисляет эти влияния: пройдём обратное распространение (backpropagation) строка за строкой, поймём, что такое накопление градиентов и топологическая сортировка, и научимся проверять правильность производных численным методом. После урока вы сможете читать и модифицировать _backward для любой операции и отличать рабочий код автоградиента от сломанного.
Неофициальный курс AI University по открытому коду (MIT). В уроке приводится код из karpathy/micrograd © Andrej Karpathy, лицензия MIT; комментарии переведены на русский, объяснения написаны нашей командой. Курс не связан с автором кода и не одобрен им.
Правило цепочки простыми словами
Представьте, что результат вычисления L зависит от x через цепочку промежуточных шагов: x → y → L. Нас интересует, насколько изменится L, если немного пошевелить x. Правило цепочки говорит: эта общая чувствительность раскладывается на произведение локальных чувствительностей каждого шага.
∂L/∂x = (∂L/∂y) · (∂y/∂x)
Второй множитель, ∂y/∂x, это локальная производная: она зависит только от самой операции, которая превращает x в y, и не знает, что было дальше. Первый множитель, ∂L/∂y, это «градиент, пришедший сверху»: он отвечает на вопрос, насколько сильно L реагирует на изменение y, и его мы уже вычислили на предыдущем шаге обратного прохода.
Возьмём числовой пример. Пусть y = x², а дальше L = 3·y. Если x = 2, то y = 4, L = 12.
Локальная производная y по x равна 2x = 4: если x чуть увеличить, y растёт в 4 раза быстрее. Градиент L по y равен 3: L увеличивается втрое быстрее, чем y.
Значит, по правилу цепочки, ∂L/∂x = 3 · 4 = 12. Проверим: если x = 2.001, то y ≈ 4.004001, L ≈ 12.012003, то есть L выросло примерно на 0.012 при изменении x на 0.001, отношение даёт около 12. Совпадает.
В Value именно эта идея реализована через поле out.grad (это ∂L/∂out, градиент, пришедший сверху) и через локальную производную, которую знает только сама операция. Каждый узел не обязан понимать всю сеть вычислений вокруг себя: он отвечает только за свой маленький кусочек правила цепочки.
Как устроен _backward у каждой операции
Для каждой операции мы заранее знаем формулу локальной производной, и _backward просто умножает её на out.grad и добавляет результат в grad входов.
# Фрагмент: micrograd/micrograd/engine.py
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), '+')
def _backward():
self.grad += out.grad
other.grad += out.grad
out._backward = _backward
return out
Для сложения out = self + other. Если self увеличить на маленькое число ε, out тоже увеличится ровно на ε, поэтому локальная производная равна 1. Отсюда self.grad += out.grad без домножения: вклад просто копируется в оба слагаемых.
# Фрагмент: micrograd/micrograd/engine.py
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
Для умножения out = self · other. Производная out по self равна other.data (если other = 5, то изменение self на ε меняет out примерно на 5ε), а производная по other равна self.data. Это и есть ровно то, чему учат на первом курсе матанализа: d(uv) = u·dv + v·du.
# Фрагмент: micrograd/micrograd/engine.py
def __pow__(self, other):
assert isinstance(other, (int, float)), "only supporting int/float powers for now"
out = Value(self.data**other, (self,), f'**{other}')
def _backward():
self.grad += (other * self.data**(other-1)) * out.grad
out._backward = _backward
return out
Здесь other не Value, а обычное число (степень), поэтому у него нет своего grad, и в _backward обновляется только self. Формула (other · self.data^(other-1)) это классическая производная степенной функции x^n по x, равная n·x^(n-1).
# Фрагмент: micrograd/micrograd/engine.py
def relu(self):
out = Value(0 if self.data < 0 else self.data, (self,), 'ReLU')
def _backward():
self.grad += (out.data > 0) * out.grad
out._backward = _backward
return out
ReLU обнуляет отрицательные значения и пропускает положительные без изменений. Локальная производная равна 1 там, где данные прошли без изменений (out.data > 0), и 0 там, где всё обнулилось: раз на выходе ноль независимо от входа, маленькое шевеление входа ничего не меняет, вклад в градиент должен быть нулевым. Выражение (out.data > 0) в Python превращается в True/False, которые ведут себя как 1 и 0 при умножении, поэтому запись работает без явного if.
Почему градиенты накапливаются через +=
Обратите внимание: во всех _backward используется +=, а не =. Это принципиальный момент, и вот почему он важен.
Представим выражение y = a * a, где один и тот же узел a используется дважды. В графе вычислений у Value out = a*a будет один родитель a, но его вклад в результат идёт по двум путям: как первый множитель и как второй. Правило цепочки для такой ситуации требует сложить вклады от всех путей, по которым a влияет на результат:
∂L/∂a = ∂L/∂a (через первое вхождение) + ∂L/∂a (через второе вхождение)
Если бы в _backward стояло self.grad = other.data * out.grad, то при вызове _backward для умножения второй вклад просто стёр бы первый, вместо того чтобы сложиться с ним.
Проверим на числах. Пусть a = 3, y = a * a = 9, dy/da = 2a = 6. В реализации micrograd при вычислении a*a создаётся Value, у которого self и other это один и тот же объект a. Вызов _backward выполнит:
self.grad += other.data * out.grad # a.grad += 3 * out.grad
other.grad += self.data * out.grad # a.grad += 3 * out.grad
Оба раза это один и тот же объект a, поэтому a.grad получит сначала +3, потом ещё +3, итого 6, что совпадает с правильной производной 2a = 6 при out.grad = 1. Если бы было =, то a.grad в конце оказался бы равен только 3, то есть в два раза меньше правильного значения.
Разберём на своём примере, чтобы увидеть ошибку своими глазами.
# Наш пример
from micrograd.engine import Value
a = Value(3.0)
y = a * a # используем один и тот же узел a дважды
y.backward()
print(a.grad) # ожидаем dy/da = 2a = 6.0
Если в собственной копии engine.py заменить += на = в методе __mul__, этот код вернёт 3.0 вместо 6.0: второе присваивание затирает первое. Такая же проблема возникает в любом графе, где узел разветвляется и снова сходится, например a + a или (ab) + (ac). Накопление через += это именно то, что позволяет micrograd корректно работать с произвольными графами, а не только с цепочками без разветвлений.
Метод backward: топологическая сортировка
Отдельная операция знает, как посчитать свой локальный вклад, но кто-то должен вызвать все _backward в правильном порядке: от выхода графа к листьям. Если вызвать _backward у промежуточного узла раньше, чем посчитан градиент у узла, который на него ссылается сверху, то out.grad ещё будет равен 0, и весь вклад потеряется.
# Фрагмент: micrograd/micrograd/engine.py
def backward(self):
# собираем все дочерние узлы графа в топологическом порядке
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build_topo(child)
topo.append(v)
build_topo(self)
# проходим по переменным по одной и применяем правило цепочки для вычисления её градиента
self.grad = 1
for v in reversed(topo):
v._backward()
build_topo это рекурсивный обход в глубину: для узла v сначала рекурсивно обрабатываются все его _prev (входы, из которых v получился), и только после этого сам v дописывается в список topo. Это классическая топологическая сортировка: узел попадает в список только после того, как в него попали все его предки по графу вычислений. В результате в конце списка окажется сам выходной узел (у него нет потомков в списке, которые ещё не добавлены), а в начале, грубо говоря, листья.
Множество visited нужно, чтобы не обрабатывать один узел дважды: если, например, a используется в нескольких местах графа, build_topo(a) может быть вызван несколько раз из разных веток рекурсии, но реально обрабатывать его нужно один раз.
Проход for v in reversed(topo) идёт от конца списка к началу, то есть от выходного узла к листьям: именно в этом порядке у каждого узла, когда до него доходит очередь, его out.grad уже полностью накоплен от всех узлов, которые на него ссылаются выше по графу.
Строка self.grad = 1 в начале задаёт стартовую точку правила цепочки: мы спрашиваем, насколько сам self чувствителен к самому себе, а ответ, очевидно, равен 1 (dL/dL = 1). Все остальные градиенты в графе это произведения локальных производных, умноженные в конечном счёте на эту единицу.
Зачем обнулять градиенты перед новым проходом
Обратите внимание: backward() нигде не обнуляет grad входных узлов перед началом, он только накапливает значения через +=. Если вызвать backward() второй раз на том же графе (или переиспользовать Value для нового прохода), новые вклады прибавятся к старым, а не заменят их.
# Наш пример
from micrograd.engine import Value
x = Value(2.0)
y = x * x
y.backward()
print(x.grad) # 4.0, верно
y2 = x * x
y2.backward()
print(x.grad) # 8.0, а не 4.0: градиенты накопились из двух проходов
Это не баг, а прямое следствие того же механизма накопления, который нужен для корректной работы внутри одного прохода. Но на границе между проходами эту сумму нужно сбрасывать вручную, иначе градиенты разных примеров или разных эпох обучения смешаются. В следующем уроке, когда мы соберём из Value нейрон и слой, мы напишем метод zero_grad(), который перед каждым новым шагом обучения проходит по всем параметрам сети и ставит grad = 0. Если его забыть, сеть всё равно будет как-то меняться, но направление обновления параметров станет мусором, накопленным за много шагов подряд.
Проверка правильности: сравнение с PyTorch и численный градиентный чек
Любая самописная реализация обратного распространения должна проверяться против эталона. В micrograd для этого есть test_engine.py, который сравнивает результат с PyTorch.
# Фрагмент: micrograd/test/test_engine.py
def test_sanity_check():
x = Value(-4.0)
z = 2 * x + 2 + x
q = z.relu() + z * x
h = (z * z).relu()
y = h + q + q * x
y.backward()
xmg, ymg = x, y
x = torch.Tensor([-4.0]).double()
x.requires_grad = True
z = 2 * x + 2 + x
q = z.relu() + z * x
h = (z * z).relu()
y = h + q + q * x
y.backward()
xpt, ypt = x, y
# прямой проход отработал верно
assert ymg.data == ypt.data.item()
# обратный проход отработал верно
assert xmg.grad == xpt.grad.item()
Идея такая: пишем одно и то же выражение дважды, один раз через Value, другой раз через torch.Tensor с requires_grad = True. Для тензора PyTorch сам считает градиент через свой autograd при вызове y.backward(), а потом мы сверяем числа. Совпадение ymg.data == ypt.data.item() проверяет, что прямой проход (сами вычисления) совпадает, а совпадение xmg.grad == xpt.grad.item() проверяет, что обратный проход даёт тот же градиент. .item() достаёт из тензора PyTorch обычное число Python для сравнения.
Второй тест, test_more_ops, делает то же самое на более длинном выражении с большим количеством операций и допуском tol = 1e-6 вместо точного равенства, потому что при большем числе операций накапливается погрешность округления чисел с плавающей точкой.
Такой тест отличный способ проверить, что ваша реализация согласуется с проверенной библиотекой, но он требует эталона. Если вы добавляете новую операцию, для которой пока нет аналога под рукой, полезен другой метод: численный градиентный чек, не зависящий ни от какой другой библиотеки.
Идея в том, что производную можно приближённо оценить по определению: насколько меняется функция при маленьком сдвиге аргумента.
f'(x) ≈ (f(x + h) − f(x − h)) / (2h)
# Наш пример
from micrograd.engine import Value
def numerical_grad_check(f, x0, h=1e-5):
"""Сравнивает аналитический градиент Value с численной оценкой по центральной разности."""
# аналитический градиент через backward
x = Value(x0)
y = f(x)
y.backward()
analytic = x.grad
# численная оценка производной
y_plus = f(Value(x0 + h)).data
y_minus = f(Value(x0 - h)).data
numeric = (y_plus - y_minus) / (2 * h)
return analytic, numeric
analytic, numeric = numerical_grad_check(lambda x: x * x * x, 2.0)
print(analytic, numeric) # оба значения около 12.0
Центральная разность точнее, чем односторонняя (f(x+h) − f(x)) / h, потому что ошибки округления первого порядка от сдвига вперёд и назад частично сокращаются. Если аналитический и численный градиенты расходятся сильнее, чем на долю процента, скорее всего в _backward ошибка: перепутан знак, забыт множитель или вместо += стоит =.
Связь с PyTorch
Устройство autograd в PyTorch концептуально то же самое, что мы только что разобрали, разница в масштабе и структуре данных. Вместо Value, который хранит одно число, PyTorch использует Tensor, который хранит целый многомерный массив чисел (и может лежать на GPU). Вместо Python-функции _backward, написанной вручную для каждой операции, в PyTorch для каждой операции зарегистрирована функция обратного прохода на уровне внутреннего движка, написанная на C++ для скорости, но по смыслу делающая то же самое: берёт градиент, пришедший сверху, умножает на локальную производную операции, прибавляет к .grad входов.
Граф операций PyTorch тоже строится динамически во время прямого прохода, как и в micrograd: когда вы пишете z = x * y с requires_grad = True, PyTorch заводит узел графа, запоминающий, что z получился умножением x и y, точно так же, как Value запоминает _prev и _op. Вызов .backward() в PyTorch тоже делает топологический обход этого графа в обратном порядке и накапливает градиенты в .grad каждого листа, поэтому там тоже нужен optimizer.zero_grad() или tensor.grad = None между шагами обучения, ровно по той же причине, которую мы разобрали выше.
Понимание micrograd, таким образом, не просто учебное упражнение: это тот же самый механизм, который работает внутри любой современной нейросети, только записанный на 150 строк Python вместо тысяч строк оптимизированного C++ и CUDA.
Попробуйте сами
Добавьте в
Valueметодыexp()иtanh()с собственными_backward. Для exp(x) производная равна exp(x) (то есть самому значению), для tanh(x) производная равна 1 − tanh(x)². Проверьте обе реализации функциейnumerical_grad_checkиз этого урока на нескольких значениях x, убедитесь, что аналитический и численный градиенты совпадают с точностью до третьего-четвёртого знака после запятой.Постройте выражение, в котором один узел входит в результат минимум тремя разными путями, например
y = a*a + a*b + a, и убедитесь, чтоa.gradсовпадает со значением, посчитанным вручную по правилу цепочки. Затем в своей копииengine.pyзамените+=на=в__mul__и__add__и покажите, что на этом же выражении градиентa.gradстановится неверным. Опишите, какое именно слагаемое теряется.Допишите в
test_engine.py(или в отдельный файл) новый тест по образцуtest_sanity_check: придумайте своё выражение с использованием exp или tanh, вычислите его через Value и черезtorch.Tensorсrequires_grad = True, сравните значения и градиенты с допускомtol = 1e-6.
Итоги
- Правило цепочки раскладывает общую чувствительность результата к входу на произведение локальных производных по шагам, а
out.gradэто ∂L/∂out, градиент, пришедший сверху от более позднего вычисления. - Каждая операция в
Valueзнает только свою локальную производную: 1 для сложения, значение другого множителя для умножения, n·x^(n-1) для степени, 0 или 1 в зависимости от знака для ReLU. - Накопление через
+=в_backwardобязательно, когда один узел используется в нескольких местах графа: иначе более поздний вклад затирает более ранний вместо того, чтобы сложиться с ним. - Метод
backward()сортирует граф топологически через рекурсивныйbuild_topo, чтобы к моменту вызова_backwardузла егоout.gradбыл уже полностью накоплен сверху, и запускает цепочку сself.grad = 1. - Градиенты не обнуляются автоматически между вызовами
backward(), поэтому перед новым проходом их нужно сбрасывать вручную, это и естьzero_grad, который мы реализуем в следующем уроке. - Правильность обратного прохода проверяют двумя способами: сравнением с эталонной библиотекой вроде PyTorch и численным градиентным чеком через центральную разность, который не требует никакого эталона, кроме самого определения производной.
Первоисточники
- Первоисточник: видео Андрея Карпати (на английском): The spelled-out intro to neural networks and backpropagation: building micrograd
- Код урока на GitHub: karpathy/micrograd/micrograd/engine.py, karpathy/micrograd/test/test_engine.py
Видео приведены только как ссылки на первоисточник; текст урока не является их переводом или пересказом. Полные тексты лицензий: в уроке «Что дальше, лицензии и источники».