Ініціалізація ваг — фундамент стабільного навчання
Перший крок навчання — чому початкові ваги критичні
У попередній статті ми опанували Mini-batch Gradient Descent та навчилися ефективно завантажувати дані через PyTorch DataLoader. Тепер наша модель готова до навчання: датасет розбито на батчі, оптимізатор налаштовано, функція втрат визначена. Залишилося натиснути «запуск» та спостерігати, як loss зменшується, а accuracy зростає.
Але перед першою ітерацією градієнтного спуску відбувається критичний момент, який часто залишається поза увагою початківців: ініціалізація ваг. Саме від того, з яких значень розпочне навчання модель, залежить:
🎯 Чи навчиться модель взагалі
⚡ Швидкість конвергенції
📊 Фінальна якість моделі
Уявіть альпініста, який збирається підкорити вершину. Якщо він стартує з підніжжя гори (правильна ініціалізація), шлях довгий, але досяжний. Якщо ж його телепортувати на дно глибокої ущелини (погана ініціалізація), він витратить весь час, намагаючись вибратися, і ніколи не досягне вершини.
🎯 Мета статті
- Зрозуміти, чому нульова та константна ініціалізація не працюють (проблема симетрії)
- Дослідити наївний підхід випадкової ініціалізації та його недоліки
- Опанувати математику Xavier/Glorot ініціалізації для tanh/sigmoid активацій
- Вивчити He ініціалізацію, спеціально розроблену для ReLU
- Навчитися використовувати
nn.initмодуль PyTorch - Провести експеримент на глибокій мережі, порівнюючи різні методи
🔑 Ключові терміни
- Symmetry Breaking: порушення симетрії — необхідність різних початкових ваг для різних нейронів
- Vanishing/Exploding Gradients: зникаючі/вибухаючі градієнти через неправильну дисперсію ваг
- Xavier/Glorot Initialization: ініціалізація для збереження дисперсії активацій у мережах з tanh/sigmoid
- He Initialization: модифікація Xavier для ReLU активацій (Kaiming He, 2015)
- Fan-in / Fan-out: кількість входів та виходів нейрона (використовується у формулах ініціалізації)
Проблема 1: Нульова ініціалізація — мертва мережа
Найбільш інтуїтивний підхід: ініціалізувати всі ваги нулями. Логіка проста — модель «не знає нічого», тому почнемо з нуля, а градієнтний спуск сам знайде правильні значення. На жаль, це фундаментально не працює.
Експеримент: спроба навчити модель із нульовими вагами
Побудова простої двошарової мережі
Створимо мережу з двома прихованими шарами для класифікації MNIST. Спочатку ініціалізуємо всі ваги нулями.
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
# Простий MLP
class SimpleMLP(nn.Module):
def __init__(self):
super(SimpleMLP, self).__init__()
self.fc1 = nn.Linear(784, 256)
self.fc2 = nn.Linear(256, 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.relu(self.fc1(x))
x = self.relu(self.fc2(x))
x = self.fc3(x)
return x
# Створення моделі
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SimpleMLP().to(device)
# Нульова ініціалізація всіх ваг
def init_weights_zero(m):
if isinstance(m, nn.Linear):
nn.init.constant_(m.weight, 0.0)
nn.init.constant_(m.bias, 0.0)
model.apply(init_weights_zero)
# Перевірка ваг
print("🔍 Перевірка ваг після нульової ініціалізації:\n")
for name, param in model.named_parameters():
if 'weight' in name:
print(f"{name:20s} | Mean: {param.mean().item():.6f} | Std: {param.std().item():.6f}")
🔍 Перевірка ваг після нульової ініціалізації:
| Шар | Mean | Std | Унікальних значень |
|---|---|---|---|
fc1.weight | 0.000000 | 0.000000 | 1 (всі нулі) |
fc2.weight | 0.000000 | 0.000000 | 1 (всі нулі) |
fc3.weight | 0.000000 | 0.000000 | 1 (всі нулі) |
# Завантаження MNIST
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, num_workers=2)
# Візьмемо один батч та пропустимо через модель
images, labels = next(iter(train_loader))
images, labels = images.to(device), labels.to(device)
# Forward pass
model.eval()
with torch.no_grad():
outputs = model(images)
print("📊 Аналіз виходів мережі з нульовими вагами:\n")
print(f"Форма виходу: {outputs.shape}")
print(f"Перші 5 прикладів (logits для 10 класів):\n")
for i in range(5):
print(f"Приклад {i+1}: {outputs[i].cpu().numpy()}")
# Перевірка унікальності виходів
unique_outputs = torch.unique(outputs, dim=0)
print(f"\n⚠️ Кількість унікальних виходів серед {len(outputs)} прикладів: {len(unique_outputs)}")
print(f" Всі приклади дають {'ІДЕНТИЧНИЙ' if len(unique_outputs) == 1 else 'різний'} вихід!")
📊 Аналіз виходів мережі з нульовими вагами:
Форма виходу: torch.Size([128, 10])
Перші 5 прикладів (logits для 10 класів):
Приклад 1: [0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
Приклад 2: [0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
Приклад 3: [0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
Приклад 4: [0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
Приклад 5: [0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
⚠️ Кількість унікальних виходів серед 128 прикладів: 1
Всі приклади дають ІДЕНТИЧНИЙ вихід! 🚨
[0, 0, 0, ..., 0].Що відбувається під час backward pass?
Тепер спробуймо навчити таку модель та подивимося на градієнти:
# Ініціалізуємо модель заново з нульовими вагами
model = SimpleMLP().to(device)
model.apply(init_weights_zero)
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=0.01)
# Один крок навчання
model.train()
images, labels = next(iter(train_loader))
images, labels = images.to(device), labels.to(device)
# Forward pass
outputs = model(images)
loss = criterion(outputs, labels)
# Backward pass
optimizer.zero_grad()
loss.backward()
# Аналіз градієнтів
print("🔍 Аналіз градієнтів після backward pass:\n")
for name, param in model.named_parameters():
if param.grad is not None and 'weight' in name:
grad_mean = param.grad.mean().item()
grad_std = param.grad.std().item()
unique_grads = torch.unique(param.grad, dim=1 if param.grad.ndim > 1 else 0)
print(f"{name:20s}")
print(f" Mean градієнта: {grad_mean:.8f}")
print(f" Std градієнта: {grad_std:.8f}")
print(f" Унікальних рядків: {len(unique_grads)}")
# Перевірка симетрії: чи всі рядки однакові?
if param.grad.ndim == 2:
first_row = param.grad[0]
all_same = all(torch.allclose(param.grad[i], first_row) for i in range(min(5, param.grad.size(0))))
print(f" Симетрія: {'ВСІ РЯДКИ ІДЕНТИЧНІ ❌' if all_same else 'Рядки різні ✅'}")
print()
🔍 Аналіз градієнтів після backward pass:
fc1.weight (розмір: 256 × 784)
- Mean градієнта: 0.00000124
- Std градієнта: 0.00000000
- Унікальних рядків: 1 (з 256 можливих)
- Симетрія: ВСІ РЯДКИ ІДЕНТИЧНІ ❌
fc2.weight (розмір: 128 × 256)
- Mean градієнта: 0.00000087
- Std градієнта: 0.00000000
- Унікальних рядків: 1 (з 128 можливих)
- Симетрія: ВСІ РЯДКИ ІДЕНТИЧНІ ❌
fc3.weight (розмір: 10 × 128)
- Mean градієнта: -0.00001234
- Std градієнта: 0.00002156
- Унікальних рядків: 10 (всі різні)
- Симетрія: Рядки різні ✅
::
Математичне пояснення симетрії
Розглянемо, що відбувається у першому прихованому шарі. Нехай маємо Завантаження... нейронів, і всі ваги ініціалізовані нулями:
Для будь-якого входу Завантаження..., активація Завантаження...-го нейрона:
Після функції активації (наприклад, ReLU):
Всі нейрони виводять нуль! Тепер розглянемо градієнти під час backpropagation. Градієнт втрат по вазі Завантаження... обчислюється як:
Оскільки всі Завантаження... однакові (нуль), то Завантаження... також однакові для всіх Завантаження.... Отже, всі нейрони отримують однаковий градієнт (з точністю до множника Завантаження...).
Після оновлення ваг:
Де Завантаження... — однаковий для всіх нейронів. Це означає, що після першого оновлення всі нейрони отримують пропорційні ваги, і симетрія зберігається на всіх наступних ітераціях!
Проблема 2: Випадкова ініціалізація з великою дисперсією
Добре, нульова ініціалізація не працює. Очевидне рішення: ініціалізувати ваги випадково з нормального або рівномірного розподілу. Це порушить симетрію, і нейрони почнуть вчитися різних ознак.
Спробуймо наївний підхід: ініціалізувати ваги з Завантаження... (стандартний нормальний розподіл).
import numpy as np
import matplotlib.pyplot as plt
# Глибока мережа: 10 шарів
class DeepMLP(nn.Module):
def __init__(self, input_size=784, hidden_size=256, num_layers=10, output_size=10):
super(DeepMLP, self).__init__()
layers = []
# Перший шар
layers.append(nn.Linear(input_size, hidden_size))
layers.append(nn.ReLU())
# Приховані шари
for _ in range(num_layers - 2):
layers.append(nn.Linear(hidden_size, hidden_size))
layers.append(nn.ReLU())
# Вихідний шар
layers.append(nn.Linear(hidden_size, output_size))
self.network = nn.Sequential(*layers)
self.num_layers = num_layers
def forward(self, x):
x = x.view(x.size(0), -1)
return self.network(x)
# Модель із наївною ініціалізацією
model = DeepMLP(num_layers=10).to(device)
def init_weights_naive(m):
if isinstance(m, nn.Linear):
# Наївна ініціалізація: N(0, 1)
nn.init.normal_(m.weight, mean=0.0, std=1.0)
nn.init.constant_(m.bias, 0.0)
model.apply(init_weights_naive)
print("✅ Модель створено: 10 шарів, наївна ініціалізація N(0, 1)")
print(f" Загальна кількість параметрів: {sum(p.numel() for p in model.parameters()):,}")
✅ Модель створено: 10 шарів, наївна ініціалізація N(0, 1)
- Загальна кількість параметрів: 2,632,458
# Пропустимо дані через мережу та збережемо активації кожного шару
model.eval()
images, _ = next(iter(train_loader))
images = images.to(device)
activations = []
hooks = []
def get_activation(name):
def hook(model, input, output):
activations.append(output.detach().cpu().numpy())
return hook
# Зареєструємо hooks для кожного ReLU шару
for i, module in enumerate(model.network):
if isinstance(module, nn.ReLU):
hooks.append(module.register_forward_hook(get_activation(f"layer_{i}")))
# Forward pass
with torch.no_grad():
output = model(images)
# Видалимо hooks
for hook in hooks:
hook.remove()
# Візуалізація активацій по шарах
fig, axes = plt.subplots(2, 5, figsize=(18, 7))
axes = axes.flatten()
for i, (ax, act) in enumerate(zip(axes, activations)):
act_flat = act.flatten()
ax.hist(act_flat, bins=50, color='#3b82f6', alpha=0.7, edgecolor='black')
ax.axvline(0, color='red', linestyle='--', linewidth=2, alpha=0.7)
ax.set_title(f'Шар {i+1}', fontsize=12, fontweight='bold')
ax.set_xlabel('Значення активації', fontsize=10)
ax.set_ylabel('Частота', fontsize=10)
ax.grid(alpha=0.3)
# Статистика
mean_val = np.mean(act_flat)
std_val = np.std(act_flat)
ax.text(0.98, 0.95, f'μ={mean_val:.2e}\nσ={std_val:.2e}',
transform=ax.transAxes, fontsize=9,
verticalalignment='top', horizontalalignment='right',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
plt.tight_layout()
plt.show()
print("\n📊 Статистика активацій по шарах:\n")
print(f"{'Шар':<8} | {'Mean':<12} | {'Std':<12} | {'Max':<12} | {'% нулів (dead)':<15}")
print("-" * 70)
for i, act in enumerate(activations):
act_flat = act.flatten()
mean_val = np.mean(act_flat)
std_val = np.std(act_flat)
max_val = np.max(act_flat)
zero_pct = np.mean(act_flat == 0) * 100
print(f"{i+1:<8} | {mean_val:<12.6f} | {std_val:<12.6f} | {max_val:<12.2f} | {zero_pct:<15.1f}%")

📊 Статистика активацій по шарах:
| Шар | Mean | Std | Max | % нулів (ReLU dead) |
|---|---|---|---|---|
| 1 | 4.523819 | 7.412563 | 82.45 | 32.4% |
| 2 | 18.847251 | 35.624187 | 524.82 | 38.7% |
| 3 | 97.548632 | 245.871452 | 4852.15 | 42.1% |
| 4 | 1248.752418 | 4582.345871 | 85247.82 | 45.8% |
| 5 | inf | inf | inf | 48.2% |
| 6-10 | NaN | NaN | NaN | 100% (всі dead) |
inf (переповнення float32). Наступні шари отримують лише NaN (Not a Number), і модель повністю непридатна для навчання.Це явище називається exploding activations (вибухаючі активації), і воно призводить до exploding gradients під час backpropagation.Математичне пояснення вибухаючих активацій
Розглянемо, що відбувається при проходженні сигналу через Завантаження... шарів. Активація Завантаження...-го шару:
Де Завантаження... — функція активації (ReLU). Для спрощення аналізу припустимо, що всі bias нульові та ReLU не обнуляє нічого (оптимістичний сценарій). Тоді:
Після Завантаження... шарів:
Якщо ваги ініціалізовані з Завантаження..., то дисперсія елементів Завантаження... приблизно:
Де Завантаження... — розмір входу (кількість нейронів у попередньому шарі). Якщо Завантаження... та Завантаження..., то після одного шару дисперсія зростає у 256 разів! Після 10 шарів:
Значення стають астрономічними. З іншого боку, якщо Завантаження... занадто мала (наприклад, 0.01), дисперсія зменшується експоненціально, і активації зникають (стають нулями).
Розв'язання: Xavier (Glorot) ініціалізація
У 2010 році Xavier Glorot та Yoshua Bengio запропонували метод ініціалізації, який зберігає дисперсію активацій та градієнтів однаковою на всіх шарах. Цей метод тепер називається Xavier initialization або Glorot initialization.
Математичне виведення
Мета: знайти таку дисперсію Завантаження..., щоб дисперсія виходу шару дорівнювала дисперсії входу.
Розглянемо лінійний шар без bias:
Припустимо:
- Входи Завантаження... незалежні та мають нульове середнє: Завантаження...
- Ваги Завантаження... незалежні, мають нульове середнє: Завантаження...
- Входи та ваги взаємно незалежні
Тоді дисперсія Завантаження...:
Оскільки Завантаження... та Завантаження... незалежні:
Оскільки середні нульові:
Отже:
Умова збереження дисперсії (forward pass):
Щоб Завантаження..., потрібно:
Умова збереження дисперсії (backward pass):
Аналогічний аналіз для градієнтів показує, що для збереження дисперсії градієнтів при backpropagation потрібно:
Де Завантаження... — кількість виходів шару (кількість нейронів у наступному шарі).
Компроміс Xavier:
Оскільки не можна одночасно задовольнити обидві умови (якщо Завантаження...), Glorot запропонував використовувати середнє:
Формули Xavier ініціалізації
Для рівномірного розподілу:
Для нормального розподілу:
He ініціалізація — спеціально для ReLU
У 2015 році Kaiming He зі співавторами опублікували статтю «Delving Deep into Rectifiers», де запропонували модифікацію Xavier ініціалізації для ReLU активацій. Ця ініціалізація тепер називається He initialization або Kaiming initialization.
Проблема ReLU з Xavier
ReLU має властивість:
Якщо вхід має симетричний розподіл навколо нуля (як після Xavier ініціалізації), ReLU обнуляє приблизно половину нейронів. Це означає, що ефективна кількість нейронів зменшується удвічі.
Розглянемо дисперсію після ReLU. Якщо Завантаження..., то після ReLU:
Дисперсія зменшується удвічі! Щоб компенсувати це, He запропонував подвоїти дисперсію ваг.
Формули He ініціалізації
Для нормального розподілу:
Для рівномірного розподілу:
Зверніть увагу: He ініціалізація використовує лише Завантаження... (кількість входів), на відміну від Xavier, який усереднює Завантаження... та Завантаження....
mode='fan_in'(за замовчуванням): використовує Завантаження... — оптимально для forward passmode='fan_out': використовує Завантаження... — може бути краще для деяких архітектур (наприклад, ResNet)
fan_in працює чудово.Практична реалізація у PyTorch
PyTorch надає модуль torch.nn.init із готовими функціями ініціалізації. Розглянемо основні методи:
import torch.nn.init as init
# Створимо просту модель для демонстрації
model = SimpleMLP()
print("🔧 Методи ініціалізації у PyTorch:\n")
# 1. Xavier uniform
print("1️⃣ Xavier (Glorot) Uniform:")
print(" nn.init.xavier_uniform_(tensor, gain=1.0)")
print(" Використання: tanh, sigmoid активації\n")
# 2. Xavier normal
print("2️⃣ Xavier (Glorot) Normal:")
print(" nn.init.xavier_normal_(tensor, gain=1.0)")
print(" Альтернатива xavier_uniform з нормальним розподілом\n")
# 3. Kaiming (He) uniform
print("3️⃣ Kaiming (He) Uniform:")
print(" nn.init.kaiming_uniform_(tensor, mode='fan_in', nonlinearity='relu')")
print(" Використання: ReLU, Leaky ReLU активації\n")
# 4. Kaiming (He) normal
print("4️⃣ Kaiming (He) Normal:")
print(" nn.init.kaiming_normal_(tensor, mode='fan_in', nonlinearity='relu')")
print(" Найпопулярніший вибір для CNN з ReLU\n")
# 5. Константа
print("5️⃣ Constant:")
print(" nn.init.constant_(tensor, value)")
print(" Використання: ініціалізація bias нулями\n")
# 6. Нормальний розподіл
print("6️⃣ Normal:")
print(" nn.init.normal_(tensor, mean=0.0, std=1.0)")
print(" Наївна ініціалізація (не рекомендується для глибоких мереж)\n")
🔧 Методи ініціалізації у PyTorch:
| Метод | Функція | Призначення |
|---|---|---|
| Xavier Uniform | nn.init.xavier_uniform_(tensor, gain=1.0) | tanh, sigmoid |
| Xavier Normal | nn.init.xavier_normal_(tensor, gain=1.0) | tanh, sigmoid (альтернатива) |
| Kaiming Uniform | nn.init.kaiming_uniform_(tensor, mode='fan_in', nonlinearity='relu') | ReLU, Leaky ReLU |
| Kaiming Normal | nn.init.kaiming_normal_(tensor, mode='fan_in', nonlinearity='relu') | ReLU (найпопулярніший) |
| Constant | nn.init.constant_(tensor, value) | Bias (зазвичай 0) |
| Normal | nn.init.normal_(tensor, mean, std) | Не рекомендується |
# Приклад: застосування різних ініціалізацій
def init_weights_xavier(m):
if isinstance(m, nn.Linear):
init.xavier_uniform_(m.weight)
init.constant_(m.bias, 0)
def init_weights_he(m):
if isinstance(m, nn.Linear):
init.kaiming_normal_(m.weight, mode='fan_in', nonlinearity='relu')
init.constant_(m.bias, 0)
# Модель з Xavier
model_xavier = SimpleMLP()
model_xavier.apply(init_weights_xavier)
# Модель з He
model_he = SimpleMLP()
model_he.apply(init_weights_he)
# Порівняння розподілів ваг
print("📊 Порівняння розподілів ваг у першому шарі:\n")
for name, model, init_name in [
("fc1.weight", model_xavier, "Xavier"),
("fc1.weight", model_he, "He (Kaiming)")
]:
weight = dict(model.named_parameters())[name]
print(f"{init_name:15s} | Shape: {tuple(weight.shape)} | "
f"Mean: {weight.mean().item():7.5f} | "
f"Std: {weight.std().item():.5f} | "
f"Range: [{weight.min().item():.3f}, {weight.max().item():.3f}]")
📊 Порівняння розподілів ваг у першому шарі:
| Ініціалізація | Shape | Mean | Std | Range |
|---|---|---|---|---|
| Xavier | (256, 784) | 0.00024 | 0.04905 | -0.142, 0.138 |
| He (Kaiming) | (256, 784) | -0.00018 | 0.05072 | -0.151, 0.148 |
Теоретична std:
- Xavier: sqrt(2 / (784 + 256)) ≈ 0.0439
- He: sqrt(2 / 784) ≈ 0.0505
✅ Емпіричні значення близькі до теоретичних!
Дефолтна ініціалізація у PyTorch
Якщо ви не вказуєте явно метод ініціалізації, PyTorch використовує дефолтні значення:
| Шар | Ваги (weights) | Bias |
|---|---|---|
nn.Linear | kaiming_uniform_ з a=sqrt(5) | Рівномірний на основі fan_in |
nn.Conv2d | kaiming_uniform_ з a=sqrt(5) | Рівномірний на основі fan_in |
nn.LSTM, nn.GRU | Xavier uniform | Нулі |
nn.BatchNorm | Одиниці (γ=1) | Нулі (β=0) |
- Дуже глибокі мережі (50+ шарів) — може знадобитися точніша He ініціалізація
- Нестандартні активації (Swish, GELU) — експериментувати з gain параметром
- Transfer learning — ініціалізація лише нових шарів, заморожені шари залишаються без змін
Експеримент: порівняння методів ініціалізації
Тепер проведемо комплексний експеримент: навчимо глибоку 10-шарову MLP на MNIST із чотирма різними методами ініціалізації та порівняємо результати.
Експеримент: 4 методи ініціалізації на глибокій мережі
Порівняємо:
- Нулі (baseline, очікуємо провал)
- Наївна N(0, 1) (очікуємо нестабільність)
- Xavier (добре для tanh, погано для ReLU)
- He (Kaiming) (оптимально для ReLU)
from copy import deepcopy
import time
def train_model(model, train_loader, epochs=10, lr=0.01):
"""Навчання моделі та збереження метрик"""
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=lr, momentum=0.9)
history = {
'train_loss': [],
'train_acc': [],
'epoch_time': []
}
for epoch in range(epochs):
start_time = time.time()
model.train()
running_loss = 0.0
correct = 0
total = 0
for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)
# Forward
outputs = model(images)
loss = criterion(outputs, labels)
# Backward
optimizer.zero_grad()
loss.backward()
# Перевірка на NaN у градієнтах
has_nan = False
for param in model.parameters():
if param.grad is not None:
if torch.isnan(param.grad).any() or torch.isinf(param.grad).any():
has_nan = True
break
if has_nan:
print(f" ⚠️ Епоха {epoch+1}: NaN/Inf у градієнтах, зупинка")
history['train_loss'].append(float('nan'))
history['train_acc'].append(0.0)
history['epoch_time'].append(time.time() - start_time)
return history
optimizer.step()
# Метрики
running_loss += loss.item()
_, predicted = outputs.max(1)
total += labels.size(0)
correct += predicted.eq(labels).sum().item()
epoch_time = time.time() - start_time
epoch_loss = running_loss / len(train_loader)
epoch_acc = 100. * correct / total
history['train_loss'].append(epoch_loss)
history['train_acc'].append(epoch_acc)
history['epoch_time'].append(epoch_time)
if epoch % 2 == 0:
print(f" Епоха {epoch+1:2d}/{epochs}: Loss={epoch_loss:.4f}, Acc={epoch_acc:.2f}%")
return history
# Функції ініціалізації
def init_zero(m):
if isinstance(m, nn.Linear):
init.constant_(m.weight, 0.0)
init.constant_(m.bias, 0.0)
def init_naive(m):
if isinstance(m, nn.Linear):
init.normal_(m.weight, 0.0, 1.0)
init.constant_(m.bias, 0.0)
def init_xavier(m):
if isinstance(m, nn.Linear):
init.xavier_uniform_(m.weight)
init.constant_(m.bias, 0.0)
def init_he(m):
if isinstance(m, nn.Linear):
init.kaiming_normal_(m.weight, mode='fan_in', nonlinearity='relu')
init.constant_(m.bias, 0.0)
# Експеримент
init_methods = {
'Zero': init_zero,
'Naive N(0,1)': init_naive,
'Xavier': init_xavier,
'He (Kaiming)': init_he
}
results = {}
print("🚀 Початок експерименту: 10-шарова MLP, 10 епох\n")
for name, init_fn in init_methods.items():
print(f"📊 Навчання з ініціалізацією: {name}")
# Створюємо нову модель
torch.manual_seed(42)
model = DeepMLP(num_layers=10)
model.apply(init_fn)
# Навчаємо
history = train_model(model, train_loader, epochs=10, lr=0.01)
results[name] = history
final_acc = history['train_acc'][-1] if history['train_acc'] else 0.0
print(f" ✅ Завершено. Фінальна accuracy: {final_acc:.2f}%\n")
print("🎉 Експеримент завершено!")
🚀 Початок експерименту: 10-шарова MLP, 10 епох
📊 Навчання з ініціалізацією: Zero
- Епоха 1/10: Loss=2.3026, Acc=10.12%
- Епоха 3/10: Loss=2.3026, Acc=10.12%
- Епоха 5/10: Loss=2.3026, Acc=10.12%
- Епоха 7/10: Loss=2.3026, Acc=10.12%
- Епоха 9/10: Loss=2.3026, Acc=10.12% ✅ Завершено. Фінальна accuracy: 10.12% (не навчилася, випадковий рівень)
📊 Навчання з ініціалізацією: Naive N(0,1)
- Епоха 1/10: Loss=2.8547, Acc=11.23%
- ⚠️ Епоха 2: NaN/Inf у градієнтах, зупинка ✅ Завершено. Фінальна accuracy: 0.00% (колапс через вибухаючі градієнти)
📊 Навчання з ініціалізацією: Xavier
- Епоха 1/10: Loss=0.9234, Acc=71.45%
- Епоха 3/10: Loss=0.3521, Acc=89.67%
- Епоха 5/10: Loss=0.2847, Acc=91.52%
- Епоха 7/10: Loss=0.2456, Acc=92.38%
- Епоха 9/10: Loss=0.2187, Acc=93.14% ✅ Завершено. Фінальна accuracy: 93.28%
📊 Навчання з ініціалізацією: He (Kaiming)
- Епоха 1/10: Loss=0.7124, Acc=77.89%
- Епоха 3/10: Loss=0.2845, Acc=91.52%
- Епоха 5/10: Loss=0.2156, Acc=93.67%
- Епоха 7/10: Loss=0.1789, Acc=94.52%
- Епоха 9/10: Loss=0.1524, Acc=95.18% ✅ Завершено. Фінальна accuracy: 95.42%
🎉 Експеримент завершено!
# Візуалізація результатів
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
colors = {
'Zero': '#94a3b8',
'Naive N(0,1)': '#ef4444',
'Xavier': '#f59e0b',
'He (Kaiming)': '#22c55e'
}
# Графік 1: Loss curves
ax1 = axes[0]
for name, history in results.items():
if not any(np.isnan(history['train_loss'])):
ax1.plot(range(1, len(history['train_loss']) + 1),
history['train_loss'],
marker='o', linewidth=2.5, label=name, color=colors[name])
else:
# Для моделей з NaN показуємо лише до моменту колапсу
valid_losses = [l for l in history['train_loss'] if not np.isnan(l)]
if valid_losses:
ax1.plot(range(1, len(valid_losses) + 1), valid_losses,
marker='x', linewidth=2.5, linestyle='--',
label=f"{name} (collapsed)", color=colors[name], alpha=0.6)
ax1.set_xlabel('Епоха', fontsize=13)
ax1.set_ylabel('Train Loss', fontsize=13)
ax1.set_title('Динаміка втрат', fontsize=15, fontweight='bold')
ax1.legend(fontsize=11)
ax1.grid(alpha=0.3)
ax1.set_ylim([0, 3])
# Графік 2: Accuracy curves
ax2 = axes[1]
for name, history in results.items():
if not any(np.isnan(history['train_loss'])):
ax2.plot(range(1, len(history['train_acc']) + 1),
history['train_acc'],
marker='s', linewidth=2.5, label=name, color=colors[name])
ax2.set_xlabel('Епоха', fontsize=13)
ax2.set_ylabel('Train Accuracy (%)', fontsize=13)
ax2.set_title('Динаміка точності', fontsize=15, fontweight='bold')
ax2.legend(fontsize=11)
ax2.grid(alpha=0.3)
ax2.set_ylim([0, 100])
plt.tight_layout()
plt.show()

print("📊 Порівняльна таблиця результатів:\n")
print(f"{'Ініціалізація':<18} | {'Фінал Loss':<12} | {'Фінал Acc':<12} | {'Статус':<30}")
print("-" * 80)
for name, history in results.items():
if not any(np.isnan(history['train_loss'])):
final_loss = history['train_loss'][-1]
final_acc = history['train_acc'][-1]
status = "✅ Успішно навчилася"
else:
final_loss = float('nan')
final_acc = 0.0
status = "❌ Колапс (NaN/Inf)"
if name == 'Zero' and final_acc < 15:
status = "❌ Не навчилася (симетрія)"
print(f"{name:<18} | {final_loss if not np.isnan(final_loss) else 'NaN':<12} | "
f"{final_acc:<12.2f} | {status}")
print("\n🏆 Переможець: He (Kaiming) ініціалізація з точністю 95.42%")
📊 Порівняльна таблиця результатів:
| Ініціалізація | Фінал Loss | Фінал Acc (%) | Статус |
|---|---|---|---|
| Zero | 2.3026 | 10.12 | ❌ Не навчилася (симетрія) |
| Naive N(0,1) | NaN | 0.00 | ❌ Колапс (NaN/Inf) |
| Xavier | 0.2145 | 93.28 | ✅ Успішно навчилася |
| He (Kaiming) | 0.1487 | 95.42 | ✅ Успішно навчилася |
🏆 Переможець: He (Kaiming) ініціалізація з точністю 95.42%
❌ Zero: Провал через симетрію
💥 Naive N(0,1): Вибухаючі градієнти
⚠️ Xavier: Працює, але не оптимально
🏆 He (Kaiming): Оптимальний результат
Висновки та практичні рекомендації
Загальне правило:
- ReLU / Leaky ReLU / PReLU → He (Kaiming) initialization
nn.init.kaiming_normal_(layer.weight, mode='fan_in', nonlinearity='relu') - Tanh / Sigmoid → Xavier (Glorot) initialization
nn.init.xavier_uniform_(layer.weight) - GELU / Swish (для Transformers) → Xavier або He з меншим gain
- Bias → завжди нулі
nn.init.constant_(layer.bias, 0)
Для 95% задач: використовуйте дефолтну ініціалізацію PyTorch — вона вже оптимізована!
Діагностика проблем ініціалізації:
- Перевірте градієнти: виведіть
.grad.mean()та.grad.std()для кожного шару- Якщо градієнти < 1e-7 → vanishing gradients (зменшити std ваг або змінити активацію)
- Якщо градієнти > 100 → exploding gradients (зменшити std ваг або gradient clipping)
- Візуалізуйте активації: додайте hooks та збережіть розподіли активацій по шарах
- Якщо всі активації ≈ 0 → проблема dead ReLU (зменшити std або використати Leaky ReLU)
- Якщо активації → ∞ → вибухаючі активації (He ініціалізація занадто агресивна)
- Зменшіть learning rate до 0.001 та спробуйте знову
Короткаотповідь: НІ.
Batch Normalization нормалізує активації кожного шару, тому вплив ініціалізації ваг значно менший. Однак:
- γ (scale) параметр BN: ініціалізується одиницями (за замовчуванням)
- β (shift) параметр BN: ініціалізується нулями (за замовчуванням)
Ці дефолтні значення оптимальні у 99% випадків. У наступній статті ми детально розглянемо Batch Normalization.
Приклад: ви завантажили pretrained ResNet18 та додали свій класифікатор:
import torchvision.models as models
# Завантажити pretrained модель
model = models.resnet18(pretrained=True)
# Замінити останній FC шар
num_features = model.fc.in_features
model.fc = nn.Linear(num_features, 10) # 10 класів
# Ініціалізувати ЛИШЕ новий FC шар
nn.init.kaiming_normal_(model.fc.weight, mode='fan_in', nonlinearity='relu')
nn.init.constant_(model.fc.bias, 0)
# Pretrained шари залишаються без змін!
Важливо: НЕ застосовуйте .apply(init_fn) до всієї моделі — це перезапише pretrained ваги!
У наступній статті ми детально розглянемо Batch Normalization — революційну техніку, яка зробила ініціалізацію ваг менш критичною та дозволила навчати мережі із сотнями шарів.
Mini-batch Gradient Descent та ефективна обробка даних
Від теоретичного градієнтного спуску до практичної роботи з великими датасетами. Розуміння batch, mini-batch та stochastic підходів, оптимізація через DataLoader та вплив розміру батчу на навчання.
Batch Normalization — прорив у глибокому навчанні
Internal Covariate Shift, математика Batch Normalization, переваги та обмеження, train/eval режими, практична реалізація у PyTorch та експериментальне порівняння з базовою архітектурою.