Автоматическое дифференцирование: свой движок
Что мы строим
В прошлом уроке мы считали градиенты по графу вручную. Это надёжно, но для сети с тысячами операций невозможно. Задача автоматического дифференцирования: пользователь пишет обычные формулы, а библиотека сама строит граф и сама считает градиенты. Так работают PyTorch, TensorFlow и JAX.
Сделаем свой такой движок. Идея простая: вместо чисел используем объекты Value, которые:
- хранят число (
data) и градиент (grad); - помнят, из каких
Valueи какой операцией они получены (_prev,_op) — так при обычных вычислениях сам собой строится граф; - умеют пробросить градиент своим входам (
_backward) — каждая операция знает свою локальную производную.
Этот урок повторяет идею знаменитого проекта micrograd Андрея Карпаты: весь движок — около 50 строк.
Шаг 1: значение и сложение
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:.4g}, grad={self.grad:.4g})"
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
# сложение раздаёт градиент без изменений
self.grad += out.grad
other.grad += out.grad
out._backward = _backward
return out
a, b = Value(2.0), Value(3.0)
c = a + b
print(c, c._op, c._prev)
c.grad = 1.0 # ∂c/∂c = 1
c._backward()
print(a, b) # оба получили градиент 1
Ключевой момент: _backward — замыкание. Функция создаётся внутри __add__ и запоминает self, other и out. Позже, при обратном проходе, она возьмёт out.grad и раздаст его входам.
Почему +=
Если значение используется в графе несколько раз (например, a + a), его градиент — сумма вкладов всех использований. Присваивание = затёрло бы предыдущие вклады.
Шаг 2: умножение, степень, вычитание, деление
Умножение: \frac{\partial (ab)}{\partial a} = b, \frac{\partial (ab)}{\partial b} = a. Степень: \frac{d x^k}{dx} = k x^{k-1}. Остальные операции выражаются через эти: a - b = a + (-1)\cdot b, a / b = a \cdot b^{-1}. Чтобы работало 2 * a и 1 + a (число слева), нужны «отражённые» методы __rmul__, __radd__.
Шаг 3: функции активации
Для \tanh производная 1 - \tanh^2 x — её удобно считать через уже вычисленное значение. Для ReLU — 1 или 0. Для e^x — само e^x.
Шаг 4: обратный проход по всему графу
Осталось правильно обойти граф. Градиент узла готов, только когда в него пришли вклады от всех узлов, которые его используют. Поэтому узлы упорядочивают топологически (каждый узел после всех своих входов) и вызывают _backward в обратном порядке — от выхода к входам.
Весь движок
Соберём всё вместе. Этот код мы будем использовать до конца урока:
import math
class Value:
def __init__(self, data, _children=(), _op="", label=""):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(_children)
self._op = _op
self.label = label
def __repr__(self):
return f"Value(data={self.data:.4g}, grad={self.grad:.4g})"
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
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():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def __pow__(self, k):
out = Value(self.data ** k, (self,), f"**{k}")
def _backward():
self.grad += k * self.data ** (k - 1) * 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
def relu(self):
out = Value(max(0.0, self.data), (self,), "relu")
def _backward():
self.grad += (self.data > 0) * out.grad
out._backward = _backward
return out
def exp(self):
e = math.exp(self.data)
out = Value(e, (self,), "exp")
def _backward():
self.grad += e * out.grad
out._backward = _backward
return out
def __neg__(self): return self * -1
def __sub__(self, other): return self + (-other)
def __truediv__(self, other): return self * (other if isinstance(other, Value) else Value(other)) ** -1
def __radd__(self, other): return self + other
def __rmul__(self, other): return self * other
def __rsub__(self, other): return Value(other) - self
def __rtruediv__(self, other): return Value(other) / self
def backward(self):
topo, seen = [], set()
def build(v):
if v not in seen:
seen.add(v)
for child in v._prev:
build(child)
topo.append(v)
build(self)
self.grad = 1.0
for v in reversed(topo):
v._backward()
# пример из прошлого урока: нейрон с ReLU и квадратичной потерей
x1, x2 = Value(2.0, label="x₁"), Value(-1.0, label="x₂")
w1, w2, c = Value(0.5, label="w₁"), Value(0.3, label="w₂"), Value(0.1, label="c")
s = w1 * x1 + w2 * x2 + c
L = (s.relu() - 1.0) ** 2
L.backward()
print("L =", round(L.data, 4))
print("∂L/∂w₁ =", w1.grad, " ∂L/∂w₂ =", w2.grad, " ∂L/∂c =", c.grad)
Те же числа, что мы получили вручную: -0.8, 0.4, -0.4. Только теперь мы просто написали формулу и вызвали backward().
Смотрим на граф
Граф строится сам. Нарисуем его, обойдя _prev:
import math, vis
class Value:
def __init__(self, data, _children=(), _op="", label=""):
self.data, self.grad, self._backward = data, 0.0, lambda: None
self._prev, self._op, self.label = set(_children), _op, label
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
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():
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
def backward(self):
topo, seen = [], set()
def build(v):
if v not in seen:
seen.add(v)
for ch in v._prev: build(ch)
topo.append(v)
build(self)
self.grad = 1.0
for v in reversed(topo): v._backward()
def draw(root, title=""):
nodes, edges, seen = {}, [], set()
def walk(v):
if id(v) in seen: return
seen.add(id(v))
name = v.label or v._op
nodes[id(v)] = f"{name}\ndata {v.data:.3g}\ngrad {v.grad:.3g}"
for ch in v._prev:
walk(ch)
edges.append((id(ch), id(v)))
walk(root)
vis.graph(nodes, edges, title=title)
x1, x2 = Value(2.0, label="x₁"), Value(0.0, label="x₂")
w1, w2, b = Value(-3.0, label="w₁"), Value(1.0, label="w₂"), Value(6.88, label="b")
n = x1 * w1 + x2 * w2 + b
n.label = "n"
o = n.tanh(); o.label = "o = tanh(n)"
o.backward()
draw(o, "Нейрон o = tanh(x₁w₁ + x₂w₂ + b) после backward()")
Посмотрите на градиенты: у w_2 он равен 0, потому что x_2 = 0 — этот вес не повлиял на результат. Градиент w_1 равен x_1 \cdot \frac{\partial o}{\partial n}.
Проверка: gradient check
Любой движок дифференцирования нужно проверять численной производной. Сравним на сложной формуле:
import math
class Value:
def __init__(self, data, _children=(), _op=""):
self.data, self.grad, self._backward, self._prev = data, 0.0, lambda: None, set(_children)
def __add__(self, o):
o = o if isinstance(o, Value) else Value(o)
out = Value(self.data + o.data, (self, o))
def bw(): self.grad += out.grad; o.grad += out.grad
out._backward = bw; return out
def __mul__(self, o):
o = o if isinstance(o, Value) else Value(o)
out = Value(self.data * o.data, (self, o))
def bw(): self.grad += o.data * out.grad; o.grad += self.data * out.grad
out._backward = bw; return out
def __pow__(self, k):
out = Value(self.data ** k, (self,))
def bw(): self.grad += k * self.data ** (k - 1) * out.grad
out._backward = bw; return out
def exp(self):
out = Value(math.exp(self.data), (self,))
def bw(): self.grad += out.data * out.grad
out._backward = bw; return out
__radd__, __rmul__ = __add__, __mul__
def __truediv__(self, o): return self * (o if isinstance(o, Value) else Value(o)) ** -1
def __rtruediv__(self, o): return Value(o) * self ** -1
def backward(self):
topo, seen = [], set()
def build(v):
if v not in seen:
seen.add(v); [build(c) for c in v._prev]; topo.append(v)
build(self); self.grad = 1.0
for v in reversed(topo): v._backward()
def f(x, y):
sig = 1 / (1 + (x * y * -1).exp()) # сигмоида от x·y
return sig * x ** 2 + y / x
x, y = Value(1.5), Value(-0.7)
out = f(x, y)
out.backward()
h = 1e-6
fx = lambda a, b: f(Value(a), Value(b)).data
print(f"∂/∂x: движок {x.grad:.8f}, численно {(fx(1.5 + h, -0.7) - fx(1.5 - h, -0.7)) / (2 * h):.8f}")
print(f"∂/∂y: движок {y.grad:.8f}, численно {(fx(1.5, -0.7 + h) - fx(1.5, -0.7 - h)) / (2 * h):.8f}")
Обучаем нейросеть на своём движке
Соберём из Value нейрон, слой и маленькую сеть — и обучим её классифицировать точки. Это настоящая нейросеть, и каждый градиент в ней посчитан нашим движком.
import math, random
import numpy as np
import vis
import nanotorch as nt
class Value:
def __init__(self, data, _children=()):
self.data, self.grad, self._backward, self._prev = data, 0.0, lambda: None, _children
def __add__(self, o):
o = o if isinstance(o, Value) else Value(o)
out = Value(self.data + o.data, (self, o))
def bw(): self.grad += out.grad; o.grad += out.grad
out._backward = bw; return out
def __mul__(self, o):
o = o if isinstance(o, Value) else Value(o)
out = Value(self.data * o.data, (self, o))
def bw(): self.grad += o.data * out.grad; o.grad += self.data * out.grad
out._backward = bw; return out
def tanh(self):
t = math.tanh(self.data); out = Value(t, (self,))
def bw(): self.grad += (1 - t * t) * out.grad
out._backward = bw; return out
__radd__, __rmul__ = __add__, __mul__
def __sub__(self, o): return self + (o * -1 if isinstance(o, Value) else -o)
def backward(self):
topo, seen = [], set()
def build(v):
if id(v) not in seen:
seen.add(id(v)); [build(c) for c in v._prev]; topo.append(v)
build(self); self.grad = 1.0
for v in reversed(topo): v._backward()
random.seed(0)
class Neuron:
def __init__(self, n_in):
self.w = [Value(random.uniform(-1, 1)) for _ in range(n_in)]
self.b = Value(0.0)
def __call__(self, x):
return sum((wi * xi for wi, xi in zip(self.w, x)), self.b).tanh()
def parameters(self):
return self.w + [self.b]
class Layer:
def __init__(self, n_in, n_out):
self.neurons = [Neuron(n_in) for _ in range(n_out)]
def __call__(self, x):
return [n(x) for n in self.neurons]
def parameters(self):
return [p for n in self.neurons for p in n.parameters()]
class MLP:
def __init__(self, sizes):
self.layers = [Layer(a, b) for a, b in zip(sizes, sizes[1:])]
def __call__(self, x):
for layer in self.layers:
x = layer(x)
return x[0]
def parameters(self):
return [p for l in self.layers for p in l.parameters()]
X, y = nt.make_moons(60, noise=0.1, seed=1)
Y = 2 * y - 1 # метки −1 и +1 под tanh
model = MLP([2, 8, 8, 1])
print("параметров:", len(model.parameters()))
losses = []
for step in range(60):
preds = [model(list(x)) for x in X]
loss = sum(((p - float(t)) * (p - float(t)) for p, t in zip(preds, Y)), Value(0.0)) * (1 / len(X))
for p in model.parameters():
p.grad = 0.0 # обнуляем накопленные градиенты!
loss.backward()
lr = 0.5 if step < 40 else 0.2
for p in model.parameters():
p.data -= lr * p.grad # шаг градиентного спуска
losses.append(loss.data)
if step % 15 == 0 or step == 59:
acc = np.mean([(p.data > 0) == (t > 0) for p, t in zip(preds, Y)])
print(f"шаг {step:2}: loss = {loss.data:.4f}, точность = {acc:.0%}")
vis.line(losses, title="Обучение сети на своём движке", xlabel="шаг", ylabel="MSE")
vis.boundary(lambda pts: [int(model(list(p)).data > 0) for p in pts], X, y, title="Граница решений", resolution=30)
Работает! Но медленно: каждое число — отдельный объект Python, и для сети из 100 параметров и 60 точек на каждом шаге создаются десятки тысяч объектов. Настоящие библиотеки работают не с числами, а с тензорами — целыми массивами. Одна операция X @ W заменяет тысячи Value-умножений, а считается в NumPy на C. Это и есть следующий шаг: превращаем наш движок в библиотеку тензоров nanotorch.
Итог
- Автоматическое дифференцирование: операции над
Valueсами строят граф, а каждая операция знает свою локальную производную. _backward— замыкание, которое раздаётout.gradвходам по правилу цепочки; градиенты накапливаются через+=.backward(): топологическая сортировка и вызов_backwardв обратном порядке.- Градиенты нужно обнулять перед каждым шагом обучения.
- Движок на скалярах работает, но медленно; дальше — тензоры.
Практика решено 0 из 5
Зачем перед обратным проходом нужна топологическая сортировка узлов графа?
Почему в методах _backward градиенты прибавляются (+=), а не присваиваются?
Умножение и степень. Ниже — заготовка класса Value с готовым сложением. Допишите:
__mul__: результатself.data * other.data, в_backward:self.grad += other.data * out.grad,other.grad += self.data * out.grad;__pow__(self, k)для числаk: локальная производная k \cdot x^{k-1}.
Метод backward() уже написан.
Какие тесты проверяют код (3)
x, y, z = Value(-2.0), Value(5.0), Value(-4.0)
f = (x + y) * z
f.backward()
assert f.data == -12, "Прямой проход: (−2 + 5)·(−4) = −12"
assert (x.grad, y.grad, z.grad) == (-4, -4, 3), f"Градиенты {(x.grad, y.grad, z.grad)}, ожидалось (−4, −4, 3)"
a = Value(3.0)
b = a * a + a ** 3
b.backward()
assert b.data == 36 and abs(a.grad - (2 * 3 + 3 * 9)) < 1e-12, f"d(a² + a³)/da при a = 3 равно 33, получено {a.grad}"
w, x = Value(0.5), Value(2.0)
L = (w * x + 1.0) ** 2
L.backward()
assert abs(w.grad - 2 * (0.5 * 2 + 1) * 2) < 1e-12
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 __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
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():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def __pow__(self, k):
out = Value(self.data ** k, (self,), f"**{k}")
def _backward():
self.grad += k * self.data ** (k - 1) * out.grad
out._backward = _backward
return out
def backward(self):
topo, seen = [], set()
def build(v):
if v not in seen:
seen.add(v)
for child in v._prev:
build(child)
topo.append(v)
build(self)
self.grad = 1.0
for v in reversed(topo):
v._backward()
Функции активации. Добавьте в класс Value (скопируйте решение прошлого задания) методы:
tanh()— t = \tanh x, производная 1 - t^2;relu()— \max(0, x), производная 1 при x > 0, иначе 0;exp()— e^x, производная e^x.
Тесты сверят градиенты с численными.
Какие тесты проверяют код (2)
def num(f, x, h=1e-6):
return (f(x + h) - f(x - h)) / (2 * h)
for x0 in (-1.3, 0.4, 2.0):
for name, fv, ff in [("tanh", lambda v: v.tanh(), math.tanh), ("exp", lambda v: v.exp(), math.exp),
("relu", lambda v: v.relu(), lambda t: max(0.0, t))]:
x = Value(x0)
y = fv(x)
y.backward()
assert abs(y.data - ff(x0)) < 1e-12, f"{name}({x0}): неверное значение"
assert abs(x.grad - num(ff, x0)) < 1e-5, f"{name}′({x0}) = {x.grad}, численно {num(ff, x0):.6f}"
x = Value(0.5)
y = (x * 2.0).tanh() * x.exp()
y.backward()
f = lambda t: math.tanh(2 * t) * math.exp(t)
assert abs(x.grad - (f(0.5 + 1e-6) - f(0.5 - 1e-6)) / 2e-6) < 1e-6, "Сложная композиция: градиент не совпадает с численным"
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 __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
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():
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
def relu(self):
out = Value(max(0.0, self.data), (self,), "relu")
def _backward():
self.grad += (1.0 if self.data > 0 else 0.0) * out.grad
out._backward = _backward
return out
def exp(self):
e = math.exp(self.data)
out = Value(e, (self,), "exp")
def _backward():
self.grad += e * out.grad
out._backward = _backward
return out
def backward(self):
topo, seen = [], set()
def build(v):
if v not in seen:
seen.add(v)
for child in v._prev:
build(child)
topo.append(v)
build(self)
self.grad = 1.0
for v in reversed(topo):
v._backward()
Обратный проход. Напишите сами метод backward(): соберите узлы графа в топологическом
порядке (каждый узел — после всех своих входов _prev), установите градиент выхода в 1 и
вызовите _backward() у узлов в обратном порядке.
Остальной класс готов. Тест проверит граф, где одно значение используется несколько раз.
Какие тесты проверяют код (1)
a, b = Value(2.0), Value(-3.0)
c = a * b # −6
d = c + a # −4
e = d * c # 24
e.backward()
# e = (ab + a)·ab ⇒ ∂e/∂a = (b + 1)·ab + (ab + a)·b, ∂e/∂b = a·ab + (ab + a)·a
assert e.data == 24
assert abs(a.grad - ((-3 + 1) * -6 + (-4) * -3)) < 1e-12, f"∂e/∂a = {a.grad}, ожидалось 24"
assert abs(b.grad - (2 * -6 + (-4) * 2)) < 1e-12, f"∂e/∂b = {b.grad}, ожидалось −20"
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 __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
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():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def backward(self):
topo, seen = [], set()
def build(v):
if v not in seen:
seen.add(v)
for child in v._prev:
build(child)
topo.append(v)
build(self)
self.grad = 1.0
for v in reversed(topo):
v._backward()
Войдите, чтобы отмечать пройденные уроки.