Фаза 01 · урок 05
Правило цепочки и автоматическое дифференцирование
Цель урока: Вы умеете вычислять производные простых функций. Но нейронная сеть — не простая функция. Это сотни скомпонованных функций: умножение матриц, добавление смещения, применение функции активации, ещё одно умножение матриц, softmax, функция…
Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.
Содержание урока
- Цели обучения
- Проблема
- Концепция
- Правило цепочки
- Вычислительные графы
- Прямой режим и обратный режим
- Дуальные числа для прямого режима
- Построение движка autograd
- Как PyTorch Autograd работает внутри
- Соберите это
- Шаг 1: класс Value
- Шаг 2: арифметические операции с отслеживанием градиентов
- Шаг 3: обратный проход
- Шаг 4: больше операций для полноценного движка
- Шаг 5: мини-MLP с нуля
- Шаг 6: проверка градиентов
- Шаг 7: проверка по ручному вычислению
- Используйте это
- Проверка с PyTorch
- Более сложное выражение
- Подготовьте результат
- Упражнения
- Ключевые термины
- Дополнительные материалы
Правило цепочки — двигатель каждой обучающейся нейронной сети.
Тип: Практика Язык: 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 нужны три вещи:
- Обёртка Value. Оберните каждое число в объект, который хранит его значение и градиент.
- Запись графа. Каждая операция записывает свои входы и функцию локального градиента.
- Обратный проход. Выполните топологическую сортировку графа, затем пройдите его в обратном порядке, применяя правило цепочки в каждом узле.
Именно это делает 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 внутри:
- Создаёт узел
Tensorдляxсrequires_grad=True. - Каждая операция (
**,*,+) создаёт новый узел и записывает функцию обратного прохода. y.backward()запускает автоматическое дифференцирование в обратном режиме по записанному графу.grad_fnкаждого узла вычисляет локальные градиенты и передаёт их родительским узлам.- Градиенты накапливаются в атрибутах
.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– навык для построения и отладки систем autogradcode/autodiff.py– минимальный движок autograd, который вы можете расширить
Класс Value, построенный здесь, — основа для цикла обучения нейронной сети в фазе 3.
Упражнения
-
Добавьте
__pow__в класс Value, чтобы можно было вычислятьx ** n. Проверьте, чтоd/dx(x^3)приx=2равно12.0. -
Добавьте
tanhкак функцию активации. Проверьте, чтоtanh'(0) = 1иtanh'(2) = 0.0707(приблизительно). -
Постройте вычислительный граф для одного нейрона:
y = relu(w1*x1 + w2*x2 + b). Вычислите все пять градиентов и проверьте результат с PyTorch. -
Реализуйте autodiff прямого режима с помощью дуальных чисел. Создайте класс
Dualи проверьте, что он даёт те же производные, что и ваш движок обратного режима.
Ключевые термины
| Термин | Что обычно говорят | Что это на самом деле означает |
|---|---|---|
| Правило цепочки | «Перемножайте производные» | Производная составных функций равна произведению локальных производных каждой функции, вычисленных в нужной точке |
| Вычислительный граф | «Схема сети» | Ориентированный ациклический граф, в котором узлы — операции, а рёбра несут значения (вперёд) или градиенты (назад) |
| Прямой режим | «Продвигайте производные вперёд» | Autodiff, который распространяет производные от входов к выходам. Один проход на входную переменную. |
| Обратный режим | «Обратное распространение ошибки» | Autodiff, который распространяет градиенты от выходов ко входам. Один проход на выходную переменную. |
| Autograd | «Автоматические градиенты» | Система, которая записывает операции над значениями, строит граф и вычисляет точные градиенты по правилу цепочки |
| Дуальные числа | «Значение плюс производная» | Числа вида a + b*epsilon (epsilon^2 = 0), переносящие информацию о производной через арифметические операции |
| Топологическая сортировка | «Порядок зависимостей» | Упорядочивание узлов графа так, чтобы каждый узел шёл после всех своих зависимостей. Требуется для правильного распространения градиентов. |
| Накопление градиентов | «Складывайте, не заменяйте» | Когда значение поступает в несколько операций, его градиент равен сумме всех входящих вкладов градиента |
| Динамический граф | «Определение во время выполнения» | Вычислительный граф, который перестраивается при каждом прямом проходе и допускает поток управления Python внутри моделей (стиль PyTorch) |
| Проверка градиентов | «Численная верификация» | Сравнение градиентов autodiff с численными градиентами конечных разностей для проверки корректности. Необходимо для отладки. |
| MLP | «Многослойный перцептрон» | Нейронная сеть с одним или несколькими скрытыми слоями нейронов. Каждый нейрон вычисляет взвешенную сумму со смещением, затем применяет функцию активации. |
| Нейрон | «Взвешенная сумма + активация» | Базовая единица: output = activation(w1x1 + w2x2 + … + b). Веса и смещение — обучаемые параметры. |
Дополнительные материалы
- 3Blue1Brown: исчисление обратного распространения – визуальное объяснение правила цепочки в нейронных сетях
- Механика PyTorch Autograd – как работает реальная система
- Baydin et al., Automatic Differentiation in Machine Learning: a Survey – исчерпывающий справочник
Источник: Chain Rule & Automatic Differentiation — оригинал Навигация: назад: 01.04 — Исчисление для машинного обучения · Фаза 1 — Математические основы · Полный каталог · далее: 01.06 — Вероятность и распределения.