02 · Автоград: как градиенты текут через крошечный DAG
У каждого фреймворка глубокого обучения на Земле — PyTorch, JAX,
TensorFlow — в сердце одно заклинание: loss.backward(). Вызови его — и
каким-то образом каждый из миллиарда параметров узнаёт, как именно он
поучаствовал в ошибке. Этот урок расколдовывает заклинание на версии,
достаточно маленькой, чтобы за ней наблюдать: 25 строк Python и 3D-граф, в
котором ты буквально видишь, как градиенты текут назад — стрелка за
стрелкой, число за числом.
Прежде чем нырнуть. Всё здесь держится на одной идее из 00 · Основы: производная как ручка чувствительности (§4) — «если я чуть-чуть подтолкну этот вход, насколько сдвинется выход?» — и цепное правило как передаточные числа шестерёнок: чувствительности сквозь цепочку операций перемножаются. Два новых слова для расшифровки заголовка: DAG — это просто схема вычисления (стрелки направлены вперёд, циклов нет), а топологический порядок означает «посещай каждый узел после узлов, от которых он зависит» — тот порядок, в котором ты и так бы всё вычислял. Это вся обязательная теория; остальное песочница сделает видимым.
Теория
Нейросеть — это функция, составленная из множества маленьких операций: сложить, умножить, взять экспоненту, ReLU. Чтобы её обучать, нужен градиент потерь по каждому параметру. Трюк, который делает это практичным, — автоматическое дифференцирование в обратном режиме (reverse-mode automatic differentiation).
Класс Value Карпатого реализует его примерно в 25 строках. Каждый Value запоминает:
- свой скаляр
data, - список
_children, из которых он был построен, - локальный градиент
d(self) / d(child_i)для каждого потомка.
Когда ты вызываешь loss.backward():
- Обойди граф в топологическом порядке (дети раньше родителей).
- Инициализируй
loss.grad = 1. - Пройди топологический список в обратном порядке и для каждого узла
vраздайv.gradв каждого потомка по цепному правилу:child.grad += local_grad_i * v.grad.
И всё. Никакой символьной математики, никакого статического графа — граф строится на лету, пока ты делаешь прямой проход, а backward() просто проигрывает его в обратную сторону.
3D-песочница ниже даёт набрать выражение, увидеть живой DAG, который строят операции Value, и запустить либо прямой импульс (данные текут от листьев к корню), либо обратный (градиенты текут от корня к листьям). Потяни слайдер, чтобы поменять значение листа, — и весь граф пересчитается у тебя на глазах.
Как читать граф
- Раскладка. Листовые переменные стоят слева, каждая операция — справа от своих входов, корень — справа дальше всех. Сливающиеся ветви — например,
aиbобе впадают в+, а потом(a+b)иcвпадают в*— нарисованы как ветви, которые на глазах сходятся вместе, чтобы было видно: это DAG, а не цепочка. - Backward идёт постепенно. Нажми Backward — и раскрытие начнётся с корня, с
root.grad = 1, а затем потечёт наружу;g=…у каждого узла появляется только когда градиент до него реально дошёл. Никакие финальные градиенты заранее не показываются. - Цепное правило прямо на стрелках. Каждая обратная стрелка подписана как
входящий градиент × локальная производная = вклад. В примере по умолчанию узел*отправляет1 × c = 10в сторону(a+b)и1 × (a+b) = -1в сторонуc; затем(a+b)отправляет10 × 1 = 10каждому изaиb. Это цепное правило, ставшее буквальным. - Производные операции.
-и/помечены как derived: движок собирает их из примитивов (a - b— этоa + (b·-1),a / b— этоa · b^-1), так что единственный узел, который ты видишь, прячет внутри эту структуру. Градиенты при этом всё равно точные — например, правый вход-получает локальную производную-1. - Показатели степени — константы.
**принимает только числовой литерал в качестве показателя (a ** 3); он показан с меткойconst, и градиент в него не течёт. Переменный показатель вродеa ** bотклоняется, потому чтоpowв этом движке дифференцирует только основание — приняв такое выражение, он молча оставил быb.grad = 0.
Аннотированный код
Класс Value живёт в src/microgpt_annotated.py, в подразделе с пометкой autograd-value-class:
class Value:
def __init__(self, data, _children=(), _local_grads=()):
self.data = data
self.grad = 0
self._children = _children
self._local_grads = _local_grads
def __add__(self, other):
return Value(self.data + other.data, (self, other), (1, 1))
def __mul__(self, other):
return Value(self.data * other.data, (self, other), (other.data, self.data))
# ... pow, exp, log, relu identical in spirit ...
def backward(self):
topo = []
visited = set()
def build(v):
if v not in visited:
visited.add(v)
for c in v._children: build(c)
topo.append(v)
build(self)
self.grad = 1
for v in reversed(topo):
for child, local_grad in zip(v._children, v._local_grads):
child.grad += local_grad * v.gradПорт на TypeScript в src/inference/value.ts зеркалит это один в один — те же имена полей, та же семантика операций, — поэтому тесты эквивалентности могут заглядывать в обе стороны.
Песочница
Набери любое выражение с + - * / **, relu(x), exp(x), log(x), однобуквенными переменными и скобками. Жми пресет, чтобы было от чего оттолкнуться. Тяни слайдеры — меняй значения переменных. Нажми Play, чтобы импульс прокатился по графу, или мотай таймлайн вручную; переключение Forward/Backward перезапускает импульс с начала.
Каждый узел — маленький вычислительный чип: структурированная карточка показывает его value и grad (градиент показывает --, пока обратная волна до него не дошла). Два тумблера: local derivatives подписывает производные операции их раскрытием в примитивы, а final gradients раскрывает все градиенты сразу (по умолчанию выключен, чтобы пошаговый backward не терял интригу). Тяни мышью, чтобы немного покрутить сцену; обзор ограничен так, чтобы граф всегда оставался читаемым.
Попробуй сам.
- Оставь умолчание
(a + b) * cи — до нажатия Backward — предскажи все три градиента сам, по цепному правилу. Потом запускай и проверяй. (Подсказка без спойлеров: градиентc— это то, чему сейчас равно(a+b).)- Набери
relu(a * b)и тяни слайдеры, покаa * bне уйдёт в минус. Смотри, как каждый градиент выше ReLU схлопывается в ноль — это тот самый «мёртвый ReLU», которого ты снова встретишь в уроке 04, вживую.- Собери
a * a(одна и та же переменная дважды). Заметь, что градиент складывается с обоих путей — это+=в обратном цикле делает свою работу.- Найди выражение, в котором подталкивание переменной вверх двигает выход вниз. Какой знак у её градиента?