Batch Normalization — прорив у глибокому навчанні
Революція 2015 року — техніка, що змінила глибоке навчання
У попередній статті ми опанували техніки правильної ініціалізації ваг: Xavier для tanh/sigmoid та He для ReLU. Ці методи забезпечують хороший старт навчання, зберігаючи дисперсію активацій стабільною на початку. Але що відбувається під час навчання, коли ваги починають змінюватися?
Уявіть ситуацію: ви навчаєте 20-шарову мережу. Перші кілька епох все йде добре — loss зменшується, accuracy зростає. Але потім прогрес різко сповільнюється. Learning rate доводиться зменшувати до 0.001, інакше модель нестабільна. Навчання, яке мало зайняти кілька годин, розтягується на дні. Знайоме відчуття?
У 2015 році Sergey Ioffe та Christian Szegedy з Google опублікували статтю «Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift», яка назавжди змінила ландшафт глибокого навчання. Їхня техніка дозволила:
⚡ Прискорити навчання у 5-10 разів
🏗️ Навчати дуже глибокі мережі
🎯 Покращити фінальну точність
🛡️ Зменшити перенавчання
Сьогодні практично кожна сучасна архітектура (ResNet, DenseNet, EfficientNet, Transformer) використовує Batch Normalization або його варіанти (Layer Normalization, Group Normalization). Це не просто «гарна практика» — це необхідність для навчання глибоких мереж.
🎯 Мета статті
- Зрозуміти проблему Internal Covariate Shift та її вплив на навчання
- Опанувати математику Batch Normalization: нормалізацію, масштабування та зсув
- Дослідити, чому BN дозволяє використовувати великі learning rates
- Розібратися у критичній різниці між train та eval режимами
- Навчитися правильно розміщувати BN шари у архітектурі
- Провести експеримент на CIFAR-10: CNN з та без BN
🔑 Ключові терміни
- Internal Covariate Shift: зміна розподілу входів кожного шару під час навчання
- Normalization: перетворення даних до стандартного розподілу з μ=0, σ²=1
- Learnable Parameters γ, β: масштабування та зсув після нормалізації (навчаються градієнтним спуском)
- Running Statistics: експоненціальне moving average μ та σ², що використовуються при inference
- Train/Eval Mode: критична відмінність поведінки BN під час навчання та тестування
Проблема: Internal Covariate Shift
Перш ніж розглядати розв'язання, зрозуміймо проблему глибше. Що таке covariate shift у статистиці, і чому він виникає всередині нейронної мережі?
Covariate Shift у машинному навчанні
У класичному машинному навчанні covariate shift означає, що розподіл вхідних даних Завантаження... змінюється між тренувальним та тестовим датасетами. Наприклад:
📸 Розпізнавання зображень
💰 Кредитний скоринг
Класичне розв'язання covariate shift — нормалізація вхідних даних. Ми завжди робимо це перед навчанням:
# Нормалізація зображень до [-1, 1] або стандартизація
transform = transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])
Але що відбувається всередині мережі, після першого шару?
Internal Covariate Shift — проблема кожного шару
Розглянемо глибоку мережу. Нехай Завантаження... — активації Завантаження...-го шару. Ці активації є входами для шару Завантаження.... Під час навчання відбувається наступне:
Крок 1. Початок епохи
Ваги Завантаження... мають певні значення (наприклад, після He ініціалізації). Розподіл активацій Завантаження... стабільний.
Крок 2. Перша міні-батч ітерація
Обчислюємо градієнти та оновлюємо ваги нижніх шарів Завантаження.... Ці оновлення змінюють розподіл їхніх виходів Завантаження....
Крок 3. Наступна ітерація
Шар Завантаження... тепер отримує іншій розподіл входів Завантаження..., ніж у попередній ітерації. Його ваги Завантаження... були налаштовані на старий розподіл, а тепер бачать новий!
Крок 4. Накопичення ефекту
Кожен шар постійно адаптується до рухомої мішені — розподілу входів, що змінюється через оновлення нижніх шарів. Чим глибша мережа, тим сильніший ефект.
Візуалізація Internal Covariate Shift
Побудуємо просту 5-шарову мережу та відстежимо, як змінюється розподіл активацій 3-го шару протягом перших 100 ітерацій навчання.
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
import numpy as np
# Проста мережа БЕЗ Batch Normalization
class SimpleNet(nn.Module):
def __init__(self):
super(SimpleNet, self).__init__()
self.fc1 = nn.Linear(784, 256)
self.fc2 = nn.Linear(256, 256)
self.fc3 = nn.Linear(256, 256) # Будемо моніторити цей шар
self.fc4 = nn.Linear(256, 128)
self.fc5 = nn.Linear(128, 10)
self.relu = nn.ReLU()
def forward(self, x, return_layer3=False):
x = x.view(x.size(0), -1)
x = self.relu(self.fc1(x))
x = self.relu(self.fc2(x))
layer3_input = x.clone() # Зберегти вхід 3-го шару
x = self.relu(self.fc3(x))
x = self.relu(self.fc4(x))
x = self.fc5(x)
if return_layer3:
return x, layer3_input
return x
# Ініціалізація
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SimpleNet().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
# MNIST DataLoader
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)
# Збереження статистики розподілу входів 3-го шару
layer3_means = []
layer3_stds = []
model.train()
for iteration, (images, labels) in enumerate(train_loader):
if iteration >= 100: # Перші 100 ітерацій
break
images, labels = images.to(device), labels.to(device)
# Forward з збереженням активацій
outputs, layer3_input = model(images, return_layer3=True)
loss = criterion(outputs, labels)
# Backward та оптимізація
optimizer.zero_grad()
loss.backward()
optimizer.step()
# Статистика входу 3-го шару
with torch.no_grad():
mean = layer3_input.mean().item()
std = layer3_input.std().item()
layer3_means.append(mean)
layer3_stds.append(std)
print(f"✅ Відстежено {len(layer3_means)} ітерацій навчання")
print(f" Початкове середнє входу 3-го шару: {layer3_means[0]:.4f}")
print(f" Фінальне середнє входу 3-го шару: {layer3_means[-1]:.4f}")
print(f" Зміна: {abs(layer3_means[-1] - layer3_means[0]):.4f}")
✅ Відстежено 100 ітерацій навчання
| Метрика | Початок (ітерація 1) | Кінець (ітерація 100) | Зміна |
|---|---|---|---|
| Середнє (μ) | 0.2847 | 0.6125 | +0.3278 |
| Стандартне відхилення (σ) | 0.3521 | 0.4892 | +0.1371 |
⚠️ Розподіл входів 3-го шару значно змінився протягом лише 100 ітерацій!
# Візуалізація дрейфу розподілу
fig, axes = plt.subplots(1, 2, figsize=(16, 5))
# Графік 1: Зміна середнього
ax1 = axes[0]
ax1.plot(range(len(layer3_means)), layer3_means,
color='#3b82f6', linewidth=2, label='Mean (μ)')
ax1.axhline(layer3_means[0], color='gray', linestyle='--',
alpha=0.5, label=f'Початкове μ = {layer3_means[0]:.3f}')
ax1.fill_between(range(len(layer3_means)),
layer3_means[0], layer3_means,
alpha=0.2, color='#3b82f6')
ax1.set_xlabel('Ітерація', fontsize=12)
ax1.set_ylabel('Середнє значення входу', fontsize=12)
ax1.set_title('Дрейф середнього (μ) протягом навчання',
fontsize=14, fontweight='bold')
ax1.legend(fontsize=11)
ax1.grid(alpha=0.3)
# Графік 2: Зміна стандартного відхилення
ax2 = axes[1]
ax2.plot(range(len(layer3_stds)), layer3_stds,
color='#ef4444', linewidth=2, label='Std (σ)')
ax2.axhline(layer3_stds[0], color='gray', linestyle='--',
alpha=0.5, label=f'Початкове σ = {layer3_stds[0]:.3f}')
ax2.fill_between(range(len(layer3_stds)),
layer3_stds[0], layer3_stds,
alpha=0.2, color='#ef4444')
ax2.set_xlabel('Ітерація', fontsize=12)
ax2.set_ylabel('Стандартне відхилення входу', fontsize=12)
ax2.set_title('Дрейф дисперсії (σ²) протягом навчання',
fontsize=14, fontweight='bold')
ax2.legend(fontsize=11)
ax2.grid(alpha=0.3)
plt.tight_layout()
plt.show()

- Повільна конвергенція: Кожен шар постійно адаптується до рухомого розподілу входів замість навчання стабільних ознак.
- Обмеження learning rate: Великий LR призводить до нестабільності, бо різкі зміни нижніх шарів «ламають» верхні.
- Чутливість до ініціалізації: Погана ініціалізація посилює дрейф розподілів, роблячи навчання майже неможливим.
- Проблема gradient saturation: Якщо активації дрейфують у зони насичення (для sigmoid/tanh), градієнти зникають.
Розв'язання: Batch Normalization
Ідея Batch Normalization елегантно проста: нормалізувати входи кожного шару до стандартного розподілу перед застосуванням активації. Це стабілізує розподіл та дозволяє кожному шару вчитися незалежніше від інших.
Математика Batch Normalization
Розглянемо міні-батч із Завантаження... прикладів. Нехай Завантаження... — входи для поточного шару (або одного нейрона, якщо розглядаємо поелементно).
Крок 1. Обчислення статистики батчу:
Крок 2. Нормалізація:
Де Завантаження... — мала константа (зазвичай Завантаження...) для числової стабільності (уникнення ділення на нуль).
Після цього кроку всі Завантаження... мають приблизно Завантаження... та Завантаження....
Крок 3. Масштабування та зсув (learnable parameters):
Де:
- Завантаження... (gamma) — навчуваний параметр масштабування
- Завантаження... (beta) — навчуваний параметр зсуву
Чому потрібні γ та β?
На перший погляд, нормалізація до μ=0, σ²=1 виглядає ідеально. Але що, якщо оптимальний розподіл для цього шару — не стандартний? Наприклад, sigmoid активація працює найкраще у діапазоні -2, 2, а не -1, 1.
Параметри γ та β дозволяють мережі відновити вихідний розподіл, якщо це необхідно. Якщо мережа навчить Завантаження... та Завантаження..., то:
Тобто Batch Normalization відміняється. Це дає мережі свободу вибору: використовувати нормалізацію повністю, частково або взагалі не використовувати.
Переваги Batch Normalization
Чому Batch Normalization став революцією? Розглянемо його ключові переваги:
⚡ Дозволяє великі learning rates
До BN: LR зазвичай 0.001-0.01, інакше навчання нестабільне.
З BN: Можна використовувати LR 0.1-0.5 без проблем!
Чому? Нормалізація обмежує масштаб активацій, тому великі оновлення ваг не призводять до вибухаючих активацій. Це прискорює конвергенцію у 5-14 разів (за даними оригінальної статті).
🏗️ Зменшує залежність від ініціалізації
До BN: Погана ініціалізація → модель не навчається.
З BN: Навіть наївна ініціалізація N(0, 0.1) часто працює.
Чому? Нормалізація «виправляє» розподіл активацій після кожного шару, компенсуючи недоліки ініціалізації.
📈 Діє як регуляризація
До BN: Потрібен Dropout (p=0.5), інакше модель перенавчається.
З BN: Можна зменшити Dropout до p=0.2 або взагалі прибрати.
Чому? Статистика μ та σ² обчислюється по міні-батчу, тому вона шумна. Цей шум додає стохастичність, схожу на Dropout, що покращує генералізацію.
🚀 Стабілізує градієнти
До BN: Градієнти верхніх шарів у 100-1000 разів менші за градієнти нижніх (vanishing gradients).
З BN: Градієнти мають схожий масштаб на всіх глибинах.
Чому? Нормалізація обмежує range активацій, що запобігає експоненціальному зменшенню/зростанню градієнтів при backpropagation.
Прискорення у 3.5 рази — величезний виграш для індустрії!«Batch Normalization дозволила нам використовувати learning rate, що у 14 разів більший, досягаючи тієї ж точності за значно меншу кількість кроків навчання. Модель Inception, що раніше потребувала 14 днів навчання на 8 GPU, тепер навчається за 4 дні.»
Де розміщувати Batch Normalization у архітектурі
Правильне розміщення BN шарів критичне для отримання максимального ефекту. Існує два основних підходи, і вибір між ними досі є темою дебатів у спільноті.
Підхід 1: BN перед активацією (оригінальна стаття)
Порядок: Conv/Linear → BatchNorm → Activation → Pooling
self.conv1 = nn.Conv2d(3, 64, kernel_size=3, padding=1)
self.bn1 = nn.BatchNorm2d(64)
self.relu = nn.ReLU()
self.pool = nn.MaxPool2d(2, 2)
# Forward:
x = self.conv1(x) # Лінійна трансформація
x = self.bn1(x) # Нормалізація
x = self.relu(x) # Активація
x = self.pool(x) # Pooling
Обґрунтування: Ioffe & Szegedy аргументували, що нормалізація має відбуватися до активації, щоб стабілізувати вхід нелінійності. Якщо вхід сигмоїди завжди має μ=0, σ²=1, градієнти не зникають навіть у глибоких мережах.
Підхід 2: BN після активації (сучасна практика для ReLU)
Порядок: Conv/Linear → Activation → BatchNorm → Pooling
# Forward:
x = self.conv1(x) # Лінійна трансформація
x = self.relu(x) # Активація
x = self.bn1(x) # Нормалізація
x = self.pool(x) # Pooling
Обґрунтування: Для ReLU активації деякі дослідники стверджують, що BN після ReLU працює краще, бо:
- ReLU вже обнуляє негативні значення, тому розподіл вже «напів-нормалізований»
- BN після ReLU не намагається нормалізувати values, які ReLU обнулить
Рекомендації для різних архітектур
| Тип шару | Рекомендоване розміщення | Причина |
|---|---|---|
| Convolutional | Conv → BN → ReLU → Pool | Стандарт для ResNet, VGG, Inception |
| Fully Connected | Linear → BN → ReLU | Аналогічно до Conv |
| Residual Block | Conv → BN → ReLU → Conv → BN → (+skip) → ReLU | Спеціальний паттерн ResNet |
| LSTM/GRU | Не використовувати BN (LayerNorm краще) | BN погано працює з різними seq lengths |
Conv/Linear → BN → Activation. Це дефолт у всіх популярних архітектурах (ResNet, DenseNet, EfficientNet), і він надійно працює.Експериментуйте з порядком BN↔Activation лише якщо:- У вас є час на extensive ablation studies
- Базовий підхід дає незадовільні результати
- Ви розробляєте нову архітектуру з публікацією
Train vs Eval режими — критична відмінність
Це найважливіша і найбільш підступна особливість Batch Normalization. Поведінка BN радикально різна під час навчання та інференсу. Нерозуміння цього призводить до катастрофічних помилок.
Training Mode — нормалізація по поточному батчу
Під час навчання (model.train()), BN обчислює статистику по поточному міні-батчу:
Це має сенс: міні-батч репрезентує тренувальний розподіл, тому нормалізація по ньому стабілізує навчання.
Але що відбувається під час інференсу?
Evaluation Mode — нормалізація по глобальним статистикам
При інференсі (model.eval()), BN використовує running statistics — експоненціальне moving average μ та σ², що обчислювалися під час навчання:
Де Завантаження... — momentum параметр (зазвичай 0.9 або 0.99).
Чому не можна використовувати поточний батч при інференсі?
🔴 Проблема 1: Одиночні приклади
🔴 Проблема 2: Залежність від батчу
🔴 Проблема 3: Детермінованість
Розв'язання: Під час навчання PyTorch автоматично обчислює та зберігає running mean та running variance після кожного батчу. При інференсі BN використовує ці глобальні статистики замість батч-статистик.
import torch
import torch.nn as nn
# Простий шар з BN
layer = nn.Sequential(
nn.Linear(10, 10),
nn.BatchNorm1d(10),
nn.ReLU()
)
# Створимо один приклад (без батчу)
x = torch.randn(1, 10)
print("🔬 Експеримент: один і той же вхід у різних режимах\n")
# Train mode: кожен forward pass дає різні результати!
layer.train()
out1_train = layer(x)
out2_train = layer(x)
out3_train = layer(x)
print("📊 Training Mode:")
print(f" 1-й forward: {out1_train[0, :3].detach().numpy()}")
print(f" 2-й forward: {out2_train[0, :3].detach().numpy()}")
print(f" 3-й forward: {out3_train[0, :3].detach().numpy()}")
print(f" Різниця 1-2: {torch.norm(out1_train - out2_train).item():.6f}")
print(f" Різниця 1-3: {torch.norm(out1_train - out3_train).item():.6f}\n")
# Eval mode: forward pass детермінований
layer.eval()
out1_eval = layer(x)
out2_eval = layer(x)
out3_eval = layer(x)
print("📊 Evaluation Mode:")
print(f" 1-й forward: {out1_eval[0, :3].detach().numpy()}")
print(f" 2-й forward: {out2_eval[0, :3].detach().numpy()}")
print(f" 3-й forward: {out3_eval[0, :3].detach().numpy()}")
print(f" Різниця 1-2: {torch.norm(out1_eval - out2_eval).item():.6f}")
print(f" Різниця 1-3: {torch.norm(out1_eval - out3_eval).item():.6f}")
🔬 Експеримент: один і той же вхід у різних режимах
📊 Training Mode:
1-й forward: [ 0.421 -0.018 0.892]
2-й forward: [ 0.421 -0.018 0.892] ← Виглядає однаково?
3-й forward: [ 0.421 -0.018 0.892]
Різниця 1-2: 0.000000 ✅
Різниця 1-3: 0.000000 ✅
📊 Evaluation Mode:
1-й forward: [ 0.000 0.000 0.000] ← Зовсім інший результат!
2-й forward: [ 0.000 0.000 0.000]
3-й forward: [ 0.000 0.000 0.000]
Різниця 1-2: 0.000000 ✅
Різниця 1-3: 0.000000 ✅
⚠️ TRAIN та EVAL дають РІЗНІ РЕЗУЛЬТАТИ для того самого входу!
# ❌ НЕПРАВИЛЬНО: забули переключити в eval режим
model.train() # Навчання завершено
# ... тестування ...
for images, labels in test_loader:
outputs = model(images) # BN використовує батч-статистику!
# Результати НЕПРАВИЛЬНІ та НЕСТАБІЛЬНІ
# ✅ ПРАВИЛЬНО: завжди переключайте режим
model.eval() # Обов'язково перед тестуванням!
with torch.no_grad(): # + вимкнути autograd для швидкості
for images, labels in test_loader:
outputs = model(images) # BN використовує running statistics
Практична реалізація у PyTorch
PyTorch надає три варіанти Batch Normalization для різних типів даних:
| Клас | Призначення | Форма входу |
|---|---|---|
nn.BatchNorm1d | Fully Connected шари, 1D дані | (batch_size, num_features) або (batch_size, num_features, length) |
nn.BatchNorm2d | Convolutional шари, зображення | (batch_size, channels, height, width) |
nn.BatchNorm3d | 3D Convolutional (відео, 3D скани) | (batch_size, channels, depth, height, width) |
class MLPWithBN(nn.Module):
def __init__(self):
super(MLPWithBN, self).__init__()
self.fc1 = nn.Linear(784, 256)
self.bn1 = nn.BatchNorm1d(256) # BN для 256 features
self.fc2 = nn.Linear(256, 128)
self.bn2 = nn.BatchNorm1d(128)
self.fc3 = nn.Linear(128, 10)
self.relu = nn.ReLU()
def forward(self, x):
x = x.view(x.size(0), -1) # Flatten
x = self.fc1(x)
x = self.bn1(x) # Нормалізація
x = self.relu(x)
x = self.fc2(x)
x = self.bn2(x)
x = self.relu(x)
x = self.fc3(x) # Останній шар БЕЗ BN
return x
model = MLPWithBN()
print(model)
print(f"\n📊 Параметри BatchNorm1d:")
print(f" bn1.weight (γ): {model.bn1.weight.shape}") # (256,)
print(f" bn1.bias (β): {model.bn1.bias.shape}") # (256,)
print(f" bn1.running_mean: {model.bn1.running_mean.shape}") # (256,)
print(f" bn1.running_var: {model.bn1.running_var.shape}") # (256,)
MLPWithBN(
(fc1): Linear(in_features=784, out_features=256, bias=True)
(bn1): BatchNorm1d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(fc2): Linear(in_features=256, out_features=128, bias=True)
(bn2): BatchNorm1d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(fc3): Linear(in_features=128, out_features=10, bias=True)
(relu): ReLU()
)
📊 Параметри BatchNorm1d:
weight (γ):torch.Size([256])— навчуваний масштабbias (β):torch.Size([256])— навчуваний зсувrunning_mean:torch.Size([256])— експоненціальне MA μrunning_var:torch.Size([256])— експоненціальне MA σ²
class CNNWithBN(nn.Module):
def __init__(self):
super(CNNWithBN, self).__init__()
# Блок 1
self.conv1 = nn.Conv2d(3, 64, kernel_size=3, padding=1)
self.bn1 = nn.BatchNorm2d(64) # BN для 64 каналів
# Блок 2
self.conv2 = nn.Conv2d(64, 128, kernel_size=3, padding=1)
self.bn2 = nn.BatchNorm2d(128)
# Класифікатор
self.pool = nn.MaxPool2d(2, 2)
self.relu = nn.ReLU()
self.fc = nn.Linear(128 * 8 * 8, 10)
def forward(self, x):
# Блок 1: Conv → BN → ReLU → Pool
x = self.conv1(x) # (batch, 64, 32, 32)
x = self.bn1(x) # Нормалізація по каналах
x = self.relu(x)
x = self.pool(x) # (batch, 64, 16, 16)
# Блок 2
x = self.conv2(x) # (batch, 128, 16, 16)
x = self.bn2(x)
x = self.relu(x)
x = self.pool(x) # (batch, 128, 8, 8)
# Класифікатор
x = x.view(x.size(0), -1)
x = self.fc(x)
return x
model = CNNWithBN()
print(f"📊 Параметри BatchNorm2d:")
print(f" bn1.weight (γ): {model.bn1.weight.shape}") # (64,) — по одному на канал
print(f" bn1.running_mean: {model.bn1.running_mean.shape}") # (64,)
📊 Параметри BatchNorm2d:
weight (γ):torch.Size([64])— один параметр на канал (не на піксель!)running_mean:torch.Size([64])— статистика по каналах
💡 Важливо: BN2d нормалізує по каналах, а не по просторових координатах. Для батчу (32, 64, 16, 16):
- Обчислюємо μ та σ² по
32 × 16 × 16 = 8192значеннях для кожного каналу окремо - Отримуємо 64 пари (μ, σ²) — по одній на канал
Параметри BatchNorm
| Параметр | За замовчуванням | Опис |
|---|---|---|
num_features | — | Кількість каналів (для BN2d) або features (для BN1d) |
eps | 1e-5 | Епсілон для числової стабільності у знаменнику |
momentum | 0.1 | Коефіцієнт для running statistics (увага: PyTorch використовує 1-α!) |
affine | True | Чи навчати γ та β (якщо False, вони фіксовані 1 та 0) |
track_running_stats | True | Чи зберігати running mean/var для eval режиму |
running_mean = (1 - momentum) * running_mean + momentum * batch_mean
momentum=0.1 означає, що 10% нової статистики і 90% старої. Це протилежно стандартній нотації у статтях!Експеримент: CNN з та без Batch Normalization на CIFAR-10
Тепер проведемо комплексний експеримент: навчимо дві ідентичні CNN — одну з BN, іншу без — та порівняємо результати.
Експеримент: вплив Batch Normalization на навчання
Порівняємо дві моделі:
- Baseline CNN без BN
- CNN + BatchNorm
Обидві мають ідентичну архітектуру (кількість шарів, фільтрів), різниця лише у наявності BN.
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import time
# Модель БЕЗ Batch Normalization
class CNNBaseline(nn.Module):
def __init__(self):
super(CNNBaseline, self).__init__()
self.conv1 = nn.Conv2d(3, 64, 3, padding=1)
self.conv2 = nn.Conv2d(64, 128, 3, padding=1)
self.conv3 = nn.Conv2d(128, 256, 3, padding=1)
self.pool = nn.MaxPool2d(2, 2)
self.fc1 = nn.Linear(256 * 4 * 4, 512)
self.fc2 = nn.Linear(512, 10)
self.relu = nn.ReLU()
self.dropout = nn.Dropout(0.5) # Потрібен Dropout для регуляризації
def forward(self, x):
x = self.relu(self.conv1(x))
x = self.pool(x)
x = self.relu(self.conv2(x))
x = self.pool(x)
x = self.relu(self.conv3(x))
x = self.pool(x)
x = x.view(x.size(0), -1)
x = self.dropout(self.relu(self.fc1(x)))
x = self.fc2(x)
return x
# Модель З Batch Normalization
class CNNWithBN(nn.Module):
def __init__(self):
super(CNNWithBN, self).__init__()
self.conv1 = nn.Conv2d(3, 64, 3, padding=1)
self.bn1 = nn.BatchNorm2d(64)
self.conv2 = nn.Conv2d(64, 128, 3, padding=1)
self.bn2 = nn.BatchNorm2d(128)
self.conv3 = nn.Conv2d(128, 256, 3, padding=1)
self.bn3 = nn.BatchNorm2d(256)
self.pool = nn.MaxPool2d(2, 2)
self.fc1 = nn.Linear(256 * 4 * 4, 512)
self.bn_fc = nn.BatchNorm1d(512)
self.fc2 = nn.Linear(512, 10)
self.relu = nn.ReLU()
# Dropout менший або взагалі не потрібен з BN
self.dropout = nn.Dropout(0.2)
def forward(self, x):
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x = self.pool(x)
x = self.conv2(x)
x = self.bn2(x)
x = self.relu(x)
x = self.pool(x)
x = self.conv3(x)
x = self.bn3(x)
x = self.relu(x)
x = self.pool(x)
x = x.view(x.size(0), -1)
x = self.fc1(x)
x = self.bn_fc(x)
x = self.relu(x)
x = self.dropout(x)
x = self.fc2(x)
return x
# Завантаження CIFAR-10
transform_train = transforms.Compose([
transforms.RandomHorizontalFlip(),
transforms.RandomCrop(32, padding=4),
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))
])
transform_test = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))
])
train_dataset = datasets.CIFAR10('./data', train=True, download=True, transform=transform_train)
test_dataset = datasets.CIFAR10('./data', train=False, download=True, transform=transform_test)
train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=2)
test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False, num_workers=2)
print("✅ Датасет завантажено")
print(f" Train: {len(train_dataset)} зображень")
print(f" Test: {len(test_dataset)} зображень")
✅ Датасет завантажено
- Train: 50,000 зображень (32×32×3, 10 класів)
- Test: 10,000 зображень
def train_and_evaluate(model, model_name, train_loader, test_loader,
epochs=20, lr=0.01, device='cuda'):
"""Навчання моделі та збереження метрик"""
model = model.to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=lr, momentum=0.9, weight_decay=5e-4)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
history = {
'train_loss': [], 'train_acc': [],
'test_loss': [], 'test_acc': [],
'epoch_time': []
}
print(f"\n🚀 Навчання моделі: {model_name}")
print(f" Learning rate: {lr}")
for epoch in range(epochs):
start_time = time.time()
# Training
model.train()
train_loss = 0.0
train_correct = 0
train_total = 0
for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
train_loss += loss.item()
_, predicted = outputs.max(1)
train_total += labels.size(0)
train_correct += predicted.eq(labels).sum().item()
# Evaluation
model.eval()
test_loss = 0.0
test_correct = 0
test_total = 0
with torch.no_grad():
for images, labels in test_loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
test_loss += loss.item()
_, predicted = outputs.max(1)
test_total += labels.size(0)
test_correct += predicted.eq(labels).sum().item()
epoch_time = time.time() - start_time
train_loss_avg = train_loss / len(train_loader)
train_acc = 100. * train_correct / train_total
test_loss_avg = test_loss / len(test_loader)
test_acc = 100. * test_correct / test_total
history['train_loss'].append(train_loss_avg)
history['train_acc'].append(train_acc)
history['test_loss'].append(test_loss_avg)
history['test_acc'].append(test_acc)
history['epoch_time'].append(epoch_time)
scheduler.step()
if (epoch + 1) % 5 == 0:
print(f" Епоха {epoch+1:2d}/{epochs}: "
f"Train Acc={train_acc:.2f}%, Test Acc={test_acc:.2f}%, "
f"Time={epoch_time:.1f}s")
return history
# Експеримент
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# Модель 1: Baseline без BN
torch.manual_seed(42)
model_baseline = CNNBaseline()
history_baseline = train_and_evaluate(
model_baseline, "Baseline (без BN)",
train_loader, test_loader,
epochs=20, lr=0.01, device=device
)
# Модель 2: З Batch Normalization
torch.manual_seed(42)
model_bn = CNNWithBN()
history_bn = train_and_evaluate(
model_bn, "CNN + BatchNorm",
train_loader, test_loader,
epochs=20, lr=0.1, # ← ЗБІЛЬШЕНИЙ LR у 10 разів!
device=device
)
print("\n🎉 Експеримент завершено!")
🚀 Навчання моделі: Baseline (без BN)
- Learning rate: 0.01
- Епоха 5/20: Train Acc=54.23%, Test Acc=52.14%, Time=24.2s
- Епоха 10/20: Train Acc=68.45%, Test Acc=64.78%, Time=24.1s
- Епоха 15/20: Train Acc=76.82%, Test Acc=69.34%, Time=24.3s
- Епоха 20/20: Train Acc=82.15%, Test Acc=71.52%, Time=24.2s
🚀 Навчання моделі: CNN + BatchNorm
- Learning rate: 0.1 (у 10 разів більший!)
- Епоха 5/20: Train Acc=72.45%, Test Acc=69.82%, Time=26.8s
- Епоха 10/20: Train Acc=84.67%, Test Acc=78.23%, Time=26.7s
- Епоха 15/20: Train Acc=91.23%, Test Acc=82.45%, Time=26.9s
- Епоха 20/20: Train Acc=94.78%, Test Acc=84.67%, Time=26.8s
🎉 Експеримент завершено!
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
# Графік 1: Test Accuracy
ax1 = axes[0]
ax1.plot(range(1, 21), history_baseline['test_acc'],
marker='o', linewidth=2.5, label='Baseline (LR=0.01)', color='#ef4444')
ax1.plot(range(1, 21), history_bn['test_acc'],
marker='s', linewidth=2.5, label='+ BatchNorm (LR=0.1)', color='#22c55e')
ax1.set_xlabel('Епоха', fontsize=13)
ax1.set_ylabel('Test Accuracy (%)', fontsize=13)
ax1.set_title('Точність на тестовому датасеті', fontsize=15, fontweight='bold')
ax1.legend(fontsize=12)
ax1.grid(alpha=0.3)
ax1.set_ylim([40, 90])
# Графік 2: Train Loss
ax2 = axes[1]
ax2.plot(range(1, 21), history_baseline['train_loss'],
marker='o', linewidth=2.5, label='Baseline', color='#ef4444')
ax2.plot(range(1, 21), history_bn['train_loss'],
marker='s', linewidth=2.5, label='+ BatchNorm', color='#22c55e')
ax2.set_xlabel('Епоха', fontsize=13)
ax2.set_ylabel('Train Loss', fontsize=13)
ax2.set_title('Динаміка втрат під час навчання', fontsize=15, fontweight='bold')
ax2.legend(fontsize=12)
ax2.grid(alpha=0.3)
plt.tight_layout()
plt.show()
# Підсумкова таблиця
print("\n📊 Порівняльна таблиця результатів:\n")
print(f"{'Модель':<25} | {'LR':<8} | {'Фінал Train Acc':<18} | {'Фінал Test Acc':<18} | {'Приріст':<10}")
print("-" * 95)
print(f"{'Baseline (без BN)':<25} | {0.01:<8.2f} | {history_baseline['train_acc'][-1]:<18.2f} | "
f"{history_baseline['test_acc'][-1]:<18.2f} | {'—':<10}")
print(f"{'CNN + BatchNorm':<25} | {0.1:<8.2f} | {history_bn['train_acc'][-1]:<18.2f} | "
f"{history_bn['test_acc'][-1]:<18.2f} | {'+' + str(round(history_bn['test_acc'][-1] - history_baseline['test_acc'][-1], 2)) + '%':<10}")
print(f"\n🏆 BatchNorm дозволила:")
print(f" ✅ Збільшити LR у 10 разів (0.01 → 0.1)")
print(f" ✅ Покращити test accuracy на {history_bn['test_acc'][-1] - history_baseline['test_acc'][-1]:.2f}%")
print(f" ✅ Досягти конвергенції швидше (крутіша крива навчання)")

📊 Порівняльна таблиця результатів:
| Модель | LR | Фінал Train Acc | Фінал Test Acc | Приріст |
|---|---|---|---|---|
| Baseline (без BN) | 0.01 | 82.15% | 71.52% | — |
| CNN + BatchNorm | 0.10 | 94.78% | 84.67% | +13.15% |
🏆 BatchNorm дозволила:
- ✅ Збільшити LR у 10 разів (0.01 → 0.1)
- ✅ Покращити test accuracy на 13.15%
- ✅ Досягти конвергенції швидше (крутіша крива навчання)
- ✅ Зменшити gap між train та test (менше перенавчання)
⚡ Швидша конвергенція
📈 Вища фінальна точність
🎯 Менше перенавчання
🔧 Стабільність навчання
Обмеження та альтернативи Batch Normalization
Попри всі переваги, Batch Normalization має кілька обмежень, які призвели до розробки альтернативних методів нормалізації.
⚠️ Залежність від розміру батчу
Проблема: Статистика μ та σ² обчислюється по батчу. Якщо batch_size < 16, статистика стає дуже шумною, і BN працює погано.
Випадки: Обмеження GPU пам'яті (великі зображення 512×512), відео (3D convolutions), рекурентні мережі з довгими послідовностями.
Рішення: Group Normalization або Layer Normalization (не залежать від batch size).
⚠️ Не працює для RNN/LSTM
Проблема: У рекурентних мережах послідовності мають різну довжину. Неможливо обчислити μ та σ² по батчу, коли у одного прикладу 10 токенів, а у іншого — 50.
Рішення: Layer Normalization — нормалізація по features замість по батчу. Використовується у Transformers (BERT, GPT).
⚠️ Додаткові обчислення
Проблема: BN додає 10-15% overhead до часу forward/backward pass.
Випадки: Якщо швидкість inference критична (edge devices, реальний час).
Рішення: Після навчання можна fuse BN з попереднім Conv/Linear шаром (обчислити еквівалентні ваги без окремого BN).
⚠️ Проблеми з Transfer Learning
Проблема: Running statistics обчислені на одному датасеті можуть не підходити для іншого.
Приклад: Pretrained ImageNet модель має running stats для природних зображень. При fine-tuning на медичні рентгени розподіл активацій інший.
Рішення: Re-compute running stats на новому датасеті або freeze BN шари.
Альтернативні методи нормалізації
| Метод | Нормалізація по | Застосування |
|---|---|---|
| Batch Norm | Батчу та просторових координатах | CNN для зображень (batch_size ≥ 32) |
| Layer Norm | Features (всередині одного прикладу) | Transformers, RNN, NLP |
| Instance Norm | Просторових координатах кожного прикладу окремо | Style transfer, GAN |
| Group Norm | Групах каналів | CNN з малими батчами (2-8) |
- CNN для зображень (batch ≥ 32): Batch Normalization
- Transformers / NLP: Layer Normalization
- Style Transfer / GAN: Instance Normalization
- Обмеження пам'яті (batch < 16): Group Normalization
Висновки та практичні рекомендації
Майже завжди — ТАК для CNN. Виключення:
- Дуже малі батчі (< 8) → використайте Group Normalization
- RNN/LSTM → використайте Layer Normalization
- Transfer learning із заморожен!ими шарами → можливо, краще без BN
Для нових архітектур: почніть з BN, експериментуйте з альтернативами лише якщо є проблеми.
Зазвичай НІ або значно менший (p=0.1-0.2 замість 0.5).
BN вже діє як регуляризація через шум у батч-статистиці. Комбінація BN + Dropout може погіршити результати через надмірну регуляризацію.
Виключення: дуже великі датасети (> 1M прикладів) — можна додати легкий Dropout (p=0.2) перед останніми FC шарами.
Два підходи:
- Freeze BN шари (рекомендовано):
for module in model.modules(): if isinstance(module, nn.BatchNorm2d): module.eval() # Завжди у eval режимі module.weight.requires_grad = False module.bias.requires_grad = False - Re-compute running stats:
# Після завантаження pretrained ваг model.train() for images, _ in new_dataset_loader: model(images) # Просто forward, без backward
Симптоми проблем:
- Train accuracy зростає, test accuracy — ні → забули
model.eval() - Prediction нестабільні (різні при кожному запуску) → використовуєте train mode при inference
- Loss не зменшується → можливо, занадто малий batch_size (< 8)
Діагностика:
# Перевірка режиму
print(f"Model training: {model.training}")
for module in model.modules():
if isinstance(module, nn.BatchNorm2d):
print(f"BN training: {module.training}")
# Перевірка running stats
bn_layer = model.bn1 # приклад
print(f"Running mean: {bn_layer.running_mean[:5]}")
print(f"Running var: {bn_layer.running_var[:5]}")
- Розміщувати після Conv/Linear, перед Activation
- Завжди
model.eval()перед тестуванням/inference - Використовувати batch_size ≥ 32 для стабільної статистики
- Зменшити або прибрати Dropout при використанні BN
- Можна збільшити learning rate у 5-10 разів порівняно з baseline
У наступній статті ми розглянемо Dropout та комбіновані стратегії регуляризації — як поєднати BN, Dropout, weight decay та data augmentation для максимальної генералізації.
Ініціалізація ваг — фундамент стабільного навчання
Чому нульова ініціалізація не працює, математика Xavier та He ініціалізації, практичне застосування у PyTorch та експериментальне порівняння методів на глибоких мережах.
Dropout та комбіновані стратегії регуляризації
Випадкове вимикання нейронів для боротьби з перенавчанням, математика та інтуїція Dropout, комбінування з BatchNorm і weight decay, експериментальне порівняння стратегій.