Перейти к содержимому
Ступень 33.2 Свой автоград (micrograd)

Автоматическое дифференцирование

Учим наш класс Value самому считать производные — каждая операция знает свою маленькую производную, граф обходится в правильном порядке, а вклады нескольких путей складываются (+=). Ровно так работает loss.backward() в PyTorch.

УРОКstage-3/3-2-micrograd/02-backward.mdx1 ч 30 минУрок 2 из 3
После урока вы сможете
  • Записать локальную производную для каждой операции Value и реализовать её в замыкании _backward
  • Объяснить, зачем нужна топологическая сортировка, и построить её рекурсивно и без рекурсии
  • Реализовать backward() и проверить его численно и сравнением с PyTorch
  • Объяснить, почему градиенты накапливаются через += и где это проявляется в настоящих LLM

В прошлом уроке Value научился запоминать граф. Теперь научим его главному — самому считать производные. Мы напишем метод backward(). Вызовите его у результата, и в поле .grad каждого узла графа окажется ∂L/∂узел\partial L / \partial \text{узел}. Это обратное распространение ошибкиобратное распространение ошибкиEN backpropagation из урока 3.1.2, но автоматический и для любого графа: хоть нейрон, хоть трансформер.

Механизм состоит из трёх идей. Каждая занимает несколько строк кода, и каждая встречается в PyTorch:

  1. Локальная производная: каждая операция знает только свою производную.
  2. Топологический порядок: узлы обходят от результата к листьям, причём каждый — только после всех его «потребителей».
  3. Накопление (+=): если узел используется несколько раз, вклады складываются.

Возьмём выражение из видео Карпатого:

e=a⋅b,d=e+c,L=d⋅f,a=2, b=−3, c=10, f=−2\begin{gathered} e = a \cdot b, \quad d = e + c, \quad L = d \cdot f, \\ a = 2,\ b = -3,\ c = 10,\ f = -2 \end{gathered}

Вперёд: e=−6e = -6, d=4d = 4, L=−8L = -8. Назад идём от LL, на каждом шаге умножая градиент сверху на локальную производную (цепное правилоцепное правилоEN chain ruleПроизводная композиции — произведение производных звеньев — (f(g(x)))′ = f′(g(x))·g′(x). Математическая основа backprop.):

УзелКак считаемgrad
LL∂L/∂L\partial L / \partial L1
ff∂(df)/∂f=d\partial (d f) / \partial f = d, умножить на 14
dd∂(df)/∂d=f\partial (d f) / \partial d = f, умножить на 1−2-2
ccсложение передаёт градиент как есть: 1⋅(−2)1 \cdot (-2)−2-2
eeто же самое−2-2
aa∂(ab)/∂a=b\partial (a b) / \partial a = b, умножить на −2-26
bb∂(ab)/∂b=a\partial (a b) / \partial b = a, умножить на −2-2−4-4

Проверим смысл одного числа. a.grad=6a.\text{grad} = 6 означает, что при a=2.001a = 2.001 значение LL вырастет примерно на 0.0060.006. Так и есть: L=(2.001⋅(−3)+10)⋅(−2)=−7.994L = (2.001 \cdot (-3) + 10) \cdot (-2) = -7.994.

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

Заголовок раздела «Локальная производная: каждый узел знает только себя»

Заметьте: чтобы посчитать градиент узла, нужны только градиент результата (out.grad) и локальная производная операции. Остальной граф не важен. Поэтому каждая операция может сама сказать, как распределить градиент между своими входами:

ОперацияВперёдЧто добавить к градиентам входов
сложениеa+ba + baa: out.grad, bb: out.grad
умножениеa⋅ba \cdot baa: b.data * out.grad, bb: a.data * out.grad
степеньxkx^kk xk−1⋅k\, x^{k-1} \cdot out.grad
tanht=tanh⁡xt = \tanh x(1−t2)⋅(1 - t^2) \cdot out.grad
expexe^xex⋅e^x \cdot out.grad (это уже out.data)
ReLUmax⁡(0,x)\max(0, x)out.grad, если x>0x > 0, иначе 0

Полезные метафоры из курса CS231n: сложение раздаёт градиент обоим входам без изменений, умножение меняет местами: вход aa получает градиент, умноженный на bb, и наоборот. ReLU — маршрутизатор: пропускает градиент только через активный вход.

Каждая операция при создании результата кладёт в него маленькую функцию _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=a⋅bc = a \cdot b при a=5a = 5, b=−2b = -2. Пусть к моменту вызова c._backward() в c.grad лежит 4. Тогда к a.grad прибавится b.data * c.grad =−2⋅4=−8= -2 \cdot 4 = -8, а к b.grad — a.data * c.grad =5⋅4=20= 5 \cdot 4 = 20.

Почему внутри +=, а не =, — главный вопрос урока. Ответим на него ниже.

У узла nn нельзя вызвать _backward, пока в n.grad не собрались вклады от всех узлов, которые используют nn. Иначе узел передаст дальше неполный градиент.

Простой обход «в ширину» — по уровням: сначала корень, потом все его дети, потом дети детей и так далее — эту гарантию не даёт. Пример: x=3x = 3, a=x2a = x^2 и

L=3⋅(2a)+a=7a,∂L∂a=7,∂L∂x=7⋅2x=42L = 3 \cdot (2a) + a = 7a, \qquad \frac{\partial L}{\partial a} = 7, \qquad \frac{\partial L}{\partial x} = 7 \cdot 2x = 42
flowchart LR
x["x = 3"] --> a(("a = x·x"))
a --> m2(("·2"))
m2 --> m3(("·3"))
m3 --> L(("+ = L"))
a --> L
Узел a нужен двум операциям: короткий путь в L напрямую и длинный — через ·2 и ·3

Обход в ширину от LL дойдёт до aa по короткому пути раньше, чем по длинному. В этот момент в aa лежит только вклад 1 из 7. Если сразу вызвать a._backward(), то xx получит 1⋅2x=61 \cdot 2x = 6 вместо 7⋅2x=427 \cdot 2x = 42, а поздний вклад 6 так и останется в a.grad, не дойдя до xx.

Решение — топологическая сортировкатопологическая сортировка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]. Задом наперёд: LL, ⋅3\cdot 3, ⋅2\cdot 2, aa, xx. Проследим градиенты по этому порядку:

ШагВызываем _backward уЧто происходитГрадиенты после шага
1L=(⋅3)+aL = (\cdot 3) + aсложение раздаёт 1 обоим входам(·3).grad = 1, a.grad = 1
2⋅3\cdot 3умножение на 3(·2).grad = 3
3⋅2\cdot 2умножение на 2a.grad = 1 + 6 = 7
4a=x⋅xa = x \cdot xxx — оба входа умножения: 3⋅7+3⋅73 \cdot 7 + 3 \cdot 7x.grad = 42

К моменту обработки aa (шаг 4) оба вклада, 1 и 6, уже на месте, и xx получает правильные 42.

Вернёмся к +=. Возьмём самый короткий пример: b=a+ab = a + a. Производная ∂b/∂a=2\partial b / \partial a = 2. Посмотрим, что делает _backward сложения, где self и other — один и тот же объект a:

self.grad = out.grad # a.grad = 1
other.grad = out.grad # a.grad = 1 — перезаписали! Ответ 1 вместо 2

С += получается 0+1+1=20 + 1 + 1 = 2. Это не трюк, а многомерное цепное правило: если величина влияет на результат по нескольким путям, её производная — сумма производных по всем путям:

∂L∂a=∑пути a→L∏рёбра на пути(локальная производная)\frac{\partial L}{\partial a} = \sum_{\text{пути } a \to L} \prod_{\text{рёбра на пути}} (\text{локальная производная})

Читается так: перечислите все пути по стрелкам от aa до LL; вдоль каждого пути перемножьте локальные производные (∏\prod — знак произведения из урока 1.0.3); сложите результаты по всем путям (∑\sum).

Это то же правило ветвления, что в уроке 1.2.2: там вы складывали вклады двух веток руками, а теперь это делает +=. В примере с x2x^2 это 1+2⋅3=71 + 2 \cdot 3 = 7. Карпатый в видео сначала пишет = и ловит ровно этот баг на a + a. Это хорошее напоминание: автоград из двадцати строк легко сделать почти правильным.

Накопление градиентовнакопление градиентовEN gradient accumulationГрадиенты складываются (+=), а не перезаписываются. Внутри графа так учитываются все пути к узлу; на уровне обучения — шаг оптимизатора делают раз в несколько микробатчей. — не экзотика. В нейросетях узлы, которые используются многократно, повсюду:

  • Веса и батч. Одна матрица WW применяется к каждому примеру батча. Её градиент — сумма вкладов примеров. Поэтому в уроке 3.1.2 получилось dW=X⊤dYdW = X^\top dY: матричное умножение суммирует по примерам.
  • Позиции в трансформере. Одни и те же WQW_Q, WKW_K, WVW_V и веса FFN применяются к каждому токену последовательности. Градиент собирается со всех позиций.
  • Остаточные связи. В y=x+Block(x)y = x + \mathrm{Block}(x) вход xx идёт по двум путям, и градиент к нему приходит по обоим (урок 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 * a
y.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.0
12.0
Trying to backward through the graph a second time

Поэтому в цикле обучения перед каждым backward() градиенты обнуляют (zero_grad). Иначе шаг делается по сумме градиентов всех прошлых шагов. Этим займёмся в следующем уроке. Заодно видно отличие от micrograd: PyTorch после backward() освобождает граф. Хотите пройти его второй раз — попросите retain_graph=True.

Соберём сигмоиду из примитивов — exp, +, ** и умножения на −1-1 — и сверим автоград с формулой σ′(z)=σ(z)(1−σ(z))\sigma'(z) = \sigma(z)(1 - \sigma(z)) из уроков 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.5
s = 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.0759
autograd: ds/dw = -0.1402
формула σ(1 − σ)·x = -0.1402

Автоград прошёл через семь операций (умножение, сложение, минус — то есть умножение на −1-1, exp, ещё сложение, степень −1-1 и умножение на 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 после задания. Сверьте: 1−o2=1−0.5=0.51 - o^2 = 1 - 0.5 = 0.5 — это градиент nn и bb. Дальше умножения меняют местами: x1x_1 получает w1⋅0.5=−1.5w_1 \cdot 0.5 = -1.5, а w1w_1 получает x1⋅0.5=1x_1 \cdot 0.5 = 1. У w2w_2 градиент ноль, потому что x2=0x_2 = 0: вес, умноженный на нулевой вход, на выход не влияет.

Допишите локальные производные для *, **, tanh, exp, relu и метод backward(). Сложение дано как образец. Тесты проверят нейрон из урока, повторное использование узлов, порядок обхода на примере с x2x^2, численные производные и длинную цепочку из 200 операций.

Каждое _backward — одна-две строки по таблице «Локальная производная». Шаблон всегда один: вход.grad += (локальная производная) * out.grad. Кнопка «Запустить» печатает градиенты нейрона из урока; правильный ответ: x1 -1.5, w1 1.0, x2 0.5, w2 0.0, b 0.5.

backward(): автоматическое дифференцирование8 тестов
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))
Проверь себя6 вопросов
1 c = a * b, где a.data = 4, b.data = −2, и к моменту вызова c._backward() в c.grad лежит 3. Что добавится к a.grad?
2 o = n.tanh(), o.data = 0.6, o.grad = 2. На сколько увеличится n.grad?
3 x=3x = 3, y = x * x + x. Чему равен x.grad после y.backward()?
4 Почему _backward узлов нельзя вызывать в произвольном порядке?
5 Какие утверждения верны?Можно выбрать несколько ответов
6 Почему рекурсивная топологическая сортировка падает на цепочке из 3000 операций?
  • Каждая операция знает только свою локальную производную и хранит её в замыкании _backward. Сложение раздаёт градиент, умножение меняет местами, tanh масштабирует на 1−t21 - t^2.
  • backward(): топологическая сортировка, затем grad = 1 в корне и _backward() у узлов в обратном порядке. Так каждый узел передаёт дальше полный градиент.
  • += — это многомерное цепное правило: вклады всех путей складываются. Отсюда суммы по батчу, по позициям, по двум ветвям остаточной связи и по двум ролям общих эмбеддингов.
  • Обратная сторона +=: .grad копит всё, поэтому перед каждым обратным проходом его обнуляют. PyTorch ведёт себя так же.

Дальше: соберём из Value нейрон, слой и MLP, напишем цикл обучения с zero_grad и научим сеть различать две «луны» с точностью выше 95%.