Автоматическое дифференцирование: свой движок

⏱ 50 мин · 🧩 заданий: 5

Что мы строим

В прошлом уроке мы считали градиенты по графу вручную. Это надёжно, но для сети с тысячами операций невозможно. Задача автоматического дифференцирования: пользователь пишет обычные формулы, а библиотека сама строит граф и сама считает градиенты. Так работают PyTorch, TensorFlow и JAX.

Сделаем свой такой движок. Идея простая: вместо чисел используем объекты Value, которые:

  1. хранят число (data) и градиент (grad);
  2. помнят, из каких Value и какой операцией они получены (_prev, _op) — так при обычных вычислениях сам собой строится граф;
  3. умеют пробросить градиент своим входам (_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 в обратном порядке — от выхода к входам.

топологический порядок: a, b, c = a·b, d = c + a, e = d·c обратный проход: e → d → c → b → a (c получает вклады и от d, и от e, прежде чем передать градиент в a и b)

Весь движок

Соберём всё вместе. Этот код мы будем использовать до конца урока:

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

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Один вариант ответа

Зачем перед обратным проходом нужна топологическая сортировка узлов графа?

Задание 2 Один вариант ответа

Почему в методах _backward градиенты прибавляются (+=), а не присваиваются?

Задание 3 Программа на Python

Умножение и степень. Ниже — заготовка класса 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

Задание 4 Программа на Python

Функции активации. Добавьте в класс 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, "Сложная композиция: градиент не совпадает с численным"

Задание 5 Программа на Python

Обратный проход. Напишите сами метод 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"

Войдите, чтобы отмечать пройденные уроки.