Перцептрон, функции активации и обратное распространение ошибки
В обзорной статье трека мы договорились, что нейросеть — это композиция аффинных преобразований и нелинейностей, а обучение — подбор параметров градиентным спуском. Осталось главное: откуда берётся градиент.
Статья отвечает на три вопроса, и порядок не случаен: что умеет один нейрон и где он упирается в стену (перцептрон, 1958); почему нелинейность — не декоративная деталь, а несущая конструкция; как правило цепочки становится алгоритмом, дающим производную скаляра по миллиардам параметров за один обратный проход ценой ≈2× прямого.
Если после статьи вы выведете backprop для двухслойной сети на листе бумаги и напишете мини-autograd с нуля — цель достигнута. Всё остальное в треке (CNN, RNN, трансформеры) — разные способы соединять узлы одного и того же графа.
1. Один нейрон: от аналогии к линейной модели
Модель Мак-Каллока и Питтса (1943) суммировала входы и «срабатывала» при превышении порога. Розенблатт в 1958-м добавил решающее — обучаемые веса и правило их обновления (The Perceptron, Psychological Review).
$$y = \varphi(w^\top x + b) = \varphi\left(\sum_{i=1}^{d} w_i x_i + b\right)$$
| Компонент | Что это | Интуиция |
|---|---|---|
| $w \in \mathbb{R}^d$ | веса | «насколько важен и в какую сторону влияет признак $i$» |
| $b \in \mathbb{R}$ | сдвиг (bias) | порог срабатывания; без него гиперплоскость обязана проходить через ноль |
| $\varphi$ | активация | нелинейное решающее преобразование |
У классического перцептрона $\varphi$ — ступенька ($1$ при $z \ge 0$, иначе $0$). Геометрически $w^\top x + b = 0$ задаёт гиперплоскость, а нейрон отвечает на вопрос «с какой стороны от неё лежит точка». Один нейрон — это линейный классификатор, не больше. Правило обучения Розенблатта обновляет веса только на ошибках:
$$w \leftarrow w + \eta,(y - \hat{y}),x, \qquad b \leftarrow b + \eta,(y - \hat{y})$$
Верное предсказание даёт $y - \hat{y} = 0$ и ничего не меняет; ошибка двигает гиперплоскость в сторону неверно классифицированной точки.
import numpy as np
def perceptron_fit(X, y, epochs=20, lr=1.0):
"""Классический перцептрон Розенблатта. X: (n, d), y ∈ {0,1}."""
w, b = np.zeros(X.shape[1]), 0.0 # для линейной модели нули безопасны (см. п.7)
for epoch in range(epochs):
errors = 0
for xi, yi in zip(X, y):
upd = lr * (yi - (1.0 if xi @ w + b >= 0 else 0.0))
if upd != 0.0:
w, b, errors = w + upd * xi, b + upd, errors + 1 # двигаем плоскость к точке
if errors == 0: # выборка разделена — обновлений больше не будет
return w, b, epoch + 1
return w, b, epochs
X = np.array([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
print(perceptron_fit(X, np.array([0., 0., 0., 1.]))) # AND разделим — сходится за пару эпох
Теорема сходимости (Novikoff, 1962): если выборка линейно разделима с зазором $\gamma$ и $|x| \le R$, алгоритм сделает не более $(R/\gamma)^2$ ошибок и остановится. Три следствия, которые обычно забывают: на неразделимых данных гарантий нет вообще (алгоритм колеблется бесконечно); он находит какую-то плоскость, не лучшую (максимизацией зазора занимается SVM — см. трек «Машинное обучение»); правило перцептрона не является градиентным спуском, у ступеньки производная всюду нулевая — именно поэтому перцептроны нельзя было составить в стек, назад нечего распространять.
Стена: XOR
В 1969 году Минский и Пейперт в книге Perceptrons показали, что XOR не вычислим одним перцептроном. Причина чисто геометрическая.
Для AND и OR прямая существует. У XOR точки классов лежат по диагоналям, и никакая прямая их не разделит: выпуклые оболочки классов пересекаются — отрезок (0,0)–(1,1) пересекает (0,1)–(1,0).
Публикация обрушила финансирование почти на два десятилетия — первая «зима ИИ». Ирония в том, что
решение было очевидно уже тогда: добавить скрытый слой, ведь XOR раскладывается как
(x₁ OR x₂) AND NOT (x₁ AND x₂) — два нейрона в скрытом слое, один на выходе. Не хватало не
архитектуры, а способа её обучить; этот способ — backprop — стал широко известен только после
статьи Rumelhart, Hinton, Williams в Nature (1986).
2. Функции активации: почему без них всё разваливается
Пусть $\varphi$ тождественна. Тогда двухслойная сеть — это
$$W_2(W_1 x + b_1) + b_2 = (W_2 W_1),x + (W_2 b_1 + b_2) = W’ x + b’$$
снова линейная модель: сто слоёв без активаций эквивалентны одному. Нелинейность — единственное, что превращает глубину в выразительность. От активации мы хотим: нелинейности; дифференцируемости почти всюду (в точке излома производную доопределяют соглашением); ненасыщающегося градиента в рабочем диапазоне; дешевизны (она вызывается для каждого элемента тензора, миллиарды раз за шаг); желательно — выхода, центрированного около нуля.
| Активация | Формула | Производная | Диапазон | Где применяют |
|---|---|---|---|---|
| Sigmoid | $\sigma(z) = \frac{1}{1+e^{-z}}$ | $\sigma(z)(1-\sigma(z))$ | (0, 1) | выход бинарной классификации, гейты LSTM |
| Tanh | $\tanh z$ | $1 - \tanh^2 z$ | (−1, 1) | RNN, малые сети; центрирован по нулю |
| ReLU | $\max(0, z)$ | $[z > 0]$ | [0, ∞) | дефолт для CNN и MLP |
| Leaky ReLU | $\max(\alpha z, z)$, $\alpha{=}0.01$ | $[z>0] + \alpha[z\le0]$ | (−∞, ∞) | лечит «мёртвые» нейроны |
| GELU | $z \cdot \Phi(z)$ | гладкая | (−0.17, ∞) | BERT, GPT — дефолт трансформеров |
| SiLU / Swish | $z \cdot \sigma(z)$ | гладкая | (−0.28, ∞) | EfficientNet, YOLO |
| SwiGLU | $\mathrm{SiLU}(xW) \odot xV$ | гладкая | — | LLaMA, PaLM, современные LLM |
| Softmax | $\frac{e^{z_i}}{\sum_j e^{z_j}}$ | якобиан $\mathrm{diag}(p) - pp^\top$ | симплекс | выход мультиклассовой задачи |
Sigmoid насыщается. При $|z| > 5$ производная $\sigma’(z) < 0.007$, а её максимум равен 0.25 даже в идеальной точке $z=0$: даже в лучшем случае слой ослабляет градиент вчетверо, а в глубокой сети затухание экспоненциально. Именно это, а не «нехватка вычислений», блокировало глубокие сети до середины 2000-х. Плюс sigmoid не центрирован — выход всегда положителен, значит все компоненты градиента по $W$ следующего слоя имеют одинаковый знак, и оптимизация идёт «зигзагом».
ReLU сломала потолок. На положительной полуоси производная ровно 1 — градиент проходит без затухания; она дешева, даёт разреженность (≈50% нулей), и именно с ней AlexNet выиграла ImageNet в 2012-м. Расплата — умирающий ReLU: нейрон, попавший в область $z < 0$ на всех примерах, имеет нулевую производную и мёртв навсегда. Типичная причина — большой learning rate, утащивший bias глубоко в минус; лечится Leaky ReLU / GELU либо аккуратным LR (об этом — в следующей статье).
Гладкие активации для трансформеров. GELU (Hendrycks & Gimpel, 2016) взвешивает вход на вероятность по стандартной нормали, $\mathrm{GELU}(z) = z,\Phi(z)$: нет излома, есть небольшая отрицательная часть, и на трансформерах сходимость стабильно лучше. SwiGLU (Shazeer, 2020) — гейтовая версия, стандарт в современных LLM: FFN-блок использует два линейных проектора вместо одного.
Практическое правило. Скрытые слои: ReLU как дефолт, GELU/SiLU — если это трансформер или вы гонитесь за последними процентами. Выходной слой определяется задачей, а не вкусом: линейный — для регрессии, sigmoid — для бинарной, softmax — для мультиклассовой. ReLU на выходе регрессии обрезает отрицательные предсказания — классическая ошибка.
3. Многослойный перцептрон: прямой проход
Слой — это не цикл по нейронам, а одно матричное умножение; именно поэтому GPU здесь эффективен. Для батча $X \in \mathbb{R}^{B \times d_{in}}$:
$$Z^{[1]} = XW^{[1]} + b^{[1]}, \quad A^{[1]} = \varphi(Z^{[1]}), \quad Z^{[2]} = A^{[1]}W^{[2]} + b^{[2]}$$
Разделение $Z$ (пред-активация, «логиты») и $A$ (после активации) кажется педантизмом, но при выводе backprop критично: производные считаются по обеим величинам, и путаница между ними — источник большинства багов в самописных реализациях.
(B × d_in)"] --> Z1["Z¹ = XW¹ + b¹"] Z1 --> A1["A¹ = φ(Z¹)"] --> Z2["Z² = A¹W² + b²"] Z2 --> L["L = CE(softmax(Z²), Y)"] end subgraph BWD["Обратный проход"] direction RL dL["∂L/∂L = 1"] --> dZ2["∂L/∂Z² = P − Y"] dZ2 --> dA1["∂L/∂A¹ = ∂L/∂Z² · W²ᵀ"] dA1 --> dZ1["∂L/∂Z¹ = ∂L/∂A¹ ⊙ φ'(Z¹)"] dZ1 --> dX["∂L/∂X (если ниже есть слои)"] end dZ2 -.->|"A¹ᵀ · ∂L/∂Z²"| gW2["∇W²"] dZ1 -.->|"Xᵀ · ∂L/∂Z¹"| gW1["∇W¹"] A1 -.->|"кэш активаций"| dZ2 Z1 -.->|"кэш пред-активаций"| dZ1 style Z1 fill:#3b82f6,fill-opacity:0.15 style Z2 fill:#3b82f6,fill-opacity:0.15 style dZ1 fill:#ef4444,fill-opacity:0.15 style dZ2 fill:#ef4444,fill-opacity:0.15 style gW1 fill:#10b981,fill-opacity:0.15 style gW2 fill:#10b981,fill-opacity:0.15
Пунктирные стрелки от $A^{[1]}$ и $Z^{[1]}$ вниз — не украшение: обратный проход требует значений, вычисленных на прямом. Отсюда растёт вся память под активации, и отсюда же — gradient checkpointing.
4. Backpropagation: правило цепочки, ставшее алгоритмом
Функция потерь — скаляр, а производные нужны по миллионам параметров. Наивный путь (численно возмущать каждый параметр) стоит $O(|\theta|)$ прямых проходов — для 100 млн параметров это вечность. Backprop — это reverse-mode автоматическое дифференцирование: один прямой проход, один обратный, и все производные готовы. Ключевое наблюдение: при скалярном выходе и множестве входов дешевле идти от выхода к входам, переиспользуя общие подвыражения.
Каждый узел знает ровно две вещи: свою локальную производную и множитель, пришедший сверху; их произведение — вклад в градиент входов. Ни один узел не знает архитектуру целиком — поэтому backprop одинаково работает для MLP, свёрток и трансформеров: граф разный, правило одно.
Вывод для двухслойной сети
Задача бинарная, выход — логит $z^{[2]}$, потеря — BCE $L = -\frac{1}{B}\sum [y\log p + (1-y)\log(1-p)]$, где $p = \sigma(z^{[2]})$.
Шаг 1. Слияние sigmoid/softmax с кросс-энтропией. Считать их производные по отдельности — плохая идея: порознь каждая численно неустойчива, а вместе они схлопываются:
$$\frac{\partial L}{\partial z^{[2]}} = \frac{\partial L}{\partial p}\cdot\frac{\partial p}{\partial z^{[2]}} = \left(\frac{p-y}{p(1-p)}\right)\cdot p(1-p) = p - y$$
Множители $p(1-p)$ сокращаются. То же верно для softmax + категориальной CE:
$\partial L/\partial Z = P - Y$. Прямое следствие — библиотеки дают BCEWithLogitsLoss и
CrossEntropyLoss, принимающие логиты, а не вероятности.
Шаг 2. Вниз по слоям. Обозначим $\delta^{[\ell]} = \partial L / \partial Z^{[\ell]}$ — «ошибка на слое».
$$\delta^{[2]} = \tfrac{1}{B}(P - Y), \qquad \nabla_{W^{[2]}} = (A^{[1]})^\top \delta^{[2]}, \qquad \nabla_{b^{[2]}} = \textstyle\sum_{b} \delta^{[2]}_ b$$
$$\delta^{[1]} = \left(\delta^{[2]} (W^{[2]})^\top\right) \odot \varphi’(Z^{[1]}), \qquad \nabla_{W^{[1]}} = X^\top \delta^{[1]}, \qquad \nabla_{b^{[1]}} = \textstyle\sum_{b} \delta^{[1]}_ b$$
где $\odot$ — поэлементное умножение. Закономерность рекурсивна и обобщается на любую глубину:
Ошибка проходит назад через матрицу весов транспонированием и гасится производной активации поэлементно. Градиент по весам — произведение входа слоя на ошибку слоя.
Умножение на $\varphi’$ на каждом слое и есть механика затухающих/взрывающихся градиентов: sigmoid ($\varphi’ \le 0.25$) → затухание, большие веса → взрыв. Про инициализацию и нормализацию, которые это лечат, — в следующей статье.
BACKPROP(X, Y, W[1..L], b[1..L]):
A[0] ← X # прямой проход: кэшируем всё, что нужно назад
для ℓ = 1..L:
Z[ℓ] ← A[ℓ-1] · W[ℓ] + b[ℓ]
A[ℓ] ← φ(Z[ℓ]) # на последнем слое — тождественная (логиты)
L_val ← loss(A[L], Y)
δ ← ∂loss/∂Z[L] # для softmax+CE это просто (P − Y)/B
для ℓ = L..1: # обратный проход
∇W[ℓ] ← A[ℓ-1]ᵀ · δ
∇b[ℓ] ← сумма δ по оси батча
если ℓ > 1: δ ← (δ · W[ℓ]ᵀ) ⊙ φ'(Z[ℓ-1])
вернуть L_val, ∇W, ∇b
Сложность и цена
| Ресурс | Оценка | Комментарий |
|---|---|---|
| Время, прямой проход слоя | $O(B, d_{in} d_{out})$ ≈ $2 B d_{in} d_{out}$ FLOPs | одно матричное умножение |
| Время, обратный проход слоя | ≈ 2× прямого | два matmul: по входу и по весам |
| Полный шаг обучения | ≈ $6 \cdot B \cdot N_{params}$ FLOPs | классическая оценка для трансформеров |
| Память под параметры | $\sum_\ell (d_{in}d_{out} + d_{out})$ | плюс столько же на градиенты и состояние оптимизатора |
| Память под активации | $O(B \sum_\ell d_\ell)$ | растёт линейно по батчу и глубине |
Практический вывод: backprop разменивает память на время — активации всех слоёв обязаны дожить до
обратного прохода, и именно они, а не веса, чаще всего вызывают CUDA out of memory. Смягчения:
gradient checkpointing (Chen et al., 2016) — хранить активации
только каждого $\sqrt{L}$-го слоя, остальные пересчитывать (память $O(\sqrt{L})$ вместо $O(L)$ ценой
≈30% времени); gradient accumulation — несколько микробатчей на один шаг оптимизатора;
mixed precision (bf16/fp16) — активации занимают вдвое меньше.
5. Реализация с нуля: MLP, решающий XOR
Меньше 50 строк numpy, честный вывод формул выше, без автодифференцирования.
import numpy as np
rng = np.random.default_rng(0)
def relu(z): return np.maximum(0.0, z)
def relu_grad(z): return (z > 0).astype(z.dtype) # в нуле берём 0 по соглашению
def bce_with_logits(z, y):
"""Устойчивая BCE. Наивная −y·log(σ(z)) даёт inf при |z| ≳ 40."""
return np.mean(np.maximum(z, 0) - z * y + np.log1p(np.exp(-np.abs(z))))
class MLP:
def __init__(self, d_in, d_hidden, d_out=1):
# He-инициализация: дисперсия 2/fan_in компенсирует обнуление половины сигнала ReLU
self.W1 = rng.normal(0, np.sqrt(2.0 / d_in), (d_in, d_hidden)); self.b1 = np.zeros(d_hidden)
self.W2 = rng.normal(0, np.sqrt(2.0 / d_hidden), (d_hidden, d_out)); self.b2 = np.zeros(d_out)
def forward(self, X):
self.X = X # кэш нужен обратному проходу
self.Z1 = X @ self.W1 + self.b1
self.A1 = relu(self.Z1)
self.Z2 = self.A1 @ self.W2 + self.b2
return self.Z2 # возвращаем ЛОГИТЫ, не вероятности
def backward(self, Y):
B = self.X.shape[0]
P = 1.0 / (1.0 + np.exp(-self.Z2))
dZ2 = (P - Y) / B # sigmoid + BCE схлопнулись в разность
dA1 = dZ2 @ self.W2.T # ошибка назад через транспонированные веса
dZ1 = dA1 * relu_grad(self.Z1) # гасим производной активации
return {"W1": self.X.T @ dZ1, "b1": dZ1.sum(0),
"W2": self.A1.T @ dZ2, "b2": dZ2.sum(0)}
def step(self, grads, lr):
for name, g in grads.items():
setattr(self, name, getattr(self, name) - lr * g)
X = np.array([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
Y = np.array([[0.], [1.], [1.], [0.]]) # XOR — то, что убило одиночный перцептрон
net = MLP(d_in=2, d_hidden=8)
for epoch in range(3000):
loss = bce_with_logits(net.forward(X), Y)
net.step(net.backward(Y), lr=0.5) # loss: 0.700 → 0.0016
print("предсказания:", (net.forward(X) > 0).astype(int).ravel()) # → [0 1 1 0]
Восемь скрытых нейронов решают задачу, заморозившую направление на двадцать лет: скрытый слой строит новое пространство признаков, где классы XOR линейно разделимы, — representation learning в самой примитивной форме.
Проверка градиента: единственный способ поверить своему коду
Ошибка в backprop почти никогда не роняет программу — она молча ухудшает сходимость, и вы неделю крутите learning rate вместо того, чтобы найти забытое транспонирование. Защита одна:
def gradient_check(net, X, Y, param="W1", n_checks=12, eps=1e-5):
"""Сравниваем аналитический градиент с численным. Порог: rel_err < 1e-6."""
net.forward(X)
analytic = net.backward(Y)[param]
P = getattr(net, param)
for _ in range(n_checks):
idx = tuple(rng.integers(0, s) for s in P.shape)
old = P[idx]
P[idx] = old + eps; l_plus = bce_with_logits(net.forward(X), Y)
P[idx] = old - eps; l_minus = bce_with_logits(net.forward(X), Y)
P[idx] = old # обязательно восстановить!
numeric = (l_plus - l_minus) / (2 * eps) # O(eps²); односторонняя — лишь O(eps)
a = analytic[idx]
rel = abs(numeric - a) / max(1e-8, abs(numeric) + abs(a))
assert rel < 1e-6, f"{param}{idx}: аналит={a:.3e} числ={numeric:.3e} err={rel:.1e}"
gradient_check(MLP(2, 8), X, Y, "W1")
gradient_check(MLP(2, 8), X, Y, "b2")
Тонкости, на которых проверка ломается у большинства: считайте в float64 (в float32 шум
округления сравним с $\varepsilon$ и проверка ложно падает); отключайте dropout и любую стохастику,
иначе два прямых прохода считают разные функции; помните, что ReLU не дифференцируема в нуле —
при $z$ около 0 численная и аналитическая производные законно разойдутся, проверяйте подальше от
излома либо на tanh-версии сети; берите $\varepsilon \approx 10^{-5}$ (меньше — доминирует округление,
больше — ошибка усечения). В PyTorch то же делает
torch.autograd.gradcheck.
6. Autograd: как backprop становится универсальным
Ручной вывод формул не масштабируется: поменяли архитектуру — переписывайте backward. Фреймворки
решают это динамическим вычислительным графом: каждая операция создаёт узел, который помнит
родителей и умеет протолкнуть градиент назад. Обзор подходов —
Baydin et al., «Automatic Differentiation in ML: a Survey».
Ключевое здесь — рефлексивная агрегация Value o-- Value. Граф строится динамически, во время
прямого прохода: узел записывает ссылки на родителей и замыкание _backward, знающее локальную
производную. Это подход PyTorch (define-by-run), в отличие от статических графов раннего TensorFlow.
import math
class Value:
"""Минимальный скалярный autograd в духе karpathy/micrograd."""
def __init__(self, data, _children=(), _op=""):
self.data, self.grad = float(data), 0.0
self._prev, self._op = set(_children), _op
self._backward = lambda: None # как протолкнуть градиент родителям
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.0 - t * t) * out.grad
out._backward = _backward
return out
def backward(self):
# топологическая сортировка: узел обрабатываем только после всех его потребителей
topo, visited = [], set()
def build(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build(child)
topo.append(v)
build(self)
self.grad = 1.0 # ∂L/∂L = 1 — точка входа всей рекурсии
for v in reversed(topo):
v._backward()
x1, x2 = Value(2.0), Value(0.0)
w1, w2 = Value(-3.0), Value(1.0)
y = (x1 * w1 + x2 * w2 + Value(6.881373587)).tanh()
y.backward()
print(f"∂y/∂w1 = {w1.grad:.4f} ∂y/∂x1 = {x1.grad:.4f}") # ≈ 1.0000 и ≈ −1.5000
Два места легко проглядеть, а они определяют корректность. +=, а не =: если тензор используется
в графе дважды (skip-connection, шаринг весов), вклады разных потребителей обязаны суммироваться —
это правило цепочки для функции нескольких аргументов; ровно поэтому в PyTorch нужен
optimizer.zero_grad(), иначе вы обучаетесь на сумме градиентов всех предыдущих батчей.
Топологическая сортировка обязательна: узел нельзя обрабатывать, пока не пришли вклады от всех
его потребителей, и наивный DFS без topo-sort даёт неверные градиенты на любом графе с ветвлением.
Полная версия с расширенным набором операций — karpathy/micrograd, около 150 строк; после её прочтения PyTorch перестаёт быть магией.
Что происходит внутри loss.backward()
иначе градиенты аккумулируются U->>M: out = model(x) M->>T: узлы Linear / ReLU / Linear
с кэшем x, маски z>0, a¹ M-->>U: логиты (grad_fn=AddmmBackward) U->>E: loss.backward() E->>T: топологическая сортировка, seed ∂L/∂L = 1 loop по узлам в обратном порядке E->>T: локальный якобиан × входящий градиент (VJP),
аккумулировать в .grad листьев end E->>T: освободить буферы графа Note over E,T: повторный backward() упадёт
без retain_graph=True U->>O: step() → θ ← θ − η·∇θ
Шаг «освободить буферы» не случаен: PyTorch по умолчанию уничтожает граф после обратного прохода,
потому что активации — самый дорогой ресурс. Отсюда классическое
Trying to backward through the graph a second time.
Тот же XOR на PyTorch — ровно та же математика, backward пишет за вас движок:
import torch, torch.nn as nn
model = nn.Sequential(nn.Linear(2, 8), nn.ReLU(), nn.Linear(8, 1))
criterion = nn.BCEWithLogitsLoss() # принимает ЛОГИТЫ: sigmoid внутри и устойчиво
opt = torch.optim.SGD(model.parameters(), lr=0.5)
X = torch.tensor([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
Y = torch.tensor([[0.], [1.], [1.], [0.]])
for epoch in range(3000):
opt.zero_grad() # без этого градиенты сложатся с прошлым шагом
criterion(model(X), Y).backward() # обход графа, заполнение .grad
opt.step() # θ ← θ − η·∇θ
print((model(X) > 0).int().flatten()) # → tensor([0, 1, 1, 0])
7. Типичные ошибки
| Ошибка | Что происходит на самом деле |
|---|---|
Sigmoid/softmax перед BCEWithLogitsLoss / CrossEntropyLoss |
двойная сигмоида; сеть учится, но заметно хуже — молчаливый баг. Эти функции ждут логиты |
Забытый optimizer.zero_grad() |
градиенты накапливаются, эффективный LR растёт от шага к шагу, обучение расходится «непонятно почему» |
| Инициализация скрытого слоя нулями | симметрия не нарушена: все нейроны получают одинаковый градиент, слой из 512 работает как один. Для одиночного перцептрона нули безопасны |
| Слишком большая инициализация | активации улетают в насыщение tanh/sigmoid, $\varphi’ \approx 0$, обучение стоит с первого шага |
| Умирающие ReLU | доля нулей в активациях устойчиво выше ~90% — снижайте LR или берите Leaky ReLU / GELU |
Ждать .grad у промежуточного тензора |
он есть только у листьев; нужен retain_grad(). Половина «нулевых градиентов» при отладке — это оно |
Разрыв графа in-place операцией или .detach()/.item() в середине |
градиент тихо перестаёт течь через часть сети, обучаются не те параметры |
Нет model.eval() и torch.no_grad() на валидации |
Dropout и BatchNorm работают в train-режиме, а граф строится и жжёт память впустую |
| Активация на выходе регрессии | ReLU обрезает отрицательные предсказания, sigmoid душит в (0,1). Выход регрессии линеен |
| Ни разу не запускали gradient check | для любой рукописной операции с кастомным backward это не опция, а процедура |
8. Как это выглядит в проде
- Vector-Jacobian product, а не якобиан. Никто не материализует якобиан (для слоя 4096×4096 это
16M×16M чисел): каждая операция реализует «входящий градиент → исходящий», то есть VJP. См.
PyTorch autograd notes. Когда нужной операции
нет или аналитический backward дешевле автоматического, пишут свою
autograd.Function— и тогда gradient check обязателен. - Фьюзинг ядер.
Linear + bias + GELUкомпилируется в одно CUDA-ядро (torch.compile, Triton, TensorRT) — экономия не на FLOPs, а на трафике в память, который и есть бутылочное горлышко. - Mixed precision. Проходы в bf16/fp16, мастер-копия весов и аккумуляция — в fp32. Для fp16 нужен loss scaling: мелкие градиенты иначе схлопываются в денормали и нули.
- Gradient clipping.
clip_grad_norm_(params, 1.0)— стандарт для трансформеров и RNN: обрезает норму градиента, спасая от единичных взрывов на «плохих» батчах. - Мониторинг норм градиента по слоям. Первое, куда смотрят, когда сеть не учится: падает на
порядки от верхних слоёв к нижним → затухание, растёт до NaN → взрыв. Логируйте
grad_normсразу. - Straight-through estimator. Для недифференцируемых операций (квантизация, argmax, сэмплирование) на обратном проходе подставляют фиктивную производную, часто тождественную. Осознанная ложь, которая работает; подробнее — в статье про инференс и деплой.
9. Мини-итог
- Один нейрон — линейный классификатор. Правило перцептрона сходится за конечное число шагов только на линейно разделимых данных и не является градиентным спуском.
- XOR — не курьёз, а граница класса моделей. Снимается скрытым слоем, строящим новое пространство признаков. Не хватало не архитектуры, а способа её обучить.
- Нелинейность несёт всю конструкцию. Без неё композиция слоёв схлопывается в один линейный слой. Sigmoid насыщается и убивает градиент, ReLU его пропускает, GELU/SwiGLU — стандарт трансформеров.
- Backprop = правило цепочки + топологический порядок + локальность. Один прямой и один обратный проход дают все производные ценой ≈2× прямого — это соотношение и делает возможным обучение моделей с сотнями миллиардов параметров. Ошибка идёт назад транспонированной матрицей весов и гасится $\varphi’$; повторяющееся умножение на $\varphi’$ — источник затухающих и взрывающихся градиентов.
- Память под активации — реальное ограничение. Backprop меняет память на время; checkpointing, accumulation и mixed precision — стандартные противовесы.
- Gradient check — не факультатив. Единственный способ отличить «модель плохо учится» от «в градиенте баг».
Источники
- Rosenblatt F. The Perceptron, Psychological Review, 1958 — psycnet
- Rumelhart D., Hinton G., Williams R. Learning representations by back-propagating errors, Nature, 1986 — nature.com
- Goodfellow I. et al. Deep Learning, гл. 6 — deeplearningbook.org
- Nielsen M. Neural Networks and Deep Learning, гл. 2 — лучший интуитивный вывод backprop: neuralnetworksanddeeplearning.com/chap2.html
- Stanford CS231n, Backpropagation, Intuitions — cs231n.github.io/optimization-2
- Karpathy A. — Yes you should understand backprop и micrograd
- Baydin A. et al. Automatic Differentiation in ML: a Survey — arXiv:1502.05767
- Glorot X., Bengio Y. Understanding the difficulty of training deep feedforward networks — PMLR v9; He K. et al. Delving Deep into Rectifiers — arXiv:1502.01852
- Hendrycks D., Gimpel K. GELU — arXiv:1606.08415; Shazeer N. GLU Variants Improve Transformer — arXiv:2002.05202
- Chen T. et al. Training Deep Nets with Sublinear Memory Cost — arXiv:1604.06174
- PyTorch, Autograd mechanics — pytorch.org/docs/stable/notes/autograd.html
Что дальше
Мы умеем считать градиент. Но градиент — только направление; чтобы сеть действительно обучилась, нужно ответить, каким шагом идти, откуда стартовать и как не переобучиться. Дальше — оптимизаторы от SGD с моментом до AdamW, схемы инициализации, батч- и слой-нормализация, dropout и weight decay: весь инструментарий, превращающий корректный градиент в работающую модель.
Обучение сетей: оптимизаторы, инициализация, нормализация, регуляризация