Фаза 03 · урок 03
Обратное распространение ошибки с нуля
Цель урока: В вашей сети один скрытый слой с 768 входами и 3072 выходами. Это 2 359 296 весов. Она сделала неверное предсказание. Какие веса вызвали ошибку? Проверка каждого веса по отдельности означает 2,3 миллиона прямых проходов. Обратное…
Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.
Содержание урока
- Цели обучения
- Проблема
- Концепция
- Правило цепочки, применённое к сетям
- Вычислительные графы
- Прямой и обратный проходы
- Поток градиента через сеть
- Затухающие градиенты
- Вывод градиентов для двухслойной сети
- Соберите это
- Шаг 1: узел Value
- Шаг 2: операции с функциями обратного прохода
- Шаг 3: сигмоида и потери
- Шаг 4: обратный проход
- Шаг 5: слой и сеть
- Шаг 6: обучение на XOR
- Шаг 7: классификация точек внутри круга
- Используйте это
- Выпустите это
- Упражнения
- Ключевые термины
- Дополнительное чтение
Обратное распространение ошибки — алгоритм, делающий обучение возможным. Без него нейронные сети — лишь дорогие генераторы случайных чисел.
Тип: Сборка Языки: Python Предварительные требования: Урок 03.02 (Многослойные сети) Время: ~120 минут
Цели обучения
- Реализовать движок
autogradна основеValue, который строит вычислительный граф и вычисляет градиенты топологической сортировкой - Вывести обратный проход для сложения, умножения и сигмоиды с помощью правила цепочки
- Обучить многослойную сеть на XOR и классификации точек внутри круга, используя только собственный движок обратного распространения
- Выявить проблему затухающих градиентов в глубоких сетях с сигмоидой и объяснить, почему градиенты экспоненциально уменьшаются
Проблема
В вашей сети один скрытый слой с 768 входами и 3072 выходами. Это 2 359 296 весов. Она сделала неверное предсказание. Какие веса вызвали ошибку? Проверка каждого веса по отдельности означает 2,3 миллиона прямых проходов. Обратное распространение вычисляет все 2,3 миллиона градиентов за один обратный проход. Это не оптимизация. Это разница между обучаемым и невозможным.
Наивный подход: возьмите один вес, сдвиньте его на крошечную величину, снова выполните прямой проход, измерьте, выросла или упала функция потерь. Так вы получите градиент для этого веса. Теперь сделайте это для каждого веса в сети. Умножьте на тысячи шагов обучения и миллионы точек данных. Чтобы обучить что-либо полезное, вам потребовалось бы геологическое время.
Обратное распространение решает эту задачу. Один прямой проход, один обратный проход — вычислены все градиенты. Хитрость в правиле цепочки из математического анализа, систематически применённом к вычислительному графу. Этот алгоритм сделал глубокое обучение практичным. Без него мы всё ещё застряли бы на игрушечных задачах.
Концепция
Правило цепочки, применённое к сетям
Вы видели правило цепочки в фазе 01, уроке 05. Краткое напоминание: если y = f(g(x)), то dy/dx = f'(g(x)) * g'(x). Производные перемножаются вдоль цепочки.
В нейронной сети «цепочка» — последовательность операций от входа до потерь. Каждый слой применяет веса, добавляет смещения, пропускает результат через функцию активации. Функция потерь сравнивает итоговый выход с целевым значением. Обратное распространение прослеживает эту цепочку в обратном направлении, вычисляя, как каждая операция внесла вклад в ошибку.
Вычислительные графы
Каждый прямой проход строит граф. Каждый узел — операция (умножение, сложение, сигмоида). Каждое ребро несёт значение вперёд и градиент назад.
Прямой проход: значения движутся слева направо. x и w образуют z1 = w*x. Добавьте b, чтобы получить z2. Сигмоида даёт активацию a. Сравните a с целью y, используя функцию потерь.
Обратный проход: градиенты движутся справа налево. Начните с dL/da (как потери меняются с активацией). Умножьте на da/dz2 (производную сигмоиды). Получится dL/dz2. Разделите его на dL/db (равный dL/dz2, поскольку z2 = z1 + b) и dL/dz1. Затем dL/dw = dL/dz1 * x, а dL/dx = dL/dz1 * w.
У каждого узла графа во время обратного прохода одна задача: принять градиент, поступающий сверху, умножить его на свою локальную производную и передать ниже.
Прямой и обратный проходы
Прямой проход сохраняет каждое промежуточное значение: z, a, входы каждого слоя. Обратному проходу нужны эти сохранённые значения для вычисления градиентов. Это компромисс между памятью и вычислениями, лежащий в основе обратного распространения. Вы обмениваете память (хранение активаций) на скорость (один проход вместо миллионов).
Поток градиента через сеть
Для трёхслойной сети градиенты проходят через каждый слой цепочкой:
На каждом слое градиент умножается на производную сигмоиды. Производная сигмоиды равна a * (1 - a) и достигает максимум 0,25 (при a = 0.5). На глубине трёх слоёв градиент умножен максимум на 0.25^3 = 0.0156. На глубине десяти слоёв: 0.25^10 = 0.000001.
Затухающие градиенты
Это и есть проблема затухающих градиентов. Сигмоида сжимает выход между 0 и 1. Её производная всегда меньше 0,25. Сложите достаточно слоёв с сигмоидой — и градиенты сократятся почти до нуля. Ранние слои почти не обучаются, поскольку получают градиенты, близкие к нулю.
sigmoid(z): Output range [0, 1]
sigmoid'(z): Max value 0.25 (at z = 0)
After 5 layers: gradient * 0.25^5 = 0.001x original
After 10 layers: gradient * 0.25^10 = 0.000001x original
Вот почему глубокие сети с сигмоидой почти невозможно обучить. Решение — ReLU и его варианты — тема урока 04. Пока же важно понять: обратное распространение работает безупречно. Проблема в том, через что оно работает.
Вывод градиентов для двухслойной сети
Конкретная математика для сети с входом x, скрытым слоем с сигмоидой, выходным слоем с сигмоидой и функцией потерь MSE.
Прямой проход:
z1 = W1 * x + b1
a1 = sigmoid(z1)
z2 = W2 * a1 + b2
a2 = sigmoid(z2)
L = (a2 - y)^2
Обратный проход (пошаговое применение правила цепочки):
dL/da2 = 2(a2 - y)
da2/dz2 = a2 * (1 - a2)
dL/dz2 = dL/da2 * da2/dz2 = 2(a2 - y) * a2 * (1 - a2)
dL/dW2 = dL/dz2 * a1
dL/db2 = dL/dz2
dL/da1 = dL/dz2 * W2
da1/dz1 = a1 * (1 - a1)
dL/dz1 = dL/da1 * da1/dz1
dL/dW1 = dL/dz1 * x
dL/db1 = dL/dz1
Каждый градиент — произведение локальных производных, прослеженных в обратном направлении от функции потерь. В этом и состоит всё обратное распространение.
backprop-vanishing
Соберите это
Шаг 1: узел Value
Каждое число в вычислении становится Value. Он хранит данные, градиент и способ своего создания (поэтому знает, как вычислять градиенты в обратном направлении).
class Value:
def __init__(self, data, children=(), op=''):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._children = set(children)
self._op = op
def __repr__(self):
return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"
Пока нет градиента (0.0). Пока нет и функции обратного прохода (операция без действия). _children отслеживает, какие Value породили текущий, чтобы позже можно было топологически отсортировать граф.
Шаг 2: операции с функциями обратного прохода
Каждая операция создаёт новый Value и определяет, как градиенты проходят через неё в обратном направлении.
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
Для сложения: d(a+b)/da = 1, d(a+b)/db = 1. Поэтому оба входа получают градиент выхода напрямую.
Для умножения: d(a*b)/da = b, d(a*b)/db = a. Каждый вход получает значение второго входа, умноженное на градиент выхода.
+= критически важен. Value может использоваться в нескольких операциях. Его градиент равен сумме градиентов по всем путям.
Шаг 3: сигмоида и потери
import math
def sigmoid(self):
x = self.data
x = max(-500, min(500, x))
s = 1.0 / (1.0 + math.exp(-x))
out = Value(s, (self,), 'sigmoid')
def _backward():
self.grad += (s * (1 - s)) * out.grad
out._backward = _backward
return out
Производная сигмоиды: sigmoid(x) * (1 - sigmoid(x)). Во время прямого прохода мы уже вычислили sigmoid(x) = s. Используйте его повторно. Дополнительная работа не нужна.
def mse_loss(predicted, target):
diff = predicted + Value(-target)
return diff * diff
MSE для одного выхода: (predicted - target)^2. Выражаем вычитание как сложение с отрицательным Value.
Шаг 4: обратный проход
Топологическая сортировка гарантирует, что узлы обрабатываются в правильном порядке: градиент узла полностью накоплен до того, как мы распространяем его дальше.
def backward(self):
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._children:
build_topo(child)
topo.append(v)
build_topo(self)
self.grad = 1.0
for v in reversed(topo):
v._backward()
Начните с потерь (градиент = 1.0, поскольку dL/dL = 1). Пройдите назад по отсортированному графу. _backward каждого узла передаёт градиенты своим потомкам.
Шаг 5: слой и сеть
import random
class Neuron:
def __init__(self, n_inputs):
scale = (2.0 / n_inputs) ** 0.5
self.weights = [Value(random.uniform(-scale, scale)) for _ in range(n_inputs)]
self.bias = Value(0.0)
def __call__(self, x):
act = sum((wi * xi for wi, xi in zip(self.weights, x)), self.bias)
return act.sigmoid()
def parameters(self):
return self.weights + [self.bias]
class Layer:
def __init__(self, n_inputs, n_outputs):
self.neurons = [Neuron(n_inputs) for _ in range(n_outputs)]
def __call__(self, x):
out = [n(x) for n in self.neurons]
return out[0] if len(out) == 1 else out
def parameters(self):
params = []
for n in self.neurons:
params.extend(n.parameters())
return params
class Network:
def __init__(self, sizes):
self.layers = []
for i in range(len(sizes) - 1):
self.layers.append(Layer(sizes[i], sizes[i + 1]))
def __call__(self, x):
for layer in self.layers:
x = layer(x)
if not isinstance(x, list):
x = [x]
return x[0] if len(x) == 1 else x
def parameters(self):
params = []
for layer in self.layers:
params.extend(layer.parameters())
return params
def zero_grad(self):
for p in self.parameters():
p.grad = 0.0
Neuron принимает входы, вычисляет взвешенную сумму со смещением и применяет сигмоиду. Инициализация весов масштабируется через sqrt(2/n_inputs), чтобы предотвратить насыщение сигмоиды в более глубоких сетях. Layer — список Neuron. Network — список Layer. Метод parameters() собирает все обучаемые Value, чтобы их можно было обновить.
Шаг 6: обучение на XOR
random.seed(42)
net = Network([2, 4, 1])
xor_data = [
([0.0, 0.0], 0.0),
([0.0, 1.0], 1.0),
([1.0, 0.0], 1.0),
([1.0, 1.0], 0.0),
]
learning_rate = 1.0
for epoch in range(1000):
total_loss = Value(0.0)
for inputs, target in xor_data:
x = [Value(i) for i in inputs]
pred = net(x)
loss = mse_loss(pred, target)
total_loss = total_loss + loss
net.zero_grad()
total_loss.backward()
for p in net.parameters():
p.data -= learning_rate * p.grad
if epoch % 100 == 0:
print(f"Epoch {epoch:4d} | Loss: {total_loss.data:.6f}")
print("\nXOR Results:")
for inputs, target in xor_data:
x = [Value(i) for i in inputs]
pred = net(x)
print(f" {inputs} -> {pred.data:.4f} (expected {target})")
Наблюдайте, как уменьшается функция потерь. От случайных предсказаний к правильным выходам XOR — полностью благодаря обратному распространению, вычисляющему градиенты и сдвигающему веса в правильном направлении.
Шаг 7: классификация точек внутри круга
В уроке 02 вы вручную настраивали веса для классификации точек внутри круга. Теперь позвольте сети выучить их.
random.seed(7)
def generate_circle_data(n=100):
data = []
for _ in range(n):
x1 = random.uniform(-1.5, 1.5)
x2 = random.uniform(-1.5, 1.5)
label = 1.0 if x1 * x1 + x2 * x2 < 1.0 else 0.0
data.append(([x1, x2], label))
return data
circle_data = generate_circle_data(80)
circle_net = Network([2, 8, 1])
learning_rate = 0.5
for epoch in range(2000):
random.shuffle(circle_data)
total_loss_val = 0.0
for inputs, target in circle_data:
x = [Value(i) for i in inputs]
pred = circle_net(x)
loss = mse_loss(pred, target)
circle_net.zero_grad()
loss.backward()
for p in circle_net.parameters():
p.data -= learning_rate * p.grad
total_loss_val += loss.data
if epoch % 200 == 0:
correct = 0
for inputs, target in circle_data:
x = [Value(i) for i in inputs]
pred = circle_net(x)
predicted_class = 1.0 if pred.data > 0.5 else 0.0
if predicted_class == target:
correct += 1
accuracy = correct / len(circle_data) * 100
print(f"Epoch {epoch:4d} | Loss: {total_loss_val:.4f} | Accuracy: {accuracy:.1f}%")
Здесь используется онлайн-SGD: веса обновляются после каждого образца вместо накопления полного пакета. Это быстрее нарушает симметрию и предотвращает насыщение сигмоиды на полной поверхности потерь. Перемешивание данных в каждой эпохе не позволяет сети запомнить порядок.
Никакой ручной настройки. Сеть самостоятельно находит круговую границу принятия решения. В этом сила обратного распространения: вы определяете архитектуру, функцию потерь и данные. Алгоритм находит веса.
Используйте это
PyTorch выполняет всё описанное выше несколькими строками. Основная идея та же: autograd строит вычислительный граф во время прямого прохода и проходит по нему назад, чтобы вычислить градиенты.
import torch
import torch.nn as nn
model = nn.Sequential(
nn.Linear(2, 4),
nn.Sigmoid(),
nn.Linear(4, 1),
nn.Sigmoid(),
)
optimizer = torch.optim.SGD(model.parameters(), lr=1.0)
criterion = nn.MSELoss()
X = torch.tensor([[0,0],[0,1],[1,0],[1,1]], dtype=torch.float32)
y = torch.tensor([[0],[1],[1],[0]], dtype=torch.float32)
for epoch in range(1000):
pred = model(X)
loss = criterion(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
print("PyTorch XOR Results:")
with torch.no_grad():
for i in range(4):
pred = model(X[i])
print(f" {X[i].tolist()} -> {pred.item():.4f} (expected {y[i].item()})")
loss.backward() — это ваш total_loss.backward(). optimizer.step() — ваше ручное p.data -= lr * p.grad. optimizer.zero_grad() — ваш net.zero_grad(). Тот же алгоритм, но реализация промышленного уровня. PyTorch поддерживает ускорение на GPU, смешанную точность, checkpointing градиентов и сотни типов слоёв. Но обратный проход остаётся тем же правилом цепочки, применённым к тому же вычислительному графу.
Обучение выполняет прямой проход, затем обратный проход, затем обновляет веса. Инференс выполняет только прямой проход. Нет градиентов, нет обновлений. Это различие важно, потому что именно инференс происходит в продакшене. Когда вы вызываете API вроде Claude или GPT, выполняется инференс: ваш промпт проходит вперёд через сеть, а с другого конца выходят токены. Веса не меняются. Понимание обратного распространения важно, потому что именно оно сформировало каждый вес в этой сети.
Выпустите это
Этот урок создаёт:
outputs/prompt-gradient-debugger.md— повторно используемый промпт для диагностики проблем с градиентами (затухание, взрыв,NaN) в любой нейронной сети
Упражнения
-
Добавьте в класс
Valueметод__sub__(a - b = a + (-1 * b)). Затем реализуйте метод__neg__. Убедитесь, что градиенты верны, сравнив их с ручным расчётом для простого выражения вроде(a - b)^2. -
Добавьте в
Valueметодrelu(выходmax(0, x), производная равна 1 приx > 0, иначе 0). Замените сигмоиду наreluв скрытых слоях и снова обучите XOR. Сравните скорость сходимости. Обучение должно стать быстрее — это предваряет урок 04. -
Реализуйте в
Valueметод__pow__для целых степеней. Используйте его, чтобы заменитьmse_lossкорректным выражением(predicted - target) ** 2. Убедитесь, что градиенты совпадают с исходной реализацией. -
Добавьте обрезание градиента в цикл обучения: после вызова
backward()обрезайте все градиенты до[-1, 1]. Обучите более глубокую сеть (4+ слоя с сигмоидой) и сравните кривые потерь с обрезанием и без него. Это ваша первая защита от взрывающихся градиентов. -
Постройте визуализацию: после обучения на XOR выведите градиент каждого параметра сети. Определите, у какого слоя наименьшие градиенты. Это демонстрирует проблему затухающих градиентов, о которой вы прочитали в разделе «Концепция».
Ключевые термины
| Термин | Как обычно говорят | Что это действительно означает |
|---|---|---|
| Обратное распространение ошибки | «Сеть обучается» | Алгоритм, который вычисляет dL/dw для каждого веса, применяя правило цепочки в обратном направлении через вычислительный граф |
| Вычислительный граф | «Структура сети» | Ориентированный ациклический граф, в котором узлы — операции, а рёбра переносят значения (вперёд) и градиенты (назад) |
| Правило цепочки | «Умножайте производные» | Если y = f(g(x)), то dy/dx = f'(g(x)) * g'(x) — математическая основа обратного распространения |
| Градиент | «Направление наискорейшего возрастания» | Частная производная потерь по параметру; показывает, как изменить параметр, чтобы уменьшить потери |
| Затухающий градиент | «Глубокие сети не обучаются» | Градиенты экспоненциально уменьшаются при распространении через слои с насыщаемыми активациями вроде сигмоиды |
| Прямой проход | «Запуск сети» | Вычисление выхода по входам последовательным применением операций каждого слоя с сохранением промежуточных значений |
| Обратный проход | «Вычисление градиентов» | Обход вычислительного графа в обратном направлении с накоплением градиентов в каждом узле по правилу цепочки |
| Скорость обучения | «Насколько быстро она обучается» | Скаляр, управляющий величиной шага при обновлении весов: w_new = w_old - lr * gradient |
| Топологическая сортировка | «Правильный порядок» | Упорядочивание узлов графа, при котором каждый узел идёт после всех узлов, от которых он зависит; обеспечивает полное накопление градиентов до их распространения |
| Autograd | «Автоматическое дифференцирование» | Система, строящая вычислительные графы при прямом вычислении и автоматически вычисляющая градиенты; именно так работает движок PyTorch |
Дополнительное чтение
- Rumelhart, Hinton & Williams, «Learning representations by back-propagating errors» (1986) — статья, сделавшая обратное распространение массово известным и открывшая обучение многослойных сетей
- 3Blue1Brown, серия «Neural Networks» (https://www.youtube.com/playlist?list=PLZHQObOWTQDNU6R1_67000Dx_ZCJB-3pi) — лучшее визуальное объяснение обратного распространения и потока градиентов
Источник: Backpropagation from Scratch 03.02 — Многослойные сети и прямой проход · Фаза 3 — Основы глубокого обучения · Полный каталог · 03.04 — Функции активации