Глубокие нейронные сети решают задачи классификации, регрессии и прогнозирования временных рядов. Обучение таких моделей основано на методе обратного распространения ошибки (backpropagation), который вычисляет градиенты функции потерь по параметрам сети. Градиенты определяют направление и величину обновления весов.
Чем больше нейронная сеть, тем сложнее контролировать градиенты. В сетях с десятками слоев возникает проблема: градиенты либо экспоненциально уменьшаются (затухают), либо экспоненциально растут (взрываются) по мере распространения от выходного слоя к входному. Затухающие градиенты приводят к остановке обучения ранних слоев сети. Взрывающиеся градиенты вызывают нестабильность оптимизации и переполнение числовых значений.
Обе проблемы делают невозможным обучение глубоких архитектур без специальных техник стабилизации. Современные подходы включают архитектурные модификации, методы нормализации, продвинутые оптимизаторы и правильную инициализацию весов.
Проблема затухающих градиентов
Затухание градиентов проявляется в сетях с множеством последовательных слоев. При обратном распространении градиент умножается на производные функций активации и веса каждого слоя. Если эти производные меньше единицы, произведение быстро стремится к нулю.
Механизм затухания
Рассмотрим простую сеть с L слоями. Градиент потерь по весам первого слоя вычисляется через цепочку производных:
∂L/∂W₁ = ∂L/∂aₗ · ∂aₗ/∂aₗ₋₁ · … · ∂a₂/∂a₁ · ∂a₁/∂W₁
где:
- L — функция потерь;
- aᵢ — активации i-го слоя;
- Wᵢ — веса i-го слоя.
Каждый член ∂aᵢ/∂aᵢ₋₁ включает производную функции активации.
Для сигмоиды σ(x) = 1/(1 + e⁻ˣ) производная равна σ'(x) = σ(x)(1 — σ(x)). Максимальное значение этой производной составляет 0.25 при x = 0. При прохождении через 10 слоев градиент уменьшается в 0.25¹⁰ ≈ 10⁻⁶ раз.
Функция tanh дает аналогичный результат. Ее производная tanh'(x) = 1 — tanh²(x) максимальна при x = 0 и равна 1, но быстро падает при отклонении от нуля. В реальных сетях активации редко находятся в точке максимума производной, что усиливает затухание.
Последствия для обучения сети
Затухающие градиенты блокируют обучение ранних слоев. Веса этих слоев обновляются с шагом, близким к нулю, и остаются практически неизменными. Сеть не может извлекать низкоуровневые признаки из входных данных.
Проблема проявляется в нескольких формах:
- Медленная сходимость — функция потерь снижается крайне медленно, обучение занимает сотни тысяч итераций;
- Застревание в плохих локальных минимумах — сеть не может выйти из неоптимальной конфигурации весов;
- Деградация точности — добавление новых слоев ухудшает результат вместо улучшения.
Диагностика затухания включает мониторинг норм градиентов по слоям. Если градиенты первых слоев на 3-4 порядка меньше градиентов последних слоев, то это признак проблемы. Визуализация распределения градиентов показывает смещение в сторону нуля для ранних слоев.
Проблема взрывающихся градиентов
Взрывающиеся градиенты возникают при обратном распространении через глубокие сети или рекуррентные архитектуры. Градиент экспоненциально растет, достигая значений, которые вызывают переполнение типа данных или делают обучение нестабильным.
Механизм взрыва
Взрыв градиентов происходит, когда произведение производных превышает единицу. В рекуррентных сетях (RNN, LSTM) одна и та же матрица весов W применяется на каждом временном шаге.
Градиент по весам пропорционален произведению W^T на длину последовательности T. Для последовательности длиной 100 шагов и матрицы весов с максимальным собственным значением 1.1 градиент увеличивается в 1.1¹⁰⁰ ≈ 10⁴ раз. Это приводит к числовой нестабильности.
В обычных feed-forward сетях взрыв возникает при неправильной инициализации весов. Если веса инициализированы большими значениями, произведение градиентов быстро растет. Функции активации ReLU усиливают эффект: их производная равна 1 для положительных входов, не ограничивая рост градиента.
Признаки проблемы
Взрывающиеся градиенты легко обнаружить по поведению метрик обучения:
- Значения функции потерь становятся NaN или Inf через несколько итераций;
- Веса модели принимают экстремальные значения (10⁶ и выше);
- Функция потерь резко возрастает вместо снижения;
- Норма градиентов превышает порог 10-100 (зависит от архитектуры).
Логирование норм градиентов на каждой итерации позволяет отследить момент начала проблемы. Резкий скачок нормы градиента на 2-3 порядка указывает на взрыв. В PyTorch проверка на NaN выполняется через torch.isnan() для тензоров весов и градиентов.
Взрывающиеся градиенты чаще встречаются в задачах обработки последовательностей с длинными зависимостями. Временные ряды финансовых данных, текстовые корпуса и аудио требуют особого внимания к стабильности градиентов.
Архитектурные решения
Модификации архитектуры нейронной сети устраняют проблемы градиентов на фундаментальном уровне. Эти подходы изменяют поток информации и градиентов через сеть, обеспечивая стабильное обучение глубоких моделей.
Residual connections
Residual connections (skip connections) добавляют прямой путь для градиентов от выходных слоев к входным. Вместо обучения функции H(x) блок обучает остаток:
F(x) = H(x) — x
Выход блока вычисляется как F(x) + x.
Преимущество residual connections в том, что градиент может проходить напрямую через сложение, минуя нелинейные преобразования. Градиент по входу блока:
∂L/∂x = ∂L/∂(F(x) + x) = ∂L/∂F · ∂F/∂x + ∂L/∂x
Второе слагаемое ∂L/∂x обеспечивает прямой путь для градиента без умножения на производные активаций. Это предотвращает затухание даже в сетях со 100+ слоями.
Архитектура ResNet применяет residual connections каждые 2-3 слоя. Базовый residual блок содержит два сверточных слоя с batch normalization и ReLU активацией. Identity mapping (прямое копирование входа) работает при совпадении размерностей. При изменении размерности используется projection shortcut — линейное преобразование через свертку 1×1.
import torch.nn as nn
class ResidualBlock(nn.Module):
def __init__(self, in_channels, out_channels, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_channels, out_channels,
kernel_size=3, stride=stride, padding=1)
self.bn1 = nn.BatchNorm2d(out_channels)
self.relu = nn.ReLU(inplace=True)
self.conv2 = nn.Conv2d(out_channels, out_channels,
kernel_size=3, padding=1)
self.bn2 = nn.BatchNorm2d(out_channels)
self.shortcut = nn.Sequential()
if stride != 1 or in_channels != out_channels:
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, out_channels,
kernel_size=1, stride=stride),
nn.BatchNorm2d(out_channels)
)
def forward(self, x):
residual = x
out = self.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
out += self.shortcut(residual)
out = self.relu(out)
return out
Код реализует residual блок с двумя свертками и skip connection. Shortcut адаптируется к изменению размерности через projection. Активация ReLU применяется после сложения, сохраняя нелинейность.
Batch Normalization
Batch Normalization нормализует активации каждого слоя по батчу примеров. Это стабилизирует распределение входов последующих слоев и ограничивает рост градиентов.
Для батча активаций x нормализация вычисляется как:
x̂ = (x — μ_B) / √(σ²_B + ε)
y = γx̂ + β
Здесь:
- μ_B — среднее по батчу;
- σ²_B — дисперсия по батчу;
- ε — малая константа для численной стабильности (10⁻⁵);
- γ, β — обучаемые параметры масштаба и сдвига.
Нормализация приводит активации к нулевому среднему и единичной дисперсии. Параметры γ и β позволяют сети восстановить оптимальное распределение активаций. Без них нормализация могла бы ограничить выразительность модели.
Batch Normalization решает три задачи:
- Уменьшает internal covariate shift — изменение распределения активаций в процессе обучения;
- Позволяет использовать более высокие learning rate без риска расходимости;
- Снижает зависимость от инициализации весов.
В сверточных сетях нормализация применяется по каналам. Для батча размером (N, C, H, W) статистики вычисляются по размерностям N, H, W отдельно для каждого канала C. В полносвязных слоях нормализация идет по признакам.
Batch Normalization добавляет небольшие вычислительные затраты, но существенно ускоряет сходимость. Модели обучаются в 2-3 раза быстрее при тех же гиперпараметрах.
Layer Normalization
Layer Normalization нормализует активации по признакам одного примера, а не по батчу. Метод крайне популярен в архитектуре рекуррентных сетей и трансформеров, где размер батча может быть малым или изменяться.
Для вектора активаций x нормализация:
x̂ = (x — μ) / √(σ² + ε)
y = γx̂ + β
Здесь μ и σ² вычисляются по всем элементам вектора x одного примера. Layer Normalization не зависит от размера батча и работает одинаково на обучении и inference.
В трансформерах Layer Normalization применяется после каждого sub-layer (self-attention и feed-forward). Это стабилизирует обучение моделей с десятками слоев. Архитектура BERT использует Layer Normalization перед каждым блоком (pre-LN) вместо после (post-LN), что улучшает стабильность.
Выбор между Batch и Layer Normalization зависит от архитектуры:
- Batch Normalization — для CNN на изображениях с большими батчами;
- Layer Normalization — для RNN, LSTM, трансформеров и малых батчей;
- Group Normalization — компромисс для средних батчей в свертках.
Layer Normalization показывает лучшие результаты в задачах обработки последовательностей. Batch Normalization превосходит в компьютерном зрении при батчах от 32 примеров.
Методы оптимизации
Оптимизаторы и техники регуляризации градиентов контролируют процесс обновления весов. Эти методы применяются поверх архитектурных решений и дополняют их.
Gradient clipping
Gradient clipping ограничивает норму градиента порогом, предотвращая взрыв. Если норма градиента превышает threshold, градиент масштабируется пропорционально.
Для градиента g и порога θ обрезанный градиент:
ĝ = g · θ / ||g||, если ||g|| > θ
ĝ = g, если ||g|| ≤ θ
Здесь ||g|| — L2-норма градиента по всем параметрам модели.
Clipping сохраняет направление градиента, изменяя только величину шага. В библиотеке PyTorch реализованы две функции clipping:
import torch.nn as nn
# Clipping по норме
nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# Clipping по значению каждого элемента
nn.utils.clip_grad_value_(model.parameters(), clip_value=0.5)
Первый метод ограничивает общую норму градиента. Второй обрезает каждый элемент градиента независимо.
Clipping по норме предпочтителен, так как сохраняет соотношение между градиентами разных параметров. Выбор порога зависит от задачи:
- Для рекуррентных сетей типичные значения: 1.0-5.0;
- Для feed-forward сетей: 5.0-10.0.
Слишком низкий порог замедляет обучение, слишком высокий не предотвращает взрыв.
Gradient clipping применяется перед шагом оптимизатора:
optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
Порядок операций имеет решающее значение: clipping выполняется после вызова backward() и перед step(). Это обеспечивает передачу в оптимизатор уже обрезанных градиентов.
Адаптивные оптимизаторы
Адаптивные оптимизаторы автоматически настраивают learning rate для каждого параметра на основе истории градиентов. Это смягчает проблему взрывающихся градиентов и ускоряет сходимость.
Adam (Adaptive Moment Estimation) поддерживает экспоненциально взвешенные средние первого и второго моментов градиента. Вот как вычисляется обновление параметров:
m_t = β₁m_{t-1} + (1 — β₁)g_t
v_t = β₂v_{t-1} + (1 — β₂)g_t²
θ_t = θ_{t-1} — α · m̂_t / (√v̂_t + ε)
Здесь:
- m_t — первый момент (среднее градиента);
- v_t — второй момент (дисперсия градиента);
- β₁, β₂ — коэффициенты затухания (типично 0.9 и 0.999);
- α — learning rate;
- m̂_t, v̂_t — bias-corrected моменты;
- ε — константа стабильности (10⁻⁸).
Деление на √v̂_t нормализует шаг обновления. Параметры с большими градиентами получают меньший эффективный learning rate, параметры с малыми градиентами — больший. Это предотвращает как взрыв, так и затухание.
AdamW добавляет decoupled weight decay — регуляризацию, отделенную от градиентов. Это улучшает обобщение модели:
optimizer = torch.optim.AdamW(
model.parameters(),
lr=1e-3,
betas=(0.9, 0.999),
weight_decay=0.01
)
Weight decay применяется напрямую к весам, не влияя на адаптацию learning rate. Это дает лучший контроль над регуляризацией.
Сравнение оптимизаторов:
- SGD с momentum — базовый выбор, требует тщательной настройки learning rate;
- Adam — универсальный оптимизатор, работает из коробки с learning rate 1e-3;
- AdamW — улучшенная версия Adam с правильной регуляризацией;
- RMSprop — альтернатива Adam для рекуррентных сетей;
- AdaGrad — для задач с разреженными градиентами.
AdamW показывает лучшие результаты в большинстве задач глубокого обучения. Для файнтюнинга предобученных моделей предпочтителен SGD с малым learning rate (1e-4 — 1e-5) и momentum 0.9.
Инициализация весов
Правильная инициализация весов предотвращает затухание и взрыв градиентов с первых итераций обучения. Случайная инициализация должна поддерживать дисперсию активаций и градиентов постоянной при прохождении через слои.
Xavier/Glorot инициализация
Инициализация Xavier разработана для функций активации с симметричной производной (tanh, sigmoid). Веса инициализируются из равномерного или нормального распределения с дисперсией, зависящей от числа входов и выходов слоя.
Для равномерного распределения:
W ~ U(-√(6/(n_in + n_out)), √(6/(n_in + n_out)))
Для нормального распределения:
W ~ N(0, √(2/(n_in + n_out)))
Здесь:
- n_in — количество входных нейронов слоя;
- n_out — количество выходных нейронов слоя.
Дисперсия активаций остается примерно постоянной при прямом проходе. Дисперсия градиентов сохраняется при обратном проходе. Это обеспечивает стабильное обучение сетей с 10-20 слоями.
В PyTorch инициализация Xavier доступна через функции:
import torch.nn as nn
# Xavier равномерная
nn.init.xavier_uniform_(layer.weight)
# Xavier нормальная
nn.init.xavier_normal_(layer.weight)
Инициализация Xavier оптимальна для tanh активации. Для sigmoid результаты хуже из-за асимметрии функции и насыщения в крайних точках.
He инициализация
He инициализация (Kaiming initialization) разработана специально для ReLU и ее вариантов. ReLU обнуляет половину активаций, что меняет дисперсию сигнала. He инициализация компенсирует это удвоением дисперсии весов.
Для нормального распределения:
W ~ N(0, √(2/n_in))
Для равномерного распределения:
W ~ U(-√(6/n_in), √(6/n_in))
Коэффициент 2 вместо 1 учитывает, что ReLU пропускает только положительные значения. Дисперсия активаций сохраняется при прямом проходе через ReLU слои.
# He нормальная (для ReLU)
nn.init.kaiming_normal_(layer.weight, mode='fan_in', nonlinearity='relu')
# He равномерная
nn.init.kaiming_uniform_(layer.weight, mode='fan_in', nonlinearity='relu')
Параметр mode определяет, какое количество нейронов использовать для расчета дисперсии:
- fan_in — по входным нейронам (рекомендуется для обучения);
- fan_out — по выходным нейронам;
- fan_avg — среднее между входными и выходными.
Параметр nonlinearity указывает функцию активации. Для leaky ReLU с параметром α:
W ~ N(0, √(2/(1 + α²) · n_in))
He инициализация стала стандартом для современных сверточных сетей с ReLU. Она используется в ResNet, VGG, EfficientNet и других архитектурах.
Выбор инициализации по функции активации:
- ReLU, Leaky ReLU, PReLU — He инициализация;
- tanh — Xavier инициализация;
- sigmoid — Xavier с осторожностью (лучше избегать sigmoid в скрытых слоях);
- SELU — LeCun инициализация (похожа на Xavier, но с n_in вместо среднего).
Правильная инициализация имеет ключевое значение для сетей без batch normalization. В архитектурах с batch normalization ее влияние уменьшается, однако на ранних этапах обучения корректная инициализация все равно остается важной.
Практическая реализация
Комбинация описанных техник обеспечивает стабильное обучение глубоких моделей. Рассмотрим реализацию residual сети с мониторингом градиентов.
import torch
import torch.nn as nn
import torch.optim as optim
class ResNet(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3)
self.bn1 = nn.BatchNorm2d(64)
self.relu = nn.ReLU(inplace=True)
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
# Residual blocks
self.layer1 = self._make_layer(64, 64, num_blocks=2)
self.layer2 = self._make_layer(64, 128, num_blocks=2, stride=2)
self.layer3 = self._make_layer(128, 256, num_blocks=2, stride=2)
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(256, num_classes)
# He initialization
self._init_weights()
def _make_layer(self, in_channels, out_channels, num_blocks, stride=1):
layers = []
layers.append(ResidualBlock(in_channels, out_channels, stride))
for _ in range(1, num_blocks):
layers.append(ResidualBlock(out_channels, out_channels))
return nn.Sequential(*layers)
def _init_weights(self):
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode='fan_out',
nonlinearity='relu')
elif isinstance(m, nn.BatchNorm2d):
nn.init.constant_(m.weight, 1)
nn.init.constant_(m.bias, 0)
def forward(self, x):
x = self.maxpool(self.relu(self.bn1(self.conv1(x))))
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.avgpool(x)
x = torch.flatten(x, 1)
x = self.fc(x)
return x
# Функция для мониторинга градиентов
def monitor_gradients(model):
total_norm = 0.0
layer_norms = {}
for name, param in model.named_parameters():
if param.grad is not None:
param_norm = param.grad.data.norm(2)
total_norm += param_norm.item() ** 2
layer_norms[name] = param_norm.item()
total_norm = total_norm ** 0.5
return total_norm, layer_norms
# Training loop с gradient clipping
model = ResNet(num_classes=10)
optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)
criterion = nn.CrossEntropyLoss()
for epoch in range(num_epochs):
for inputs, targets in train_loader:
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, targets)
loss.backward()
# Gradient clipping
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# Мониторинг (выполнять периодически, не на каждой итерации)
if iteration % 100 == 0:
total_norm, layer_norms = monitor_gradients(model)
print(f"Total gradient norm: {total_norm:.4f}")
# Проверка на взрыв
if total_norm > 10.0:
print("Warning: gradient explosion detected")
# Проверка на затухание
first_layer_norm = layer_norms.get('conv1.weight', 0)
last_layer_norm = layer_norms.get('fc.weight', 0)
if first_layer_norm > 0 and last_layer_norm / first_layer_norm > 100:
print("Warning: potential gradient vanishing")
optimizer.step()
Код объединяет residual архитектуру, batch normalization, He инициализацию, gradient clipping и адаптивный оптимизатор. Функция monitor_gradients вычисляет L2-норму градиентов по всей модели и отдельно по слоям. Периодический мониторинг позволяет обнаружить проблемы до их критического проявления.
Пороги для детекции проблем зависят от архитектуры. Для ResNet с 20-50 слоями норма градиентов 1-5 считается нормальной. Значения выше 10 указывают на начало взрыва. Разница между нормами первого и последнего слоев более чем в 100 раз сигнализирует о затухании.
Дополнительные техники стабилизации включают learning rate scheduling и warmup. Linear warmup постепенно увеличивает learning rate от малого значения до целевого за первые 1000-5000 итераций. Это предотвращает резкие обновления весов на начальном этапе:
from torch.optim.lr_scheduler import LinearLR, SequentialLR
warmup_scheduler = LinearLR(optimizer, start_factor=0.1, total_iters=1000)
main_scheduler = CosineAnnealingLR(optimizer, T_max=num_epochs)
scheduler = SequentialLR(optimizer,
schedulers=[warmup_scheduler, main_scheduler],
milestones=[1000])
Warmup необходим при использовании больших значений learning rate (>1e-3) и больших батчей (>256). Без warmup оптимизация может расходиться в первые эпохи.
Выводы и рекомендации по стабилизации градиентов
Качество и стабильность обучения deep learning моделей напрямую зависят от того, насколько предсказуемо ведут себя градиенты по мере роста глубины сети. Поэтому внимание к их динамике и способность корректировать возникающие отклонения — обязательная часть работы с современными архитектурами.
Корректный выбор инициализации, архитектуры, нормализации и оптимизатора играет решающую роль, однако даже при правильной теоретической основе обучение может становиться нестабильным. В таких случаях помогает системный подход к отладке, основанный на постепенной проверке ключевых компонентов модели и наблюдении за поведением градиентов.
Стратегия отладки проблем с градиентами:
- Начать с простой архитектуры (2-3 слоя) и убедиться в стабильном обучении;
- Постепенно увеличивать глубину, добавляя residual connections каждые 2-3 слоя;
- Применять batch normalization после каждого сверточного слоя;
- Использовать He инициализацию для ReLU, Xavier для tanh;
- Установить gradient clipping с порогом 1-5;
- Выбрать AdamW с learning rate 1e-3 и weight decay 0.01;
- Добавить warmup на 1000 итераций при больших learning rates;
- Мониторить нормы градиентов каждые 100 итераций.
Эта последовательность минимизирует риск проблем с градиентами и позволяет быстро локализовать источник нестабильности.
Современные фреймворки включают большинство решений по умолчанию. PyTorch автоматически применяет правильную инициализацию при создании слоев. Batch normalization встроена в стандартные архитектуры. Gradient clipping и продвинутые оптимизаторы доступны через простой API. Понимание механизмов проблем градиентов позволяет эффективно использовать эти инструменты и диагностировать нетипичные случаи.