Правило цепочки
Композиция и её производная
Нейросеть — композиция функций: вход → слой → активация → слой → … → потеря. Нам нужна производная потери по каждому весу, а вес спрятан глубоко внутри. Как продифференцировать f(g(x))?
Правило цепочки:
Или в обозначениях Лейбница, где u = g(x), y = f(u):
Производные звеньев перемножаются.
Интуиция: шестерёнки
Если шестерёнка u крутится в 3 раза быстрее x, а y — в 2 раза быстрее u, то y крутится в 2 \cdot 3 = 6 раз быстрее x. Каждое звено передаёт изменение дальше, умножая его на свой «коэффициент передачи» — локальную производную.
Пример
f(x) = (3x + 1)^2. Внутренняя функция u = 3x + 1, внешняя y = u^2:
f = lambda x: (3 * x + 1) ** 2
df = lambda x: 6 * (3 * x + 1)
for x in (0.0, 1.0, -2.0):
num = (f(x + 1e-6) - f(x - 1e-6)) / 2e-6
print(f"x = {x}: по формуле {df(x)}, численно {num:.6f}")
Ещё примеры:
- e^{2x}: \;(e^u)' \cdot (2x)' = e^{2x} \cdot 2;
- \ln(x^2 + 1): \;\frac{1}{x^2 + 1} \cdot 2x;
- \sigma(wx + b) по w: \;\sigma'(wx + b) \cdot x = \sigma(1 - \sigma) \cdot x.
Длинные цепочки
Звеньев может быть сколько угодно — производные всё равно перемножаются:
import vis
vis.graph(
{"x": "x", "u1": "u₁ = f₁(x)", "u2": "u₂ = f₂(u₁)", "y": "y = f₃(u₂)"},
[("x", "u1"), ("u1", "u2"), ("u2", "y")],
title="dy/dx = f₃′(u₂) · f₂′(u₁) · f₁′(x)",
)
Чтобы посчитать такое произведение, нужно знать значения в каждом звене (u_1, u_2) — они нужны для локальных производных. Поэтому вычисления делают в два прохода:
- Прямой проход (forward): идём от x к y, вычисляем и запоминаем все промежуточные значения.
- Обратный проход (backward): идём от y назад к x, перемножая локальные производные.
Обратный проход и называется обратным распространением ошибки (backpropagation). Почему именно назад, а не вперёд, — станет ясно в уроке о графе вычислений: так за один проход получаются производные по всем параметрам сразу.
Разбор: производная потерь нейрона
Возьмём нейрон с одним входом и квадратичной потерей:
Хотим \frac{\partial L}{\partial w} и \frac{\partial L}{\partial b}.
Прямой проход при w = 0.5, b = -0.2, x = 2, y = 1:
- z = 0.5 \cdot 2 - 0.2 = 0.8
- p = \sigma(0.8) \approx 0.690
- L = (0.690 - 1)^2 \approx 0.096
Обратный проход — локальные производные от конца к началу:
| Звено | Локальная производная | Значение |
|---|---|---|
| L = (p - y)^2 | \frac{\partial L}{\partial p} = 2(p - y) | -0.620 |
| p = \sigma(z) | \frac{dp}{dz} = p(1 - p) | 0.214 |
| z = wx + b | \frac{\partial z}{\partial w} = x, \frac{\partial z}{\partial b} = 1 | 2 и 1 |
Перемножаем:
Обе производные отрицательны: увеличение w и b уменьшит потерю. И правда — нейрон выдал 0.69 вместо 1, ему нужно «загореться» сильнее.
import math
w, b, x, y = 0.5, -0.2, 2.0, 1.0
# прямой проход
z = w * x + b
p = 1 / (1 + math.exp(-z))
L = (p - y) ** 2
print(f"z = {z:.3f}, p = {p:.3f}, L = {L:.4f}")
# обратный проход
dL_dp = 2 * (p - y)
dp_dz = p * (1 - p)
dL_dz = dL_dp * dp_dz
dL_dw, dL_db = dL_dz * x, dL_dz * 1
print(f"∂L/∂w = {dL_dw:.4f}, ∂L/∂b = {dL_db:.4f}")
# проверка численной производной
loss = lambda w, b: (1 / (1 + math.exp(-(w * x + b))) - y) ** 2
h = 1e-6
print(f"численно: {(loss(w + h, b) - loss(w - h, b)) / (2 * h):.4f}, {(loss(w, b + h) - loss(w, b - h)) / (2 * h):.4f}")
# шаг градиентного спуска
lr = 1.0
w2, b2 = w - lr * dL_dw, b - lr * dL_db
print(f"после шага: L = {loss(w2, b2):.4f} (было {L:.4f})")
Один шаг против градиента — и потеря уменьшилась. Повторите это сотни раз для всех объектов — и нейрон обучится. Именно это вы сделаете в заданиях.
Затухающие градиенты
В глубокой сети с сигмоидой в цепочке много множителей \sigma'(z) \le 0.25. Произведение двадцати таких чисел — меньше 10^{-12}: до первых слоёв градиент почти не доходит, и они не учатся. Это проблема затухающих градиентов. Обратная беда — взрывающиеся градиенты, когда множители больше 1.
import numpy as np
import vis
depth = np.arange(1, 31)
vis.line(depth, {"сигмоида (множитель 0.25)": 0.25 ** depth, "tanh (до 1, обычно ~0.6)": 0.6 ** depth, "ReLU (1 у активных)": np.ones(30)},
title="Масштаб градиента после n слоёв", xlabel="число слоёв", logy=True)
Поэтому в современных сетях используют ReLU и её родственников (у них производная 1), аккуратную инициализацию весов, нормализацию (BatchNorm, LayerNorm) и остаточные связи. Всем этим темам будут посвящены отдельные уроки.
Итог
- Правило цепочки: производная композиции — произведение производных звеньев, \frac{dy}{dx} = \frac{dy}{du}\frac{du}{dx}.
- Для вычисления нужны промежуточные значения: сначала прямой проход, затем обратный — это backpropagation.
- Производная потерь нейрона: \frac{\partial L}{\partial w} = 2(p - y) \cdot p(1-p) \cdot x.
- Длинные цепочки множителей меньше 1 приводят к затухающим градиентам.
Практика решено 0 из 7
Найдите производную f(x) = (3x + 1)^2 в точке x = 1.
Найдите производную f(x) = e^{2x} в точке x = 0.
Найдите производную f(x) = \ln(x^2 + 1) в точке x = 1.
В цепочке L(\sigma(z(w))) известно: \frac{\partial L}{\partial \sigma} = 0.4, \frac{d\sigma}{dz} = 0.25, \frac{dz}{dw} = 3. Чему равна \frac{dL}{dw}?
Сеть из 20 слоёв с сигмоидой. Производная сигмоиды не больше 0.25. Что примерно происходит с градиентом первого слоя?
Градиент нейрона. Нейрон с одним входом: z = wx + b, p = \sigma(z), потеря L = (p - y)^2.
Напишите neuron_grads(w, b, x, y), возвращающую пару \left(\frac{\partial L}{\partial w}, \frac{\partial L}{\partial b}\right),
по правилу цепочки:
Какие тесты проверяют код (1)
def loss(w, b, x, y):
p = 1 / (1 + math.exp(-(w * x + b)))
return (p - y) ** 2
h = 1e-6
for w, b, x, y in [(0.5, -0.2, 2.0, 1.0), (-1.0, 0.3, 0.5, 0.0), (2.0, 1.0, -1.5, 1.0)]:
gw, gb = neuron_grads(w, b, x, y)
nw = (loss(w + h, b, x, y) - loss(w - h, b, x, y)) / (2 * h)
nb = (loss(w, b + h, x, y) - loss(w, b - h, x, y)) / (2 * h)
assert abs(gw - nw) < 1e-6, f"∂L/∂w неверно для {(w, b, x, y)}: {gw} vs численно {nw}"
assert abs(gb - nb) < 1e-6, f"∂L/∂b неверно для {(w, b, x, y)}: {gb} vs численно {nb}"
import math
def neuron_grads(w, b, x, y):
z = w * x + b
p = 1 / (1 + math.exp(-z))
dL_dp = 2 * (p - y)
dp_dz = p * (1 - p)
dL_dz = dL_dp * dp_dz
return dL_dz * x, dL_dz
Обучаем нейрон. Используя neuron_grads из прошлого задания (скопируйте её), напишите
train_neuron(data, lr=1.0, epochs=200): начните с w = 0, b = 0, и epochs раз пройдите по
всем парам (x, y) из data, делая шаг w -= lr * gw; b -= lr * gb после каждой пары.
Верните (w, b).
Данные: «x > 1 → 1, иначе 0». Обученный нейрон должен правильно классифицировать все точки
(σ(wx + b) > 0.5 ⇔ y = 1).
Какие тесты проверяют код (1)
data = [(-1.0, 0), (0.0, 0), (0.5, 0), (0.8, 0), (1.3, 1), (1.6, 1), (2.0, 1), (3.0, 1)]
w, b = train_neuron(data)
sig = lambda z: 1 / (1 + math.exp(-z))
wrong = [(x, y) for x, y in data if (sig(w * x + b) > 0.5) != (y == 1)]
assert not wrong, f"Ошибки на точках {wrong}; w={w:.3f}, b={b:.3f}"
assert w > 0, "Вес должен стать положительным: чем больше x, тем ближе к классу 1"
import math
def neuron_grads(w, b, x, y):
p = 1 / (1 + math.exp(-(w * x + b)))
dL_dz = 2 * (p - y) * p * (1 - p)
return dL_dz * x, dL_dz
def train_neuron(data, lr=1.0, epochs=200):
w, b = 0.0, 0.0
for _ in range(epochs):
for x, y in data:
gw, gb = neuron_grads(w, b, x, y)
w -= lr * gw
b -= lr * gb
return w, b
Войдите, чтобы отмечать пройденные уроки.