Нейронные сети Перцептрон, функции активации и обратное распространение ошибки
0%

Перцептрон, функции активации и обратное распространение ошибки

Перцептрон, функции активации и обратное распространение ошибки

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

Статья отвечает на три вопроса, и порядок не случаен: что умеет один нейрон и где он упирается в стену (перцептрон, 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

Для 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 критично: производные считаются по обеим величинам, и путаница между ними — источник большинства багов в самописных реализациях.

Пунктирные стрелки от $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 одно матричное умножение
Время, обратный проход слоя прямого два 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()

Шаг «освободить буферы» не случаен: 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 — не факультатив. Единственный способ отличить «модель плохо учится» от «в градиенте баг».

Источники


Что дальше

Мы умеем считать градиент. Но градиент — только направление; чтобы сеть действительно обучилась, нужно ответить, каким шагом идти, откуда стартовать и как не переобучиться. Дальше — оптимизаторы от SGD с моментом до AdamW, схемы инициализации, батч- и слой-нормализация, dropout и weight decay: весь инструментарий, превращающий корректный градиент в работающую модель.

Обучение сетей: оптимизаторы, инициализация, нормализация, регуляризация

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов