Фаза 01 · урок 05

Правило цепочки и автоматическое дифференцирование

Цель урока: Вы умеете вычислять производные простых функций. Но нейронная сеть — не простая функция. Это сотни скомпонованных функций: умножение матриц, добавление смещения, применение функции активации, ещё одно умножение матриц, softmax, функция…

Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.

Курс
AI Engineering from Scratch
Фаза
Математические основы
Чтение
17 мин.
Проверено
Содержание урока
  1. Цели обучения
  2. Проблема
  3. Концепция
  4. Правило цепочки
  5. Вычислительные графы
  6. Прямой режим и обратный режим
  7. Дуальные числа для прямого режима
  8. Построение движка autograd
  9. Как PyTorch Autograd работает внутри
  10. Соберите это
  11. Шаг 1: класс Value
  12. Шаг 2: арифметические операции с отслеживанием градиентов
  13. Шаг 3: обратный проход
  14. Шаг 4: больше операций для полноценного движка
  15. Шаг 5: мини-MLP с нуля
  16. Шаг 6: проверка градиентов
  17. Шаг 7: проверка по ручному вычислению
  18. Используйте это
  19. Проверка с PyTorch
  20. Более сложное выражение
  21. Подготовьте результат
  22. Упражнения
  23. Ключевые термины
  24. Дополнительные материалы

Правило цепочки — двигатель каждой обучающейся нейронной сети.

Тип: Практика Язык: Python Предварительные требования: Фаза 1, урок 04 («Производные и градиенты») Время: около 90 минут

Цели обучения

  • Создать минимальный движок autograd (класс Value), который записывает операции и вычисляет градиенты с помощью автоматического дифференцирования в обратном режиме.
  • Реализовать прямой и обратный проходы по вычислительному графу с помощью топологической сортировки.
  • Построить и обучить многослойный перцептрон на XOR, используя только написанный с нуля движок autograd.
  • Проверить корректность autodiff с помощью проверки градиентов по численным конечным разностям.

Проблема

Вы умеете вычислять производные простых функций. Но нейронная сеть — не простая функция. Это сотни скомпонованных функций: умножение матриц, добавление смещения, применение функции активации, ещё одно умножение матриц, softmax, функция потерь кросс-энтропии. Выход — это функция от функции от функции.

Чтобы обучить сеть, вам нужен градиент функции потерь по отношению к каждому отдельному весу. Выполнить это вручную невозможно для миллионов параметров. Выполнять численно (конечными разностями) слишком медленно.

Правило цепочки даёт математику. Автоматическое дифференцирование даёт алгоритм. Вместе они позволяют вычислять точные градиенты через произвольные композиции функций за время, пропорциональное одному прямому проходу.

Так работают PyTorch, TensorFlow и JAX. Вы создадите миниатюрную версию с нуля.

Концепция

Правило цепочки

Если y = f(g(x)), производная y по x равна:

dy/dx = dy/dg * dg/dx = f'(g(x)) * g'(x)

Перемножайте производные вдоль цепочки. Каждое звено вносит свою локальную производную.

Пример: y = sin(x^2)

g(x) = x^2       g'(x) = 2x
f(g) = sin(g)     f'(g) = cos(g)

dy/dx = cos(x^2) * 2x

Для более глубоких композиций цепочка продолжается:

y = f(g(h(x)))

dy/dx = f'(g(h(x))) * g'(h(x)) * h'(x)

Каждый слой нейронной сети — одно звено этой цепочки.

Вычислительные графы

Вычислительный граф делает правило цепочки наглядным. Каждая операция становится узлом. Данные текут вперёд по графу. Градиенты текут назад.

Прямой проход (вычисление значений):

Диаграмма к уроку «Правило цепочки и автоматическое дифференцирование»

Обратный проход (вычисление градиентов):

Диаграмма к уроку «Правило цепочки и автоматическое дифференцирование»

Обратный проход применяет правило цепочки в каждом узле, распространяя градиенты от выходов ко входам.

Прямой режим и обратный режим

Есть два способа применить правило цепочки в графе.

Прямой режим начинается на входах и продвигает производные вперёд. Он вычисляет dx/dx = 1 и распространяет результат через каждую операцию. Хорош, когда входов мало, а выходов много.

Forward mode: seed dx/dx = 1, propagate forward

  x = 2       (dx/dx = 1)
  a = x^2     (da/dx = 2x = 4)
  y = sin(a)  (dy/dx = cos(a) * da/dx = cos(4) * 4 = -2.615)

Обратный режим начинается на выходе и протягивает градиенты назад. Он вычисляет dy/dy = 1 и распространяет результат через каждую операцию в обратном порядке. Хорош, когда входов много, а выходов мало.

Reverse mode: seed dy/dy = 1, propagate backward

  y = sin(a)  (dy/dy = 1)
  a = x^2     (dy/da = cos(a) = cos(4) = -0.654)
  x = 2       (dy/dx = dy/da * da/dx = -0.654 * 4 = -2.615)

У нейронных сетей миллионы входов (весов) и один выход (функция потерь). Обратный режим вычисляет все градиенты за один обратный проход. Поэтому обратное распространение ошибки использует обратный режим.

Режим Начальное значение Направление Лучше всего, когда
Прямой dx_i/dx_i = 1 От входа к выходу Мало входов, много выходов
Обратный dy/dy = 1 От выхода ко входу Много входов, мало выходов (нейронные сети)

Дуальные числа для прямого режима

Прямой режим можно элегантно реализовать с помощью дуальных чисел. Дуальное число имеет вид a + b*epsilon, где epsilon^2 = 0.

Dual number: (value, derivative)

(2, 1) means: value is 2, derivative w.r.t. x is 1

Arithmetic rules:
  (a, a') + (b, b') = (a+b, a'+b')
  (a, a') * (b, b') = (a*b, a'*b + a*b')
  sin(a, a')         = (sin(a), cos(a)*a')

Задайте для входной переменной производную 1. Производная автоматически распространится через каждую операцию.

Построение движка autograd

Движку autograd нужны три вещи:

  1. Обёртка Value. Оберните каждое число в объект, который хранит его значение и градиент.
  2. Запись графа. Каждая операция записывает свои входы и функцию локального градиента.
  3. Обратный проход. Выполните топологическую сортировку графа, затем пройдите его в обратном порядке, применяя правило цепочки в каждом узле.

Именно это делает autograd в PyTorch. Класс torch.Tensor оборачивает значения, записывает операции при requires_grad=True и вычисляет градиенты, когда вы вызываете .backward().

Как PyTorch Autograd работает внутри

Когда вы пишете код PyTorch:

x = torch.tensor(2.0, requires_grad=True)
y = x ** 2 + 3 * x + 1
y.backward()
print(x.grad)  # 7.0 = 2*x + 3 = 2*2 + 3

PyTorch внутри:

  1. Создаёт узел Tensor для x с requires_grad=True.
  2. Каждая операция (**, *, +) создаёт новый узел и записывает функцию обратного прохода.
  3. y.backward() запускает автоматическое дифференцирование в обратном режиме по записанному графу.
  4. grad_fn каждого узла вычисляет локальные градиенты и передаёт их родительским узлам.
  5. Градиенты накапливаются в атрибутах .grad через сложение (а не замену).

Граф динамический (define-by-run). При каждом прямом проходе строится новый граф. Поэтому PyTorch поддерживает поток управления (if/else, циклы) внутри моделей.

chain-rule

Соберите это

Шаг 1: класс Value

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:.4f}, grad={self.grad:.4f})"

Каждый Value хранит свои числовые данные, градиент (изначально нулевой), функцию обратного прохода и указатели на дочерние узлы, породившие его.

Шаг 2: арифметические операции с отслеживанием градиентов

    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 relu(self):
        out = Value(max(0, self.data), (self,), 'relu')
        def _backward():
            self.grad += (1.0 if out.data > 0 else 0.0) * out.grad
        out._backward = _backward
        return out

Каждая операция создаёт замыкание, которое умеет вычислять локальные градиенты и умножать их на градиент, пришедший сверху (out.grad). += обрабатывает случай, когда значение используется в нескольких операциях.

Шаг 3: обратный проход

    def backward(self):
        topo = []
        visited = set()
        def build_topo(v):
            if v not in visited:
                visited.add(v)
                for child in v._prev:
                    build_topo(child)
                topo.append(v)
        build_topo(self)

        self.grad = 1.0
        for v in reversed(topo):
            v._backward()

Топологическая сортировка гарантирует, что градиент каждого узла полностью вычислен до того, как он распространится к дочерним узлам. Начальный градиент равен 1.0 (dy/dy = 1).

Шаг 4: больше операций для полноценного движка

Базовый класс Value обрабатывает сложение, умножение и relu. Настоящему движку autograd нужно больше. Вот операции, которые необходимо построить для нейронных сетей:

    def __neg__(self):
        return self * -1

    def __sub__(self, other):
        return self + (-other)

    def __radd__(self, other):
        return self + other

    def __rmul__(self, other):
        return self * other

    def __rsub__(self, other):
        return other + (-self)

    def __pow__(self, n):
        out = Value(self.data ** n, (self,), f'**{n}')
        def _backward():
            self.grad += n * (self.data ** (n - 1)) * out.grad
        out._backward = _backward
        return out

    def __truediv__(self, other):
        return self * (other ** -1) if isinstance(other, Value) else self * (Value(other) ** -1)

    def exp(self):
        import math
        e = math.exp(self.data)
        out = Value(e, (self,), 'exp')
        def _backward():
            self.grad += e * out.grad
        out._backward = _backward
        return out

    def log(self):
        import math
        out = Value(math.log(self.data), (self,), 'log')
        def _backward():
            self.grad += (1.0 / self.data) * out.grad
        out._backward = _backward
        return out

    def tanh(self):
        import math
        t = math.tanh(self.data)
        out = Value(t, (self,), 'tanh')
        def _backward():
            self.grad += (1 - t ** 2) * out.grad
        out._backward = _backward
        return out

Почему важна каждая операция:

Операция Правило обратного прохода Используется в
__sub__ Повторно использует add + neg Вычисление функции потерь (pred - target)
__pow__ n * x^(n-1) Полиномиальные функции активации, MSE (error^2)
__truediv__ Повторно использует mul + pow(-1) Нормализация, масштабирование скорости обучения
exp exp(x) * upstream Softmax, логарифмическое правдоподобие
log (1/x) * upstream Функция потерь кросс-энтропии, логарифмы вероятностей
tanh (1 - tanh^2) * upstream Классическая функция активации

Хитрая часть: __sub__ и __truediv__ определены через существующие операции. Они получают правильные градиенты бесплатно, поскольку правило цепочки составляется через базовые операции add/mul/pow.

Шаг 5: мини-MLP с нуля

С полным классом Value вы можете построить нейронную сеть. Без PyTorch. Без NumPy. Только Values и правило цепочки.

import random

class Neuron:
    def __init__(self, n_inputs):
        self.w = [Value(random.uniform(-1, 1)) for _ in range(n_inputs)]
        self.b = Value(0.0)

    def __call__(self, x):
        act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
        return act.tanh()

    def parameters(self):
        return self.w + [self.b]

class Layer:
    def __init__(self, n_inputs, n_outputs):
        self.neurons = [Neuron(n_inputs) for _ in range(n_outputs)]

    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(sizes[i], sizes[i+1]) for i in range(len(sizes)-1)]

    def __call__(self, x):
        for layer in self.layers:
            x = layer(x)
        return x[0] if len(x) == 1 else x

    def parameters(self):
        return [p for layer in self.layers for p in layer.parameters()]

Neuron вычисляет tanh(w1*x1 + w2*x2 + ... + b). Layer — список нейронов. MLP объединяет слои в стек. Каждый вес — это Value, поэтому вызов loss.backward() распространяет градиенты на каждый параметр.

Обучение на XOR:

random.seed(42)
model = MLP([2, 4, 1])  # 2 inputs, 4 hidden neurons, 1 output

xs = [[0, 0], [0, 1], [1, 0], [1, 1]]
ys = [-1, 1, 1, -1]  # XOR pattern (using -1/1 for tanh)

for step in range(100):
    preds = [model(x) for x in xs]
    loss = sum((p - y) ** 2 for p, y in zip(preds, ys))

    for p in model.parameters():
        p.grad = 0.0
    loss.backward()

    lr = 0.05
    for p in model.parameters():
        p.data -= lr * p.grad

    if step % 20 == 0:
        print(f"step {step:3d}  loss = {loss.data:.4f}")

print("\nPredictions after training:")
for x, y in zip(xs, ys):
    print(f"  input={x}  target={y:2d}  pred={model(x).data:6.3f}")

Это micrograd. Полный цикл обучения нейронной сети на чистом Python с автоматическим дифференцированием. Каждый коммерческий фреймворк глубокого обучения делает то же самое в огромном масштабе.

Шаг 6: проверка градиентов

Как узнать, что ваш autodiff верен? Сравнить его с численными производными. Это проверка градиентов.

def gradient_check(build_expr, x_val, h=1e-7):
    x = Value(x_val)
    y = build_expr(x)
    y.backward()
    autodiff_grad = x.grad

    y_plus = build_expr(Value(x_val + h)).data
    y_minus = build_expr(Value(x_val - h)).data
    numerical_grad = (y_plus - y_minus) / (2 * h)

    diff = abs(autodiff_grad - numerical_grad)
    return autodiff_grad, numerical_grad, diff

Проверьте на сложном выражении:

def expr(x):
    return (x ** 3 + x * 2 + 1).tanh()

ad, num, diff = gradient_check(expr, 0.5)
print(f"Autodiff:  {ad:.8f}")
print(f"Numerical: {num:.8f}")
print(f"Difference: {diff:.2e}")
# Difference should be < 1e-5

Проверка градиентов необходима при реализации новых операций. Если в вашем обратном проходе есть ошибка, численная проверка её поймает. Каждая серьёзная реализация глубокого обучения запускает проверки градиентов во время разработки.

Когда использовать проверку градиентов:

Ситуация Выполнять проверку градиентов?
Добавляете новую операцию в свой autograd Да, всегда
Отлаживаете цикл обучения, который не сходится Да, сначала проверьте градиенты
Обучение в production Нет, слишком медленно (2x прямых прохода на параметр)
Модульные тесты для кода autograd Да, автоматизируйте её

Шаг 7: проверка по ручному вычислению

x1 = Value(2.0)
x2 = Value(3.0)
a = x1 * x2          # a = 6.0
b = a + Value(1.0)    # b = 7.0
y = b.relu()          # y = 7.0

y.backward()

print(f"y = {y.data}")          # 7.0
print(f"dy/dx1 = {x1.grad}")   # 3.0 (= x2)
print(f"dy/dx2 = {x2.grad}")   # 2.0 (= x1)

Ручная проверка: y = relu(x1*x2 + 1). Поскольку x1*x2 + 1 = 7 > 0, relu тождественна. dy/dx1 = x2 = 3. dy/dx2 = x1 = 2. Результаты движка совпадают.

Используйте это

Проверка с PyTorch

import torch

x1 = torch.tensor(2.0, requires_grad=True)
x2 = torch.tensor(3.0, requires_grad=True)
a = x1 * x2
b = a + 1.0
y = torch.relu(b)
y.backward()

print(f"PyTorch dy/dx1 = {x1.grad.item()}")  # 3.0
print(f"PyTorch dy/dx2 = {x2.grad.item()}")  # 2.0

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

Более сложное выражение

a = Value(2.0)
b = Value(-3.0)
c = Value(10.0)
f = (a * b + c).relu()  # relu(2*(-3) + 10) = relu(4) = 4

f.backward()
print(f"df/da = {a.grad}")  # -3.0 (= b)
print(f"df/db = {b.grad}")  #  2.0 (= a)
print(f"df/dc = {c.grad}")  #  1.0

Подготовьте результат

Этот урок создаёт:

  • outputs/skill-autodiff.md – навык для построения и отладки систем autograd
  • code/autodiff.py – минимальный движок autograd, который вы можете расширить

Класс Value, построенный здесь, — основа для цикла обучения нейронной сети в фазе 3.

Упражнения

  1. Добавьте __pow__ в класс Value, чтобы можно было вычислять x ** n. Проверьте, что d/dx(x^3) при x=2 равно 12.0.

  2. Добавьте tanh как функцию активации. Проверьте, что tanh'(0) = 1 и tanh'(2) = 0.0707 (приблизительно).

  3. Постройте вычислительный граф для одного нейрона: y = relu(w1*x1 + w2*x2 + b). Вычислите все пять градиентов и проверьте результат с PyTorch.

  4. Реализуйте autodiff прямого режима с помощью дуальных чисел. Создайте класс Dual и проверьте, что он даёт те же производные, что и ваш движок обратного режима.

Ключевые термины

Термин Что обычно говорят Что это на самом деле означает
Правило цепочки «Перемножайте производные» Производная составных функций равна произведению локальных производных каждой функции, вычисленных в нужной точке
Вычислительный граф «Схема сети» Ориентированный ациклический граф, в котором узлы — операции, а рёбра несут значения (вперёд) или градиенты (назад)
Прямой режим «Продвигайте производные вперёд» Autodiff, который распространяет производные от входов к выходам. Один проход на входную переменную.
Обратный режим «Обратное распространение ошибки» Autodiff, который распространяет градиенты от выходов ко входам. Один проход на выходную переменную.
Autograd «Автоматические градиенты» Система, которая записывает операции над значениями, строит граф и вычисляет точные градиенты по правилу цепочки
Дуальные числа «Значение плюс производная» Числа вида a + b*epsilon (epsilon^2 = 0), переносящие информацию о производной через арифметические операции
Топологическая сортировка «Порядок зависимостей» Упорядочивание узлов графа так, чтобы каждый узел шёл после всех своих зависимостей. Требуется для правильного распространения градиентов.
Накопление градиентов «Складывайте, не заменяйте» Когда значение поступает в несколько операций, его градиент равен сумме всех входящих вкладов градиента
Динамический граф «Определение во время выполнения» Вычислительный граф, который перестраивается при каждом прямом проходе и допускает поток управления Python внутри моделей (стиль PyTorch)
Проверка градиентов «Численная верификация» Сравнение градиентов autodiff с численными градиентами конечных разностей для проверки корректности. Необходимо для отладки.
MLP «Многослойный перцептрон» Нейронная сеть с одним или несколькими скрытыми слоями нейронов. Каждый нейрон вычисляет взвешенную сумму со смещением, затем применяет функцию активации.
Нейрон «Взвешенная сумма + активация» Базовая единица: output = activation(w1x1 + w2x2 + … + b). Веса и смещение — обучаемые параметры.

Дополнительные материалы


Источник: Chain Rule & Automatic Differentiation — оригинал Навигация: назад: 01.04 — Исчисление для машинного обучения · Фаза 1 — Математические основы · Полный каталог · далее: 01.06 — Вероятность и распределения.