Skip to Content
🌐 Русский02 · Автоград

02 · Автоград: как градиенты текут через крошечный DAG

🌐 English · Русский · Eesti

У каждого фреймворка глубокого обучения на Земле — 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():

  1. Обойди граф в топологическом порядке (дети раньше родителей).
  2. Инициализируй loss.grad = 1.
  3. Пройди топологический список в обратном порядке и для каждого узла 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 не терял интригу). Тяни мышью, чтобы немного покрутить сцену; обзор ограничен так, чтобы граф всегда оставался читаемым.

Попробуй сам.

  1. Оставь умолчание (a + b) * c и — до нажатия Backward — предскажи все три градиента сам, по цепному правилу. Потом запускай и проверяй. (Подсказка без спойлеров: градиент c — это то, чему сейчас равно (a+b).)
  2. Набери relu(a * b) и тяни слайдеры, пока a * b не уйдёт в минус. Смотри, как каждый градиент выше ReLU схлопывается в ноль — это тот самый «мёртвый ReLU», которого ты снова встретишь в уроке 04, вживую.
  3. Собери a * a (одна и та же переменная дважды). Заметь, что градиент складывается с обоих путей — это += в обратном цикле делает свою работу.
  4. Найди выражение, в котором подталкивание переменной вверх двигает выход вниз. Какой знак у её градиента?
Last updated on