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

Класс Value и вычислительный граф

Начинаем писать с нуля свой автоград — систему, которая сама считает производные, — по мотивам micrograd Карпатого. Сегодня первая половина — число, которое помнит, из чего получено, и вычислительный граф, который строится сам.

УРОКstage-3/3-2-micrograd/01-value-graph.mdx1 ч 20 минУрок 1 из 3
После урока вы сможете
  • Объяснить, что такое автоград и почему все фреймворки глубокого обучения строят вычислительный граф
  • Реализовать класс Value с операциями +, *, **, -, /, tanh и exp, включая выражения вида 2 * a
  • Обойти граф от результата к листьям, посчитать узлы и рёбра и объяснить, почему это DAG, а не дерево
  • Связать Value с grad_fn в PyTorch и понять, что такое динамический граф (define-by-run)

В прошлом уроке вы вывели обратный проход для трёх операций: линейного слоя, ReLU и softmax + cross-entropy. В блоке современной LLM таких операций десятки: эмбеддинги, RMSNorm, RoPE, softmax внимания, SwiGLU, маски, остаточные связи. Если выводить backward для каждой руками и поддерживать его в согласии с forward, ошибки неизбежны. А каждая ошибка в градиенте — это неделя обучения, выброшенная в мусор.

Поэтому все фреймворки устроены иначе: вы пишете только прямой проход, а фреймворк записывает каждую операцию в вычислительный графвычислительный графEN computational graphСхема вычисления — узлы-операции и рёбра-значения. По нему идёт прямой проход (значения) и обратный (градиенты). и потом сам проходит по нему назад. Это называется автоматическим дифференцированием (автоградом)автоматическое дифференцирование (автоград)EN automatic differentiation (autograd)Программа записывает операции в граф и сама считает производные по цепному правилу. Для loss → параметры используют обратный режим (reverse mode) — это и есть backprop.. Автоград есть в PyTorch, JAX, PaddlePaddle и MindSpore, и именно он срабатывает в каждом loss.backward(), с которым обучают LLM.

В этом модуле мы напишем свой автоград с нуля — по мотивам micrograd Андрея Карпатого. Это около 100 строк на чистом Python, которые делают в точности то же, что torch.autograd, только для отдельных чисел, а не тензоров. План на три урока:

  1. Сегодня: класс Value, который считает и запоминает, из чего получен, — прямой проход и граф.
  2. Урок 3.2.2: backward() — автоматический обратный проход по графу.
  3. Урок 3.2.3: нейрон, слой и MLP поверх Value и обучение на настоящем датасете.

Идея: число, которое помнит своё происхождение

Заголовок раздела «Идея: число, которое помнит своё происхождение»

Обычное число в Python «забывает», откуда взялось: в d = a * b + c переменная d хранит только 4.0. Для обратного прохода этого мало: нам нужно знать, что d получено сложением из a * b и c, а a * b — умножением из a и b.

Напомним слова из урока 1.2.2. Граф — это точки, соединённые стрелками. Точки называют узлами: каждое число и каждый результат операции — свой узел. Стрелки называют рёбрами: ребро a→ea \to e значит «aa — вход операции, которая дала ee». Узлы, в которые не входит ни одна стрелка (исходные числа и параметры), — листья. Узел, из которого стрелки уже не выходят (итоговый результат, обычно loss), — корень.

Заведём класс-обёртку Value — класс, внутри которого лежит обычное число плюс дополнительные сведения (классы вы писали в уроке 0.2.3). Кроме самого числа data он хранит:

  • _prev — множество узлов, из которых он получен (у исходных чисел — пусто). Вслед за Карпатым будем называть их детьми узла: в коде micrograd этот аргумент так и называется — _children;
  • _op — строку с названием операции ('+', '*', 'tanh'…);
  • grad — производную итогового результата по этому узлу. Сегодня она всегда 0, заполнять её научимся в следующем уроке.
import math
class Value:
def __init__(self, data, _children=(), _op=""):
self.data = data
self.grad = 0.0 # понадобится в следующем уроке
self._prev = set(_children) # из каких узлов получен
self._op = _op # какой операцией
def __repr__(self):
return f"Value(data={self.data})"
def __add__(self, other): # вызывается для self + other
other = other if isinstance(other, Value) else Value(other) # число 3 заворачиваем в Value(3)
# результат: новое число + запись «получен из (self, other) операцией +»
return Value(self.data + other.data, (self, other), "+")
def __mul__(self, other): # вызывается для self * other
other = other if isinstance(other, Value) else Value(other)
return Value(self.data * other.data, (self, other), "*")
a, b, c = Value(2.0), Value(-3.0), Value(10.0)
e = a * b # Python вызывает a.__mul__(b)
d = e + c # Python вызывает e.__add__(c)
print(d, d._op)
# множество (set) не хранит порядок, поэтому для печати сортируем значения детей
print(sorted(v.data for v in d._prev))
print(sorted(v.data for v in e._prev), e._op)
Value(data=4.0) +
[-6.0, 10.0]
[-3.0, 2.0] *

Строка isinstance(other, Value) проверяет, является ли other объектом Value. Если нет (например, это просто число 3), мы заворачиваем его в Value, чтобы дальше работать одинаково.

Вычисление идёт как обычно, но каждый результат держит ссылки на своих детей — входы операции. Цепочка ссылок от d вниз до a, b, c и есть граф. Мы его не строили специально: он появился сам, пока выполнялись * и +.

Перегрузка операторов: Python работает на нас

Заголовок раздела «Перегрузка операторов: Python работает на нас»

В уроке 0.2.3 вы видели, что a + b — это вызов a.__add__(b), а a * b — вызов a.__mul__(b). Это перегрузка операторовперегрузка операторовEN operator overloading: мы определяем, что значат + и * для своего класса.

Выражение a * 2 работает: вызывается a.__mul__(2), и число 2 заворачивается в Value(2). А 2 * a? Python сначала пробует int.__mul__(2, a). Целое число не умеет умножаться на Value и возвращает NotImplemented. Тогда Python пробует «отражённый» метод правого операнда, a.__rmul__(2). Для сложения и умножения порядок не важен, поэтому оба метода просто вызывают уже готовые + и *. Это и следующие фрагменты — методы, которые дописываются внутрь класса Value (поэтому у них отступ):

def __radd__(self, other): # other + self
return self + other
def __rmul__(self, other): # other * self
return self * other

Минус, вычитание и деление — через уже готовое

Заголовок раздела «Минус, вычитание и деление — через уже готовое»

Каждую новую операцию придётся научить считать производную. Поэтому выгодно свести всё к минимальному набору примитивов:

ОперацияКак выражаемНужны правила backward для
-aa * -1*
a - ba + (-b)+, *
a / ba * b ** -1*, **
2 - a (__rsub__)Value(2) + (-a)+, *
2 / a (__rtruediv__)Value(2) * a ** -1*, **

Остаётся степень с обычным числом в показателе:

def __pow__(self, k):
assert isinstance(k, (int, float)), "степень — только обычное число"
return Value(self.data ** k, (self,), f"**{k}")

У степени один ребёнок, а показатель kk — константа, «вшитая» в название операции. Возводить в степень Value мы не разрешаем: для a ** b понадобилась бы производная по показателю, а нам она не нужна.

Для нейрона нам нужна активация. Возьмём tanh⁡\tanh, как в видео Карпатого. Она близка к сигмоиде из урока 3.1.1, но симметрична и принимает значения от −1-1 до 11:

tanh⁡x=e2x−1e2x+1\tanh x = \frac{e^{2x} - 1}{e^{2x} + 1}
def tanh(self):
return Value(math.tanh(self.data), (self,), "tanh")
def exp(self):
return Value(math.exp(self.data), (self,), "exp")

Обе операции унарные — у них один вход, а значит, один ребёнок. Обратите внимание на гранулярность — насколько крупными «кирпичами» мы записываем вычисления. tanh⁡\tanh можно было собрать из exp, +, - и /: значение то же, просто узлов больше. Какие операции считать неделимыми («атомарными») — решение инженера. В PyTorch неделимы целые операции над таблицами чисел, например softmax или scaled_dot_product_attention. Чем крупнее кирпич, тем меньше узлов, памяти и служебной работы, но тем больше ручных правил backward нужно написать.

Соберём нейрон с двумя входами — тот самый пример из видео Карпатого:

o=tanh⁡(x1w1+x2w2+b),x1=2, x2=0, w1=−3, w2=1,b=6.8813735870195432\begin{gathered} o = \tanh(x_1 w_1 + x_2 w_2 + b), \\ x_1 = 2,\ x_2 = 0,\ w_1 = -3,\ w_2 = 1, \\ b = 6.8813735870195432 \end{gathered}

Странный сдвиг bb подобран так, чтобы n=x1w1+x2w2+b≈0.8814n = x_1 w_1 + x_2 w_2 + b \approx 0.8814 и o=tanh⁡n=1/2≈0.7071o = \tanh n = 1/\sqrt 2 \approx 0.7071. В следующем уроке это даст красивые числа: 1−o2=0.51 - o^2 = 0.5.

flowchart LR
x1["x₁ = 2"] --> m1(("*"))
w1["w₁ = −3"] --> m1
x2["x₂ = 0"] --> m2(("*"))
w2["w₂ = 1"] --> m2
m1 -->|"−6"| s1(("+"))
m2 -->|"0"| s1
s1 -->|"−6"| s2(("+"))
b["b = 6.8814"] --> s2
s2 -->|"n = 0.8814"| t(("tanh"))
t --> o["o = 0.7071"]
Граф нейрона: прямоугольники — листья (входы и параметры), кружки — операции, на стрелках — значения

Посчитаем: 5 листьев (x1,x2,w1,w2,bx_1, x_2, w_1, w_2, b) и 5 операций (два умножения, два сложения, tanh⁡\tanh). Итого 10 узлов. Рёбер 9: у каждого умножения и сложения по 2 входа, у tanh⁡\tanh — один.

Чтобы посчитать узлы, напечатать граф или (в следующем уроке) пройти его назад, нужно обойти всех предков результата. Делаем это рекурсией — функцией, которая вызывает саму себя (урок 0.2.2). Идея: «чтобы обойти узел, запиши его и обойди каждого его ребёнка». Чтобы не записать один узел дважды, храним множество уже посещённых:

def trace(root):
nodes, edges = set(), set() # множества: повторы в них не попадают
def build(v):
if v not in nodes: # каждый узел — один раз, даже если он встречается многократно
nodes.add(v)
for child in v._prev:
edges.add((child, v)) # ребро «ребёнок → результат»
build(child) # спускаемся к ребёнку и обходим уже его детей
build(root)
return nodes, edges

Проследим на d = a * b + c. build(d) записывает d и идёт к детям: e = a * b и c. build(e) записывает e и идёт к a и b; у них детей нет, рекурсия возвращается. build(c) записывает c. Итог: 5 узлов (d, e, a, b, c) и 4 ребра (e→d, c→d, a→e, b→e). Такой обход называют обходом в глубину: сначала спускаемся по одной ветке до самого низа, потом возвращаемся и идём по следующей.

А вот печать графа текстом, от корня к листьям. Это тоже рекурсия: каждый уровень вглубь печатается с отступом на четыре пробела больше:

def show(v, indent=0):
"""Печатает граф от корня вниз: операция и значение каждого узла."""
label = f"{v._op} → {v.data:.4f}" if v._op else f"{v.data:.4f}" # у листьев операции нет
print(" " * indent + label)
# детей сортируем (по операции, потом по значению), чтобы печать была одинаковой при каждом запуске
for child in sorted(v._prev, key=lambda c: (c._op, c.data)):
show(child, indent + 1)
x1, x2 = Value(2.0), Value(0.0)
w1, w2 = Value(-3.0), Value(1.0)
b = Value(6.8813735870195432)
o = (x1 * w1 + x2 * w2 + b).tanh()
show(o)
nodes, edges = trace(o)
print(len(nodes), "узлов,", len(edges), "рёбер")
tanh → 0.7071
+ → 0.8814
6.8814
+ → -6.0000
* → -6.0000
-3.0000
2.0000
* → 0.0000
0.0000
1.0000
10 узлов, 9 рёбер

Граф ориентированный: рёбра идут от входов к результату. И ациклический: значение не может зависеть от самого себя. Отсюда название DAG (directed acyclic graph). Но это не дерево: один узел может участвовать в нескольких операциях. В e = a * a + a узел a — ребёнок и умножения, и сложения. Узлов при этом всего три: a, a * a и сумма. Именно поэтому trace хранит множество посещённых узлов.

В нейросетях повторное использование встречается постоянно. Один вес умножается на каждый пример батча. Вход остаточной связи идёт и в блок, и в обход блока. Матрица эмбеддингов в малых моделях служит ещё и выходным слоем. В следующем уроке выяснится, что из-за этого градиенты нужно складывать, и это одна из самых важных деталей всего автограда.

Наш граф строится по ходу выполнения обычного Python-кода. Значит, в модели можно писать любые if, for и вызовы функций. Граф запишет только то, что реально выполнилось:

# фрагмент: xs и ws — списки объектов Value одинаковой длины
total = Value(0.0)
for xi, wi in zip(xs, ws): # цикл любой длины
if xi.data != 0: # ветвление — в графе останется только выполненная ветка
total = total + xi * wi

Такой подход называют динамическим графомдинамический граф (define-by-run)EN dynamic computational graph (define-by-run)Граф строится заново при каждом выполнении кода, поэтому работают обычные if и for. Так устроены micrograd и PyTorch; противоположность — статический граф (static graph), который сначала описывают, потом запускают. (define-by-run). Так работают micrograd и PyTorch: граф пересоздаётся на каждой итерации обучения. Альтернатива — статический граф (define-and-run), как в TensorFlow 1.x: сначала описать граф целиком, потом запускать его на данных. Статический граф проще оптимизировать, зато его неудобно отлаживать. Современные системы совмещают оба подхода. Пишем динамически, а torch.compile или JAX перехватывают граф и компилируют его.

Забежим вперёд: PyTorch (модуль 3.3) устроен так же. Его числа называются тензорами, а requires_grad=True просит записывать граф. У каждого тензора, полученного операцией, есть атрибут grad_fn — ссылка на узел графа, который его создал. У узла есть next_functions — ссылки на детей (PyTorch хранит их парами «узел, номер», нам нужен только узел). Это наши _op и _prev. Если PyTorch ещё не установлен, просто прочитайте код и вывод:

import torch
x1 = torch.tensor(2.0, requires_grad=True)
x2 = torch.tensor(0.0, requires_grad=True)
w1 = torch.tensor(-3.0, requires_grad=True)
w2 = torch.tensor(1.0, requires_grad=True)
b = torch.tensor(6.8813735870195432, requires_grad=True)
n = x1 * w1 + x2 * w2 + b
o = torch.tanh(n)
def name(fn):
return type(fn).__name__ if fn is not None else None
print(round(o.item(), 4))
print(name(o.grad_fn)) # узел, который создал o
print([name(f) for f, _ in o.grad_fn.next_functions]) # его дети
print([name(f) for f, _ in n.grad_fn.next_functions])
print(x1.grad_fn, x1.is_leaf) # у листа нет grad_fn
0.7071
TanhBackward0
['AddBackward0']
['AddBackward0', 'AccumulateGrad']
None True

Названия говорят сами за себя: PyTorch хранит в графе не «операции вперёд», а функции для обратного прохода (TanhBackward0, AddBackward0). Листья-параметры представлены узлами AccumulateGrad — «накопи градиент в .grad». Флаг requires_grad=True включает запись графа. Без него PyTorch считает как обычный калькулятор.

Допишите класс Value: умножение, степень, минус, вычитание, деление, «отражённые» операции, tanh, exp и функцию обхода графа trace. Сложение дано как образец — почти все методы пишутся по его подобию или через уже готовые операции (таблица «Минус, вычитание и деление» выше). После каждого метода нажимайте «Запустить»: внизу заготовки собирается нейрон из урока, и, когда всё готово, печатается Value(data=0.707…) tanh 10 узлов, 9 рёбер.

Класс Value: прямой проход и граф7 тестов
import math


class Value:
    """Скаляр, который помнит, из чего и какой операцией он получен."""

    def __init__(self, data, _children=(), _op=""):
        self.data = data
        self.grad = 0.0                 # понадобится в следующем уроке
        self._prev = set(_children)     # «дети» узла (как у Карпатого): из каких узлов получено значение
        self._op = _op                  # какой операцией

    def __repr__(self):
        return f"Value(data={self.data})"

    def __add__(self, other):           # готовый образец
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data + other.data, (self, other), "+")

    def __mul__(self, other):
        # TODO: как __add__, только произведение и операция "*"
        ...

    def __pow__(self, k):
        # TODO: k — обычное число (int/float). Операция f"**{k}", единственный ребёнок — self
        ...

    def __neg__(self):                  # -a
        # TODO: подсказка — через умножение на -1
        ...

    def __sub__(self, other):           # a - b
        # TODO: через сложение и унарный минус
        ...

    def __truediv__(self, other):       # a / b
        # TODO: a / b = a * b**-1 (не забудьте обернуть число в Value)
        ...

    # «Отражённые» версии: Python вызывает их для 2 + a, 2 * a, 2 - a, 2 / a
    def __radd__(self, other):
        # TODO
        ...

    def __rmul__(self, other):
        # TODO
        ...

    def __rsub__(self, other):          # other - self, other — число
        # TODO
        ...

    def __rtruediv__(self, other):      # other / self, other — число
        # TODO
        ...

    def tanh(self):
        # TODO: math.tanh, операция "tanh"
        ...

    def exp(self):
        # TODO: math.exp, операция "exp"
        ...


def trace(root):
    """Обходит граф от root и возвращает (nodes, edges):
    nodes — множество всех Value в графе (включая root),
    edges — множество пар (ребёнок, результат), например (a, a*b).
    """
    # TODO: рекурсивный обход по v._prev с множеством посещённых узлов
    ...


# Проверьте себя кнопкой «Запустить»: нейрон из урока, ожидается Value(data≈0.7071) tanh 10 узлов, 9 рёбер
try:
    x1, x2 = Value(2.0), Value(0.0)
    w1, w2 = Value(-3.0), Value(1.0)
    b = Value(6.8813735870195432)
    o = (x1 * w1 + x2 * w2 + b).tanh()
    result = trace(o)
    if result is not None:
        nodes, edges = result
        print(o, o._op, len(nodes), "узлов,", len(edges), "рёбер")
except Exception as e:  # пока не все методы готовы
    print("Ещё не работает:", type(e).__name__, e)

Когда задание решено, скопируйте свой класс Value и trace в файл и добавьте:

import random
random.seed(0) # фиксируем случайность, чтобы числа совпали с нашими
def neuron(x, n_in):
"""Нейрон tanh(w·x) со случайными весами из [-1, 1]; x — список Value."""
w = [Value(random.uniform(-1, 1)) for _ in range(n_in)]
act = Value(0.0)
for wi, xi in zip(w, x):
act = act + wi * xi # каждый шаг добавляет в граф два узла: * и +
return act.tanh()
x = [Value(1.0), Value(-2.0), Value(0.5)]
h1 = [neuron(x, 3) for _ in range(16)] # слой 3 → 16
h2 = [neuron(h1, 16) for _ in range(16)] # слой 16 → 16
out = neuron(h2, 16) # выход 16 → 1
nodes, edges = trace(out)
print(len(nodes), "узлов,", len(edges), "рёбер")
1029 узлов, 1313 рёбер

Один пример через крошечную сеть с 320 весами — это уже тысяча объектов Python. Батч из 32 примеров (веса общие, остальное своё у каждого примера) даст около 23 тысяч. В PyTorch граф того же прохода — около десятка узлов: три матричных умножения и три tanh над целыми тензорами плюс узлы для трёх матриц весов. Скалярный автоград идеален для понимания, а для скорости нужны тензоры — к ним перейдём в модуле 3.3.

Проверь себя5 вопросов
1 Что произойдёт при вычислении 2 * a, где a — объект Value?
2 Сколько узлов в графе выражения d = a * b + c, где a, b, c — листья?
3 А сколько узлов в графе e = a * a + a?
4 Какие утверждения верны?Можно выбрать несколько ответов
5 Зачем выражать a / b через a * b ** -1, а не писать отдельную операцию деления?
  • Автоград: вы пишете только прямой проход, а фреймворк записывает граф операций и сам считает производные.
  • Value — число с памятью: data, дети _prev (входы операции), операция _op. Граф появляется сам, пока выполняются + и *.
  • Перегрузка операторов и «отражённые» методы делают Value похожим на обычное число. Сложные операции выражаем через минимальный набор примитивов: +, *, **, tanh, exp.
  • Граф — DAG: узлы переиспользуются, поэтому обход хранит множество посещённых. Граф динамический: он строится при выполнении кода, как в PyTorch (grad_fn).

Дальше: научим Value считать производные. Локальные производные каждой операции, топологическая сортировка графа, метод backward() и правило +=, без которого автоград врёт.