Перейти к содержанию
Новое AiManual теперь в MAX Подписаться
Публикация AiManual

Обратное распространение: как цепное правило и повторное использование вычислений ускоряют обучение нейросетей

Разбираем механику цепного правила в обратном распространении: как системное вычисление градиентов снижает сложность с O(N²) до O(N) и делает обучение глубоких

Коротко

Что будет в материале

  1. 01

    Почему прямое вычисление градиентов не масштабируется

  2. 02

    Цепное правило как основа системного подхода

  3. 03

    Как повторное использование вычислений делает обучение возможным

  4. 04

    От цепного правила к эффективному алгоритму: что дальше?

Обратное распространение ошибки (backpropagation) - это алгоритм, который вычисляет градиенты функции потерь по всем параметрам нейронной сети за один проход от выхода к входу. В его основе лежит цепное правило из математического анализа. Без него прямое дифференцирование каждого веса в отдельности приводило бы к экспоненциальному росту повторных вычислений и делало обучение глубоких сетей практически невозможным. В этой статье мы разберём механику цепного правила на конкретном примере, покажем, как именно повторное использование промежуточных производных снижает вычислительную сложность с O(N²) до O(N), и подготовим почву для финальной части руководства, где рассмотрим эффективные реализации для разных архитектур.

Знание этих основ помогает осознанно работать с инструментами вроде torch.autograd, писать кастомные слои и отлаживать градиенты. Мы пройдём путь от наивного подхода, который быстро упирается в вычислительный потолок, до системного метода, который сделал возможным обучение моделей с миллиардами параметров.

Почему прямое вычисление градиентов не масштабируется

Прямое вычисление градиентов - это подход, при котором частная производная функции потерь считается отдельно для каждого параметра сети. На первый взгляд кажется, что достаточно применить определение производной и получить результат. Проблема в том, что при увеличении числа параметров объём повторных вычислений растёт катастрофически быстро.

Представьте полносвязную нейросеть с тремя входами, двумя скрытыми слоями на четыре и два нейрона и одним выходом. Функция потерь зависит от десяти весов. Чтобы вычислить градиент по одному весу в первом слое, нужно продифференцировать всю цепочку операций: линейное преобразование, активацию, второе линейное преобразование, вторую активацию, выходной слой и функцию потерь. Для другого веса в том же слое придётся повторить почти те же вычисления - изменится лишь последний множитель в цепочке производных. Для десяти весов мы получим десятки повторных вычислений одних и тех же промежуточных производных. Если масштабировать сеть до тысячи параметров, счёт пойдёт на тысячи повторений. Для современных моделей с миллиардами параметров наивный подход требует O(N²) операций, что делает обучение нереализуемым даже на самых мощных кластерах.

Именно поэтому фреймворки вроде PyTorch и TensorFlow не считают производные «в лоб» для каждого параметра. Они строят динамический граф вычислений и применяют автоматическое дифференцирование, основанное на обратном распространении. Если вы когда-либо запускали loss.backward() в PyTorch, вы использовали алгоритм, который решает проблему повторных вычислений.

Пример: нейросеть с двумя скрытыми слоями и экспоненциальный рост вычислений

Рассмотрим конкретную сеть: входной слой из трёх нейронов, первый скрытый слой из четырёх нейронов с сигмоидной активацией, второй скрытый слой из двух нейронов с сигмоидой и выходной слой из одного нейрона. Обозначим веса как W₁ (3×4), W₂ (4×2) и W₃ (2×1). Итого 12 + 8 + 2 = 22 параметра, если считать смещения.

При прямом дифференцировании функции потерь MSE по одному весу из W₁ мы последовательно применяем цепное правило через все слои. Производная сигмоиды σ'(x) = σ(x)(1-σ(x)) вычисляется на каждом нейроне скрытых слоёв. Для веса w₁₁ в W₁ цепочка выглядит так: dL/dw₁₁ = dL/dŷ * dŷ/dh₂ * dh₂/dh₁ * dh₁/dw₁₁. Здесь h₁ - выход первого скрытого слоя, h₂ - выход второго, ŷ - выход сети. Для другого веса w₁₂ в том же W₁ цепочка dL/dŷ * dŷ/dh₂ * dh₂/dh₁ остаётся неизменной, меняется только последний множитель dh₁/dw₁₂. Мы заново вычисляем производные сигмоид для всех нейронов второго слоя и выходного слоя, хотя они уже были посчитаны для w₁₁.

Для 22 параметров такой сети при наивном подходе производная сигмоиды второго скрытого слоя будет вычислена 12 раз (по числу весов W₁), а производная выходного слоя - все 22 раза. При масштабировании до 1000 параметров число повторений переваливает за десятки тысяч. Это квадратичная зависимость, которая делает прямой подход неприменимым для сколько-нибудь глубоких архитектур.

Цепное правило как основа системного подхода

Цепное правило утверждает: если переменная z зависит от y, а y зависит от x, то производная z по x равна произведению производной z по y на производную y по x. В нотации Лейбница: dz/dx = dz/dy * dy/dx. Для функций многих переменных правило обобщается через частные производные и суммирование по всем путям влияния.

В контексте нейросети функция потерь L зависит от выхода сети ŷ, который зависит от активаций последнего слоя, те - от линейной комбинации предыдущего слоя, и так далее до входных данных. Граф вычислений представляет собой направленный ациклический граф, где узлы - это операции, а рёбра - потоки данных. Цепное правило позволяет разложить градиент по любому параметру в произведение локальных градиентов вдоль пути от выхода к этому параметру.

Ключевая идея: если вычислять производные в порядке от выхода к входу, каждая промежуточная производная вычисляется один раз и переиспользуется для всех параметров, которые лежат на пути к ней. Например, dL/dh₂ (градиент по выходу второго скрытого слоя) нужна для всех весов W₁ и W₂. Вычислив её один раз, мы используем её многократно, а не пересчитываем для каждого параметра заново. Этот принцип лежит в основе алгоритма обратного распространения и именно он обеспечивает линейную сложность O(N) вместо квадратичной.

От функции потерь к градиентам: пошаговый разбор

Вернёмся к нашей сети 3-4-2-1 с сигмоидными активациями и MSE-функцией потерь. Прямой проход даёт нам значения всех активаций, которые мы сохраняем для обратного прохода.

Обратный проход начинается с вычисления градиента функции потерь по выходу сети. Для MSE: L = ½(ŷ - y)², производная dL/dŷ = ŷ - y. Это скаляр, который становится «входящим градиентом» для выходного слоя.

Двигаемся на слой назад - к весам W₃. Выходной нейрон получает взвешенную сумму z₃ = W₃·h₂ + b₃ и пропускает её через сигмоиду: ŷ = σ(z₃). По цепному правилу: dL/dz₃ = dL/dŷ * σ'(z₃). Здесь σ'(z₃) - локальный градиент сигмоиды, который мы вычисляем через уже известное значение ŷ: σ'(z₃) = ŷ(1-ŷ). Затем градиенты по весам и смещению: dL/dW₃ = dL/dz₃ * h₂ᵀ, dL/db₃ = dL/dz₃. Градиент по выходу предыдущего слоя: dL/dh₂ = W₃ᵀ * dL/dz₃. Этот градиент dL/dh₂ мы передаём дальше, во второй скрытый слой.

Для второго скрытого слоя повторяем процедуру. Зная dL/dh₂, вычисляем dL/dz₂ = dL/dh₂ ⊙ σ'(z₂), где ⊙ - поэлементное умножение. Локальные градиенты сигмоид для каждого нейрона: σ'(z₂ᵢ) = h₂ᵢ(1-h₂ᵢ). Градиенты по весам и смещениям: dL/dW₂ = dL/dz₂ * h₁ᵀ, dL/db₂ = dL/dz₂. Градиент для передачи дальше: dL/dh₁ = W₂ᵀ * dL/dz₂.

Для первого скрытого слоя процедура идентична. Получив dL/dh₁, вычисляем dL/dz₁ = dL/dh₁ ⊙ σ'(z₁), затем dL/dW₁ = dL/dz₁ * xᵀ, dL/db₁ = dL/dz₁. На этом обратный проход завершён - градиенты по всем 22 параметрам вычислены за один проход.

Каждая промежуточная производная (dL/dz₃, dL/dh₂, dL/dz₂, dL/dh₁, dL/dz₁) была вычислена ровно один раз и использована для получения градиентов по всем параметрам соответствующего слоя. Ни одного повторного вычисления.

Как повторное использование вычислений делает обучение возможным

Сравним два подхода количественно. В наивном методе для каждого из N параметров мы проходим весь путь от выхода до этого параметра, вычисляя все промежуточные производные заново. Если сеть имеет L слоёв и каждый слой содержит порядка M операций, то вычисление градиента для одного параметра требует O(L·M) операций. Для N параметров получаем O(N·L·M). В полносвязной сети N пропорционально M²·L, итоговая сложность - O(M⁴·L²), что экспоненциально по глубине.

При обратном распространении мы проходим граф вычислений ровно дважды: прямой проход (O(N) операций) и обратный проход (O(N) операций). Каждая операция в графе участвует в вычислении своего локального градиента ровно один раз. Итоговая сложность - O(N), линейная по числу параметров.

Для сети с десятью слоями и сотней нейронов в каждом наивный подход требует порядка 10¹⁰ операций. Обратное распространение - порядка 10⁶. Разница в четыре порядка. Для современных моделей с сотнями миллионов параметров эта разница превращает обучение из теоретически невозможного в практически осуществимое за часы или дни на GPU-кластере.

Практическое ограничение обратного распространения - память. Для вычисления локальных градиентов на обратном проходе нужны активации, сохранённые во время прямого прохода. Для сети с миллиардом параметров и размером батча 32 объём хранимых активаций может достигать десятков гигабайт VRAM. Здесь вступают в игру техники компромисса между памятью и вычислениями: gradient checkpointing (сохраняем активации только для некоторых слоёв, остальные перевычисляем на обратном проходе) и mixed precision training (используем float16 для активаций и float32 для критических операций). Эти методы не меняют асимптотическую сложность, но позволяют уместить обучение в доступную память GPU.

Сравнение вычислительной сложности: наивный подход vs. backprop

Формализуем сравнение для полносвязной сети с L слоями и средним числом нейронов M в каждом скрытом слое:

Метод Вычислительная сложность Для L=5, M=100 Для L=10, M=200
Наивное прямое дифференцирование O(M^(2L)) ~10²⁰ операций ~10⁴⁶ операций
Обратное распространение O(L·M²) ~5·10⁴ операций ~4·10⁵ операций
Выигрыш ~10¹⁵ раз ~10⁴¹ раз

Цифры говорят сами за себя. Для сети глубиной 10 слоёв наивный подход требует больше операций, чем количество атомов в наблюдаемой Вселенной. Обратное распространение справляется за доли секунды на современном GPU.

На практике основное потребление памяти приходится на хранение активаций для обратного прохода. Для сети с L слоями и размером батча B объём хранимых активаций составляет O(B·L·M). При B=32, L=100, M=1000 это около 3.2 миллиона чисел, или примерно 12.8 МБ в float32. Для современных LLM с M=8192 и L=80 объём активаций достигает 2.6 ГБ на один обучающий пример, что при B=64 даёт 166 ГБ - больше, чем VRAM одного H100 (80 ГБ). Здесь на помощь приходит gradient checkpointing: сохраняя активации только для каждого k-го слоя (обычно k=√L), мы снижаем потребление памяти до O(B·√L·M) ценой двукратного увеличения вычислений на обратном проходе.

От цепного правила к эффективному алгоритму: что дальше?

Цепное правило даёт математическую основу, а правильный порядок вычислений - от выхода к входу - устраняет повторную работу. Вместе они образуют алгоритм обратного распространения, который масштабируется линейно по числу параметров и делает обучение глубоких сетей практически осуществимым.

Однако за рамками этой статьи остаются вопросы, которые возникают при реализации backprop для конкретных архитектур. Как вычислять градиенты для операций без явной аналитической производной, таких как max pooling или ReLU в точке излома? Субградиенты решают эту проблему: для ReLU производная в нуле доопределяется как 0 или 0.5, и на практике это работает. Как эффективно реализовать обратное распространение для свёрточных слоёв, где веса разделяются между пространственными позициями? Градиент по ядру свёртки вычисляется как свёртка входного тензора с градиентом по выходу - операция, которую cuDNN реализует на уровне CUDA-ядер.

Отдельная тема - проблема исчезающих и взрывающихся градиентов в глубоких сетях. При умножении большого числа локальных градиентов сигмоиды (максимальное значение 0.25) градиент экспоненциально затухает к первому слою. Batch Normalization и Residual connections решают эту проблему архитектурно, а gradient clipping ограничивает норму градиента сверху, предотвращая взрывы. Эти техники будут детально разобраны в финальной части руководства.

Понимание механики обратного распространения - это не просто академический интерес. Когда вы пишете кастомный слой в PyTorch и определяете его backward(), вы вручную задаёте, как входящий градиент преобразуется в градиенты по параметрам и входу. Ошибка в порядке умножения матриц или в знаке локального градиента приводит к неверным обновлениям весов, которые сложно отловить без понимания цепного правила. Мы рекомендуем начать с первой части руководства, где алгоритм разбирается на конкретных числах для линейной регрессии и простой нейросети - это поможет закрепить интуицию перед переходом к продакшен-техникам.

В финальной части мы рассмотрим реализации обратного распространения для свёрточных, рекуррентных и трансформерных архитектур, разберём техники gradient clipping, batch normalization и gradient checkpointing с примерами кода на PyTorch, а также покажем, как профилировать потребление памяти и вычислений с помощью torch.autograd.profiler.

Подписаться на канал