Класс Value и вычислительный граф
Начинаем писать с нуля свой автоград — систему, которая сама считает производные, — по мотивам micrograd Карпатого. Сегодня первая половина — число, которое помнит, из чего получено, и вычислительный граф, который строится сам.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В прошлом уроке вы вывели обратный проход для трёх операций: линейного слоя, 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, только для отдельных чисел, а не тензоров.
План на три урока:
- Сегодня: класс
Value, который считает и запоминает, из чего получен, — прямой проход и граф. - Урок 3.2.2:
backward()— автоматический обратный проход по графу. - Урок 3.2.3: нейрон, слой и MLP поверх
Valueи обучение на настоящем датасете.
Идея: число, которое помнит своё происхождение
Заголовок раздела «Идея: число, которое помнит своё происхождение»Обычное число в Python «забывает», откуда взялось: в d = a * b + c переменная d хранит только 4.0. Для
обратного прохода этого мало: нам нужно знать, что d получено сложением из a * b и c, а a * b —
умножением из a и b.
Напомним слова из урока 1.2.2. Граф — это точки, соединённые стрелками. Точки называют узлами: каждое число и каждый результат операции — свой узел. Стрелки называют рёбрами: ребро значит « — вход операции, которая дала ». Узлы, в которые не входит ни одна стрелка (исходные числа и параметры), — листья. Узел, из которого стрелки уже не выходят (итоговый результат, обычно 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: мы определяем, что значат + и * для своего класса.
Числа слева: __radd__ и __rmul__
Заголовок раздела «Числа слева: __radd__ и __rmul__»Выражение 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 для |
|---|---|---|
-a | a * -1 | * |
a - b | a + (-b) | +, * |
a / b | a * 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}")У степени один ребёнок, а показатель — константа, «вшитая» в название операции. Возводить в степень Value
мы не разрешаем: для a ** b понадобилась бы производная по показателю, а нам она не нужна.
Нелинейности: tanh и exp
Заголовок раздела «Нелинейности: tanh и exp»Для нейрона нам нужна активация. Возьмём , как в видео Карпатого. Она близка к сигмоиде из урока 3.1.1, но симметрична и принимает значения от до :
def tanh(self): return Value(math.tanh(self.data), (self,), "tanh")
def exp(self): return Value(math.exp(self.data), (self,), "exp")Обе операции унарные — у них один вход, а значит, один ребёнок. Обратите внимание на гранулярность — насколько
крупными «кирпичами» мы записываем вычисления. можно было собрать из exp, +, - и /: значение то же,
просто узлов больше. Какие операции считать неделимыми («атомарными») — решение инженера. В PyTorch неделимы целые
операции над таблицами чисел, например softmax или scaled_dot_product_attention. Чем крупнее кирпич, тем меньше
узлов, памяти и служебной работы, но тем больше ручных правил backward нужно написать.
Вычислительный граф нейрона
Заголовок раздела «Вычислительный граф нейрона»Соберём нейрон с двумя входами — тот самый пример из видео Карпатого:
Странный сдвиг подобран так, чтобы и . В следующем уроке это даст красивые числа: .
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 листьев () и 5 операций (два умножения, два сложения, ). Итого 10 узлов. Рёбер 9: у каждого умножения и сложения по 2 входа, у — один.
Обход графа
Заголовок раздела «Обход графа»Чтобы посчитать узлы, напечатать граф или (в следующем уроке) пройти его назад, нужно обойти всех предков результата. Делаем это рекурсией — функцией, которая вызывает саму себя (урок 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.000010 узлов, 9 рёберПочему это DAG, а не дерево
Заголовок раздела «Почему это DAG, а не дерево»Граф ориентированный: рёбра идут от входов к результату. И ациклический: значение не может зависеть от
самого себя. Отсюда название DAG (directed acyclic graph). Но это не дерево: один узел может участвовать в
нескольких операциях. В e = a * a + a узел a — ребёнок и умножения, и сложения. Узлов при этом всего три: a,
a * a и сумма. Именно поэтому trace хранит множество посещённых узлов.
В нейросетях повторное использование встречается постоянно. Один вес умножается на каждый пример батча. Вход остаточной связи идёт и в блок, и в обход блока. Матрица эмбеддингов в малых моделях служит ещё и выходным слоем. В следующем уроке выяснится, что из-за этого градиенты нужно складывать, и это одна из самых важных деталей всего автограда.
Динамический граф: define-by-run
Заголовок раздела «Динамический граф: define-by-run»Наш граф строится по ходу выполнения обычного 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
Заголовок раздела «То же самое в PyTorch»Забежим вперёд: 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 + bo = 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)) # узел, который создал oprint([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_fn0.7071TanhBackward0['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 рёбер.
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 → 16h2 = [neuron(h1, 16) for _ in range(16)] # слой 16 → 16out = neuron(h2, 16) # выход 16 → 1nodes, edges = trace(out)print(len(nodes), "узлов,", len(edges), "рёбер")1029 узлов, 1313 рёберОдин пример через крошечную сеть с 320 весами — это уже тысяча объектов Python. Батч из 32 примеров (веса общие,
остальное своё у каждого примера) даст около 23 тысяч. В PyTorch граф того же прохода — около десятка узлов: три
матричных умножения и три tanh над целыми тензорами плюс узлы для трёх матриц весов.
Скалярный автоград идеален для понимания, а для скорости нужны тензоры — к ним перейдём в модуле 3.3.
Проверь себя
Заголовок раздела «Проверь себя»- Автоград: вы пишете только прямой проход, а фреймворк записывает граф операций и сам считает производные.
Value— число с памятью:data, дети_prev(входы операции), операция_op. Граф появляется сам, пока выполняются+и*.- Перегрузка операторов и «отражённые» методы делают
Valueпохожим на обычное число. Сложные операции выражаем через минимальный набор примитивов:+,*,**,tanh,exp. - Граф — DAG: узлы переиспользуются, поэтому обход хранит множество посещённых. Граф динамический: он строится
при выполнении кода, как в PyTorch (
grad_fn).
Дальше: научим Value считать производные. Локальные производные каждой операции, топологическая сортировка
графа, метод backward() и правило +=, без которого автоград врёт.