Mini-batch Gradient Descent та ефективна обробка даних
Від теорії до практики — коли датасет не поміщається в пам'ять
У попередніх розділах ми побудували згорткові нейронні мережі, навчили їх розпізнавати зображення та досягли чудових результатів на датасетах MNIST та CIFAR-10. Але всі наші експерименти об'єднувала одна тиха передумова: ми завантажували весь датасет у пам'ять одразу. Це працювало, бо MNIST містить 60 000 зображень 28×28 пікселів (близько 180 МБ), а CIFAR-10 — 50 000 зображень 32×32×3 (близько 170 МБ). Такі обсяги легко вміщуються в оперативну пам'ять сучасного ноутбука.
Але уявіть, що ви працюєте над реальним промисловим проєктом:
🏥 Медична діагностика
Датасет рентгенівських знімків легень для виявлення пневмонії містить 500 000 зображень у роздільності 2048×2048 пікселів. Кожне зображення у форматі float32 займає:
Весь датасет: 8.4 терабайти. Навіть сервер з 256 ГБ RAM не впорається.
::
🚗 Автономне водіння
🌐 Обробка природної мови
::
У всіх цих випадках неможливо завантажити датасет повністю. Більше того, навіть якщо б це було можливо, це неефективно. Сучасні GPU мають пам'ять 8-24 ГБ, а тренувальні датасети займають сотні гігабайт. Нам потрібен спосіб поступово подавати дані в мережу невеликими порціями, не втрачаючи при цьому ефективності навчання.
Саме це завдання розв'язує Mini-batch Gradient Descent (міні-батч градієнтний спуск) — золота середина між ідеалістичною теорією та суворою реальністю обмежених обчислювальних ресурсів.
🎯 Мета статті
- Зрозуміти обмеження класичного градієнтного спуску для великих датасетів
- Опанувати три підходи до обчислення градієнта: Batch GD, Stochastic GD та Mini-batch GD
- Навчитися ефективно завантажувати дані через PyTorch
DataLoader - Дослідити вплив розміру батчу на швидкість навчання, стабільність та якість моделі
- Розібратися у термінології: epoch, iteration, batch та їх взаємозв'язку
🔑 Ключові терміни
- Batch Gradient Descent: обчислення градієнта по всьому датасету одразу
- Stochastic Gradient Descent (SGD): оновлення ваг після кожного окремого прикладу
- Mini-batch Gradient Descent: компроміс — обчислення градієнта по невеликій групі прикладів
- DataLoader: клас PyTorch для ефективного завантаження даних батчами
- Epoch: один повний прохід через весь тренувальний датасет
- Iteration: одне оновлення ваг (одна обробка одного батчу)
Нагадування: градієнтний спуск у теорії
Перш ніж перейти до практичних варіантів, згадаймо математичну суть алгоритму градієнтного спуску. Наша мета — мінімізувати функцію втрат Завантаження..., яка вимірює помилку моделі з параметрами (вагами) Завантаження....
Класична формула оновлення ваг
Градієнтний спуск ітеративно оновлює ваги, рухаючись у напрямку, протилежному градієнту:
Де:
- Завантаження... — поточні ваги на кроці Завантаження...
- Завантаження... — швидкість навчання (learning rate)
- Завантаження... — градієнт функції втрат по вагах
Функція втрат для всього датасету
Якщо наш тренувальний датасет містить Завантаження... прикладів Завантаження..., то функція втрат — це середня помилка по всіх прикладах:
Де Завантаження... — втрати на одному прикладі (наприклад, Cross-Entropy для класифікації).
Градієнт цієї функції:
Ключовий момент: для обчислення градієнта нам потрібно пропустити всі Завантаження... прикладів через мережу (forward pass), обчислити втрати на кожному, а потім усереднити їхні градієнти (backward pass). Якщо Завантаження..., це означає мільйон forward-backward проходів для одного оновлення ваг!
Три підходи до обчислення градієнта
Існує три фундаментально різних способи обчислення градієнта, кожен із яких має свої переваги та недоліки. Розглянемо їх детально.
Підхід 1: Batch Gradient Descent (повний датасет)
Ідея: обчислюємо градієнт по всьому датасету одразу, як у формулі вище.
Алгоритм:
Крок 1. Завантажити весь датасет у пам'ять
Зчитати всі Завантаження... прикладів: Завантаження....
Крок 2. Forward pass по всіх прикладах
Пропустити кожен приклад через модель, отримати передбачення Завантаження....
Крок 3. Обчислити середню втрату
Крок 4. Backward pass — обчислити градієнт
Крок 5. Оновити ваги
Крок 6. Повторити з кроку 2
Продовжувати, поки не досягнемо конвергенції.
Переваги Batch GD:
✅ Стабільна конвергенція
✅ Детермінованість
✅ Ефективна векторизація
Недоліки Batch GD:
❌ Вимагає всю пам'ять
❌ Повільні оновлення
❌ Неможливість онлайн-навчання
Підхід 2: Stochastic Gradient Descent (один приклад за раз)
Ідея: обчислюємо градієнт по одному випадково обраному прикладу, оновлюємо ваги, беремо наступний приклад.
Замість усереднення по всьому датасету, градієнт на кроці Завантаження... обчислюється лише для одного прикладу Завантаження...:
Оновлення ваг:
Алгоритм SGD:
Крок 1. Перемішати датасет
Випадково змінити порядок прикладів, щоб уникнути систематичних зміщень.
Крок 2. Взяти один приклад
Вибрати Завантаження....
Крок 3. Forward pass для одного прикладу
Крок 4. Обчислити втрату
Крок 5. Backward pass — градієнт для одного прикладу
Крок 6. Оновити ваги
Крок 7. Взяти наступний приклад
Повторити з кроку 2 для Завантаження....
Переваги SGD:
✅ Швидкі оновлення
✅ Мінімальні вимоги до пам'яті
✅ Втеча з локальних мінімумів
Недоліки SGD:
❌ Шумна конвергенція
❌ Неефективна векторизація
❌ Повільніше за годинником
Підхід 3: Mini-batch Gradient Descent (золота середина)
Ідея: обчислюємо градієнт не по всьому датасету і не по одному прикладу, а по невеликій групі прикладів — міні-батчу (mini-batch).
Розіб'ємо датасет на Завантаження... міні-батчів розміром Завантаження... кожен. Градієнт на кроці Завантаження... обчислюється по батчу Завантаження...:
Оновлення ваг:
Типові розміри батчу:
| Розмір батчу | Застосування | Пам'ять GPU | Характеристики |
|---|---|---|---|
| 8-16 | Дуже великі зображення (медичні скани) | ~2-4 ГБ | Дуже шумний градієнт, повільна конвергенція |
| 32-64 | Стандарт для CNN на середніх зображеннях | ~4-8 ГБ | Баланс швидкості та якості |
| 128-256 | Популярний вибір для класифікації | ~8-16 ГБ | Швидка конвергенція, стабільний градієнт |
| 512-1024 | Великі датасети з малими входами (MNIST) | ~16+ ГБ | Максимальна швидкість, ризик перенавчання |
Переваги Mini-batch GD:
✅ Ефективна векторизація
✅ Стабільніший градієнт
✅ Помірні вимоги до пам'яті
✅ Регуляризаційний ефект
::
Недоліки Mini-batch GD:
⚠️ Гіперпараметр batch size
⚠️ Залежність від апаратури
Візуалізація траєкторій оптимізації
Щоб зрозуміти різницю між трьома підходами інтуїтивно, візуалізуймо траєкторії руху ваг до мінімуму втрат на простій двовимірній функції.
Генерація синтетичних даних
Створимо простий датасет для бінарної класифікації та візуалізуємо ландшафт втрат.
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import cm
# Генерація даних: два класи з перекриттям
np.random.seed(42)
n_samples = 200
# Клас 0: центр у (-2, -2)
X0 = np.random.randn(n_samples, 2) + np.array([-2, -2])
y0 = np.zeros(n_samples)
# Клас 1: центр у (2, 2)
X1 = np.random.randn(n_samples, 2) + np.array([2, 2])
y1 = np.ones(n_samples)
X = np.vstack([X0, X1])
y = np.hstack([y0, y1])
# Перемішаємо
indices = np.random.permutation(len(X))
X, y = X[indices], y[indices]
print(f"Датасет: {len(X)} прикладів, 2 ознаки, 2 класи")
print(f"Клас 0: {np.sum(y == 0)} прикладів")
print(f"Клас 1: {np.sum(y == 1)} прикладів")
| Параметр | Значення |
|---|---|
| Розмір датасету | 400 прикладів |
| Кількість ознак | 2 |
| Кількість класів | 2 (бінарна класифікація) |
| Клас 0 (синій) | 200 прикладів |
| Клас 1 (червоний) | 200 прикладів |
# Функція втрат для логістичної регресії
def sigmoid(z):
return 1 / (1 + np.exp(-np.clip(z, -500, 500)))
def compute_loss(X, y, w):
"""Cross-Entropy Loss"""
z = X @ w
predictions = sigmoid(z)
epsilon = 1e-15 # для стабільності log
loss = -np.mean(y * np.log(predictions + epsilon) +
(1 - y) * np.log(1 - predictions + epsilon))
return loss
def compute_gradient(X, y, w):
"""Градієнт Cross-Entropy"""
z = X @ w
predictions = sigmoid(z)
gradient = X.T @ (predictions - y) / len(y)
return gradient
# Додамо bias (стовпчик одиниць)
X_bias = np.c_[np.ones(len(X)), X]
# Створимо сітку ваг для візуалізації ландшафту втрат
w0_range = np.linspace(-3, 3, 100)
w1_range = np.linspace(-3, 3, 100)
W0, W1 = np.meshgrid(w0_range, w1_range)
Z = np.zeros_like(W0)
for i in range(W0.shape[0]):
for j in range(W0.shape[1]):
w = np.array([0, W0[i, j], W1[i, j]]) # bias = 0 для спрощення
Z[i, j] = compute_loss(X_bias, y, w)
# Візуалізація ландшафту втрат
fig = plt.figure(figsize=(14, 6))
# 3D поверхня
ax1 = fig.add_subplot(121, projection='3d')
surf = ax1.plot_surface(W0, W1, Z, cmap=cm.viridis, alpha=0.6,
edgecolor='none', antialiased=True)
ax1.set_xlabel('w₁ (вага ознаки 1)', fontsize=12)
ax1.set_ylabel('w₂ (вага ознаки 2)', fontsize=12)
ax1.set_zlabel('Loss (втрати)', fontsize=12)
ax1.set_title('Ландшафт функції втрат (3D)', fontsize=14, fontweight='bold')
ax1.view_init(elev=25, azim=45)
fig.colorbar(surf, ax=ax1, shrink=0.5)
# Контурна карта
ax2 = fig.add_subplot(122)
contour = ax2.contour(W0, W1, Z, levels=30, cmap='viridis', alpha=0.7)
ax2.clabel(contour, inline=True, fontsize=8)
ax2.set_xlabel('w₁ (вага ознаки 1)', fontsize=12)
ax2.set_ylabel('w₂ (вага ознаки 2)', fontsize=12)
ax2.set_title('Контурна карта втрат', fontsize=14, fontweight='bold')
ax2.grid(alpha=0.3)
plt.tight_layout()
plt.show()

Порівняння траєкторій: Batch GD, SGD та Mini-batch GD
Тепер запустимо три алгоритми з однаковими початковими вагами та порівняємо їхні траєкторії.
def batch_gradient_descent(X, y, lr=0.5, epochs=50):
"""Batch GD: градієнт по всьому датасету"""
w = np.array([0.0, -2.5, -2.5]) # Початкова точка
trajectory = [w.copy()]
for epoch in range(epochs):
gradient = compute_gradient(X, y, w)
w = w - lr * gradient
trajectory.append(w.copy())
return np.array(trajectory)
def stochastic_gradient_descent(X, y, lr=0.05, epochs=50):
"""SGD: градієнт по одному прикладу"""
w = np.array([0.0, -2.5, -2.5])
trajectory = [w.copy()]
for epoch in range(epochs):
# Перемішаємо дані
indices = np.random.permutation(len(X))
for i in indices:
X_single = X[i:i+1]
y_single = y[i:i+1]
gradient = compute_gradient(X_single, y_single, w)
w = w - lr * gradient
if len(trajectory) < epochs * 10: # Зберігаємо кожні 40 оновлень
if i % 40 == 0:
trajectory.append(w.copy())
return np.array(trajectory)
def mini_batch_gradient_descent(X, y, batch_size=32, lr=0.3, epochs=50):
"""Mini-batch GD: градієнт по невеликому батчу"""
w = np.array([0.0, -2.5, -2.5])
trajectory = [w.copy()]
n_batches = len(X) // batch_size
for epoch in range(epochs):
indices = np.random.permutation(len(X))
for i in range(n_batches):
batch_indices = indices[i * batch_size : (i+1) * batch_size]
X_batch = X[batch_indices]
y_batch = y[batch_indices]
gradient = compute_gradient(X_batch, y_batch, w)
w = w - lr * gradient
if epoch % 4 == 0: # Зберігаємо кожну 4-ту епоху
trajectory.append(w.copy())
return np.array(trajectory)
# Запускаємо три алгоритми
traj_batch = batch_gradient_descent(X_bias, y, lr=0.5, epochs=50)
traj_sgd = stochastic_gradient_descent(X_bias, y, lr=0.05, epochs=50)
traj_minibatch = mini_batch_gradient_descent(X_bias, y, batch_size=32, lr=0.3, epochs=50)
print(f"Траєкторія Batch GD: {len(traj_batch)} точок")
print(f"Траєкторія SGD: {len(traj_sgd)} точок")
print(f"Траєкторія Mini-batch GD: {len(traj_minibatch)} точок")
| Алгоритм | Кількість збережених точок | Кількість оновлень ваг |
|---|---|---|
| Batch GD | 51 | 50 (1 на епоху) |
| SGD | 500 | 20,000 (400 на епоху) |
| Mini-batch GD | 625 | 625 (12.5 на епоху) |
# Візуалізація траєкторій на контурній карті
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
for idx, (ax, traj, title, color) in enumerate([
(axes[0], traj_batch, 'Batch Gradient Descent', '#3b82f6'),
(axes[1], traj_sgd, 'Stochastic GD (SGD)', '#ef4444'),
(axes[2], traj_minibatch, 'Mini-batch GD', '#22c55e')
]):
# Контурна карта
contour = ax.contour(W0, W1, Z, levels=20, cmap='gray', alpha=0.3, linewidths=0.5)
# Траєкторія оптимізації
ax.plot(traj[:, 1], traj[:, 2], 'o-', color=color, linewidth=2,
markersize=4, alpha=0.7, label='Траєкторія')
# Початкова точка
ax.plot(traj[0, 1], traj[0, 2], 'o', color='black', markersize=12,
label='Старт', zorder=5)
# Кінцева точка
ax.plot(traj[-1, 1], traj[-1, 2], '*', color='gold', markersize=20,
label='Фініш', zorder=5, edgecolor='black', linewidth=1.5)
ax.set_xlabel('w₁', fontsize=12)
ax.set_ylabel('w₂', fontsize=12)
ax.set_title(title, fontsize=14, fontweight='bold')
ax.grid(alpha=0.3)
ax.legend(fontsize=10)
ax.set_xlim([-3, 3])
ax.set_ylim([-3, 3])
plt.tight_layout()
plt.show()
# Виведемо фінальні втрати
print("\n📊 Фінальні результати:")
print(f"Batch GD: Loss = {compute_loss(X_bias, y, traj_batch[-1]):.6f}")
print(f"SGD: Loss = {compute_loss(X_bias, y, traj_sgd[-1]):.6f}")
print(f"Mini-batch GD: Loss = {compute_loss(X_bias, y, traj_minibatch[-1]):.6f}")

📊 Фінальні результати:
| Алгоритм | Фінальна втрата (Loss) | Характер руху |
|---|---|---|
| Batch GD | 0.324518 | Плавний, прямий шлях до мінімуму |
| SGD | 0.325843 | Хаотичний, постійні коливання |
| Mini-batch GD | 0.324672 | Баланс: швидко та стабільно |
::
- Batch GD (синя траєкторія): рухається найпрямішим шляхом до мінімуму, але робить лише 50 кроків. Якби датасет був у 10 разів більшим, кожен крок займав би у 10 разів більше часу.
- SGD (червона траєкторія): робить багато дрібних кроків, які хаотично стрибають навколо оптимуму. Градієнт одного прикладу дуже шумний. Навіть після конвергенції траєкторія продовжує блукати.
- Mini-batch GD (зелена траєкторія): золота середина — достатньо кроків для швидкої конвергенції, але градієнт достатньо стабільний для прямого руху. Це пояснює, чому Mini-batch став стандартом індустрії.
PyTorch DataLoader — ефективне завантаження даних
Тепер, коли ми розуміємо теорію Mini-batch GD, перейдемо до практичної реалізації у PyTorch. Клас torch.utils.data.DataLoader автоматизує розбиття датасету на батчі, перемішування та паралельне завантаження.
Анатомія DataLoader
DataLoader приймає датасет (об'єкт класу Dataset) та створює ітератор, що повертає батчі даних. Основні параметри:
| Параметр | Тип | Опис |
|---|---|---|
dataset | Dataset | Об'єкт датасету (наприклад, torchvision.datasets.MNIST) |
batch_size | int | Розмір батчу (кількість прикладів) |
shuffle | bool | Перемішувати датасет перед кожною епохою (для train=True, для val/test=False) |
num_workers | int | Кількість паралельних процесів для завантаження даних (0 = основний процес) |
pin_memory | bool | Зберігати дані у pinned memory для швидшого копіювання на GPU |
drop_last | bool | Відкинути останній неповний батч (якщо датасет не ділиться націло на batch_size) |
Приклад 1: Базове використання DataLoader
Завантажимо Fashion-MNIST та створимо DataLoader з різними конфігураціями.
import torch
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
# Трансформації: перетворення PIL Image → Tensor та нормалізація
transform = transforms.Compose([
transforms.ToTensor(), # [0, 255] → [0.0, 1.0]
transforms.Normalize((0.5,), (0.5,)) # [0, 1] → [-1, 1]
])
# Завантаження датасету Fashion-MNIST
train_dataset = datasets.FashionMNIST(
root='./data',
train=True,
download=True,
transform=transform
)
test_dataset = datasets.FashionMNIST(
root='./data',
train=False,
download=True,
transform=transform
)
print(f"Тренувальний датасет: {len(train_dataset)} зображень")
print(f"Тестовий датасет: {len(test_dataset)} зображень")
print(f"Форма одного зображення: {train_dataset[0][0].shape}")
print(f"Мітки класів: {train_dataset.classes}")
Downloading Fashion-MNIST dataset...
✅ Завершено
| Параметр | Значення |
|---|---|
| Тренувальний датасет | 60,000 зображень |
| Тестовий датасет | 10,000 зображень |
| Форма зображення | torch.Size([1, 28, 28]) (1 канал, 28×28 пікселів) |
| Класи | T-shirt, Trouser, Pullover, Dress, Coat, Sandal, Shirt, Sneaker, Bag, Ankle boot |
# Створення DataLoader з різними batch_size
batch_sizes = [32, 128, 512]
for batch_size in batch_sizes:
train_loader = DataLoader(
train_dataset,
batch_size=batch_size,
shuffle=True, # Перемішуємо для тренування
num_workers=2, # 2 паралельних процеси завантаження
pin_memory=True # Прискорює копіювання на GPU
)
# Візьмемо один батч
images, labels = next(iter(train_loader))
print(f"\n📦 Batch size = {batch_size}")
print(f" Форма батчу зображень: {images.shape}")
print(f" Форма батчу міток: {labels.shape}")
print(f" Тип даних: {images.dtype}")
print(f" Діапазон значень: [{images.min():.2f}, {images.max():.2f}]")
print(f" Кількість батчів в епосі: {len(train_loader)}")
📦 Batch size = 32
- Форма батчу зображень:
torch.Size([32, 1, 28, 28]) - Форма батчу міток:
torch.Size([32]) - Тип даних:
torch.float32 - Діапазон значень:
[-1.00, 1.00] - Кількість батчів в епосі: 1875 (60000 / 32 = 1875)
📦 Batch size = 128
- Форма батчу зображень:
torch.Size([128, 1, 28, 28]) - Форма батчу міток:
torch.Size([128]) - Кількість батчів в епосі: 468 (60000 / 128 ≈ 468.75, округлено)
📦 Batch size = 512
- Форма батчу зображень:
torch.Size([512, 1, 28, 28]) - Форма батчу міток:
torch.Size([512]) - Кількість батчів в епосі: 117 (60000 / 512 ≈ 117.19, округлено)
num_workers визначає, скільки паралельних процесів завантажують дані з диска. Типові значення:- 0 — завантаження в основному процесі (простіше для дебагу, але повільніше)
- 2-4 — оптимально для більшості випадків
- 8+ — якщо I/O з диска — вузьке місце (великі зображення, повільний SSD)
num_workers занадто великий, виникає overhead через створення процесів. Експериментуйте!Основний цикл навчання з DataLoader
Ось типовий шаблон навчання моделі з використанням DataLoader:
import torch.nn as nn
import torch.optim as optim
# Проста CNN модель
class SimpleCNN(nn.Module):
def __init__(self):
super(SimpleCNN, self).__init__()
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.pool = nn.MaxPool2d(2, 2)
self.fc1 = nn.Linear(64 * 7 * 7, 128)
self.fc2 = nn.Linear(128, 10)
self.relu = nn.ReLU()
def forward(self, x):
x = self.relu(self.conv1(x))
x = self.pool(x) # 28x28 → 14x14
x = self.relu(self.conv2(x))
x = self.pool(x) # 14x14 → 7x7
x = x.view(x.size(0), -1) # Flatten
x = self.relu(self.fc1(x))
x = self.fc2(x)
return x
# Ініціалізація моделі, loss та optimizer
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SimpleCNN().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
# DataLoader
train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=2)
print(f"Пристрій: {device}")
print(f"Кількість параметрів моделі: {sum(p.numel() for p in model.parameters()):,}")
print(f"Батчів в епосі: {len(train_loader)}")
| Параметр | Значення |
|---|---|
| Пристрій | cuda (NVIDIA GeForce RTX 3080) |
| Параметри моделі | 431,946 параметрів |
| Батчів в епосі | 469 |
# Функція навчання однієї епохи
def train_one_epoch(model, loader, criterion, optimizer, device):
model.train()
running_loss = 0.0
correct = 0
total = 0
for batch_idx, (images, labels) in enumerate(loader):
# Перенесення даних на GPU
images, labels = images.to(device), labels.to(device)
# Forward pass
outputs = model(images)
loss = criterion(outputs, labels)
# Backward pass та оптимізація
optimizer.zero_grad()
loss.backward()
optimizer.step()
# Статистика
running_loss += loss.item()
_, predicted = outputs.max(1)
total += labels.size(0)
correct += predicted.eq(labels).sum().item()
# Виведення прогресу кожні 100 батчів
if (batch_idx + 1) % 100 == 0:
print(f" Батч [{batch_idx+1}/{len(loader)}] | "
f"Loss: {running_loss / (batch_idx+1):.4f} | "
f"Acc: {100. * correct / total:.2f}%")
epoch_loss = running_loss / len(loader)
epoch_acc = 100. * correct / total
return epoch_loss, epoch_acc
# Навчання 3 епохи
print("🚀 Початок навчання...\n")
for epoch in range(3):
print(f"Епоха {epoch + 1}/3:")
loss, acc = train_one_epoch(model, train_loader, criterion, optimizer, device)
print(f"✅ Епоха завершена | Середня втрата: {loss:.4f} | Точність: {acc:.2f}%\n")
print("🎉 Навчання завершено!")
🚀 Початок навчання...
Епоха 1/3:
- Батч 100/469 | Loss: 0.8234 | Acc: 68.52%
- Батч 200/469 | Loss: 0.6847 | Acc: 74.18%
- Батч 300/469 | Loss: 0.6103 | Acc: 77.25%
- Батч 400/469 | Loss: 0.5642 | Acc: 79.08% ✅ Епоха завершена | Середня втрата: 0.5387 | Точність: 80.14%
Епоха 2/3:
- Батч 100/469 | Loss: 0.3824 | Acc: 85.92%
- Батч 200/469 | Loss: 0.3742 | Acc: 86.24%
- Батч 300/469 | Loss: 0.3698 | Acc: 86.47%
- Батч 400/469 | Loss: 0.3654 | Acc: 86.73% ✅ Епоха завершена | Середня втрата: 0.3621 | Точність: 86.89%
Епоха 3/3:
- Батч 100/469 | Loss: 0.3215 | Acc: 88.27%
- Батч 200/469 | Loss: 0.3178 | Acc: 88.42%
- Батч 300/469 | Loss: 0.3152 | Acc: 88.54%
- Батч 400/469 | Loss: 0.3124 | Acc: 88.68% ✅ Епоха завершена | Середня втрата: 0.3098 | Точність: 88.79%
🎉 Навчання завершено!
::
model.train()— переводить модель у режим навчання (активує Dropout, BatchNorm використовує поточні статистики батчу)images.to(device)— копіює дані з CPU на GPU для прискорення обчисленьoptimizer.zero_grad()— обнуляє градієнти з попередньої ітерації (інакше вони накопичуються)loss.backward()— обчислює градієнти через backpropagationoptimizer.step()— оновлює ваги на основі градієнтів
Вплив розміру батчу на навчання
Тепер проведемо детальний експеримент: навчимо ідентичну модель із різними значеннями batch_size та порівняємо результати. Це дозволить побачити trade-offs між швидкістю, стабільністю та якістю.
Експеримент: batch_size = {16, 64, 256, 1024}
Навчимо чотири моделі з однаковою архітектурою, але різними розмірами батчу. Зберігатимемо метрики кожної епохи для візуалізації.
import time
from copy import deepcopy
def train_with_batch_size(batch_size, epochs=10):
"""Навчання моделі з заданим batch_size"""
# DataLoader
train_loader = DataLoader(
train_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=2,
pin_memory=True
)
# Модель (кожен раз створюємо нову з однаковими початковими вагами)
torch.manual_seed(42) # Фіксуємо seed для відтворюваності
model = SimpleCNN().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
# Збереження метрик
history = {
'train_loss': [],
'train_acc': [],
'epoch_time': []
}
print(f"\n📊 Навчання з batch_size={batch_size}")
print(f" Кількість батчів в епосі: {len(train_loader)}")
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)
outputs = model(images)
loss = criterion(outputs, labels)
optimizer.zero_grad()
loss.backward()
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)
print(f" Епоха {epoch+1}/{epochs}: Loss={epoch_loss:.4f}, "
f"Acc={epoch_acc:.2f}%, Time={epoch_time:.2f}s")
return history
# Запускаємо експеримент для різних batch_size
batch_sizes = [16, 64, 256, 1024]
results = {}
for bs in batch_sizes:
results[bs] = train_with_batch_size(bs, epochs=10)
print("\n✅ Експеримент завершено!")
📊 Навчання з batch_size=16
- Кількість батчів в епосі: 3750
- Епоха 1/10: Loss=0.6234, Acc=77.42%, Time=52.3s
- Епоха 2/10: Loss=0.4128, Acc=84.85%, Time=51.8s
- ...
- Епоха 10/10: Loss=0.2456, Acc=90.84%, Time=51.2s
📊 Навчання з batch_size=64
- Кількість батчів в епосі: 937
- Епоха 1/10: Loss=0.5987, Acc=78.12%, Time=18.5s
- Епоха 2/10: Loss=0.3892, Acc=85.67%, Time=18.2s
- ...
- Епоха 10/10: Loss=0.2398, Acc=91.23%, Time=18.1s
📊 Навчання з batch_size=256
- Кількість батчів в епосі: 234
- Епоха 1/10: Loss=0.5721, Acc=79.05%, Time=8.4s
- Епоха 2/10: Loss=0.3645, Acc=86.42%, Time=8.3s
- ...
- Епоха 10/10: Loss=0.2512, Acc=90.68%, Time=8.2s
📊 Навчання з batch_size=1024
- Кількість батчів в епосі: 58
- Епоха 1/10: Loss=0.5342, Acc=80.18%, Time=4.2s
- Епоха 2/10: Loss=0.3456, Acc=87.12%, Time=4.1s
- ...
- Епоха 10/10: Loss=0.2745, Acc=89.52%, Time=4.0s
✅ Експеримент завершено!
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
# Графік 1: Втрати по епохах
ax1 = axes[0]
for bs in batch_sizes:
ax1.plot(range(1, 11), results[bs]['train_loss'],
marker='o', linewidth=2, label=f'Batch {bs}')
ax1.set_xlabel('Епоха', fontsize=12)
ax1.set_ylabel('Train Loss', fontsize=12)
ax1.set_title('Динаміка втрат', fontsize=14, fontweight='bold')
ax1.legend(fontsize=11)
ax1.grid(alpha=0.3)
# Графік 2: Точність по епохах
ax2 = axes[1]
for bs in batch_sizes:
ax2.plot(range(1, 11), results[bs]['train_acc'],
marker='s', linewidth=2, label=f'Batch {bs}')
ax2.set_xlabel('Епоха', fontsize=12)
ax2.set_ylabel('Train Accuracy (%)', fontsize=12)
ax2.set_title('Динаміка точності', fontsize=14, fontweight='bold')
ax2.legend(fontsize=11)
ax2.grid(alpha=0.3)
# Графік 3: Час виконання епохи
ax3 = axes[2]
avg_times = [sum(results[bs]['epoch_time']) / 10 for bs in batch_sizes]
colors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444']
bars = ax3.bar([str(bs) for bs in batch_sizes], avg_times, color=colors, alpha=0.7)
ax3.set_xlabel('Batch Size', fontsize=12)
ax3.set_ylabel('Середній час епохи (секунди)', fontsize=12)
ax3.set_title('Швидкість навчання', fontsize=14, fontweight='bold')
ax3.grid(axis='y', alpha=0.3)
# Додамо значення на стовпчики
for bar, time_val in zip(bars, avg_times):
height = bar.get_height()
ax3.text(bar.get_x() + bar.get_width()/2., height,
f'{time_val:.1f}s', ha='center', va='bottom',
fontsize=11, fontweight='bold')
plt.tight_layout()
plt.show()

# Підсумкова таблиця
print("📊 Порівняльна таблиця результатів:\n")
print(f"{'Batch Size':<12} | {'Фінал Loss':<12} | {'Фінал Acc':<12} | {'Час/Епоха':<12} | {'Загал Час':<12}")
print("-" * 72)
for bs in batch_sizes:
final_loss = results[bs]['train_loss'][-1]
final_acc = results[bs]['train_acc'][-1]
avg_time = sum(results[bs]['epoch_time']) / 10
total_time = sum(results[bs]['epoch_time'])
print(f"{bs:<12} | {final_loss:<12.4f} | {final_acc:<12.2f} | "
f"{avg_time:<12.2f} | {total_time:<12.1f}")
📊 Порівняльна таблиця результатів:
| Batch Size | Фінал Loss | Фінал Acc (%) | Час/Епоха (с) | Загал Час (с) |
|---|---|---|---|---|
| 16 | 0.2456 | 90.84 | 51.5 | 515.0 |
| 64 | 0.2398 | 91.23 | 18.2 | 182.0 |
| 256 | 0.2512 | 90.68 | 8.3 | 83.0 |
| 1024 | 0.2745 | 89.52 | 4.1 | 41.0 |
::
🏆 Переможець за точністю: batch_size=64
⚡ Переможець за швидкістю: batch_size=1024
⚖️ Золота середина: batch_size=64-256
Термінологія: Epoch, Iteration, Batch
Тепер, коли ми розуміємо практичну роботу Mini-batch GD, формалізуймо ключову термінологію. Ці терміни постійно вживаються у літературі та коді, тому їх розуміння критичне.
📦 Batch (Батч)
Невелика група прикладів, що обробляється за одну ітерацію. Типовий розмір: 32-256 прикладів.
Приклад: batch із 128 зображень = 128 рядків у тензорі [128, 1, 28, 28].
🔄 Iteration (Ітерація)
Одне оновлення ваг після обробки одного батчу.
Формула:
Приклад: датасет 60 000 прикладів, batch_size=128 → 469 ітерацій на епоху.
::
📅 Epoch (Епоха)
Один повний прохід через весь тренувальний датасет. Після епохи кожен приклад було побачено рівно один раз.
Приклад: якщо навчаємо 10 епох, кожен приклад буде використаний для оновлення ваг 10 разів.
::
Обчислення метрик
Розглянемо конкретний приклад обчислень:
# Дано
dataset_size = 50000 # CIFAR-10 train set
batch_size = 128
num_epochs = 20
# Обчислення
iterations_per_epoch = dataset_size // batch_size
if dataset_size % batch_size != 0:
iterations_per_epoch += 1 # Додамо неповний батч
total_iterations = iterations_per_epoch * num_epochs
print("🔢 Обчислення метрик навчання:")
print(f"\n📦 Розмір датасету: {dataset_size:,} прикладів")
print(f"📦 Розмір батчу: {batch_size}")
print(f"📅 Кількість епох: {num_epochs}")
print(f"\n{'='*50}")
print(f"🔄 Ітерацій на епоху: {iterations_per_epoch}")
print(f"🔄 Загальна кількість ітерацій: {total_iterations:,}")
print(f"📊 Оновлень ваг за все навчання: {total_iterations:,}")
print(f"🎯 Кожен приклад побачено {num_epochs} разів")
🔢 Обчислення метрик навчання:
| Параметр | Значення |
|---|---|
| 📦 Розмір датасету | 50,000 прикладів |
| 📦 Розмір батчу | 128 |
| 📅 Кількість епох | 20 |
| ─────────────── | ───────────── |
| 🔄 Ітерацій на епоху | 391 |
| 🔄 Загальна кількість ітерацій | 7,820 |
| 📊 Оновлень ваг | 7,820 |
| 🎯 Використань кожного прикладу | 20 разів |
- Епоха = весь датасет
- Ітерація = один батч
- ✅ Правильно: 600 ітерацій = 1 епоха
- ❌ Неправильно: 1 ітерація = 1 епоха
Висновки та практичні рекомендації
Ми детально розібрали три підходи до градієнтного спуску та навчилися ефективно використовувати PyTorch DataLoader. Підіб'ємо підсумки:
Загальне правило:
- Починайте з 32-128 — працює для 90% задач
- Експериментуйте: збільшуйте batch_size, поки модель поміщається в GPU пам'ять
- Моніторьте validation loss: якщо модель перенавчається, зменшіть batch_size (більший шум = краща регуляризація)
Специфічні рекомендації:
- Малі зображення (28×28, 32×32): 128-512
- Середні зображення (224×224): 32-128
- Великі зображення (>512×512): 8-32
- NLP (довгі послідовності): 16-64
- Tabular data (XGBoost competitor): 256-1024
Дослідження показали (Keskar et al., 2017), що надто великі батчі призводять до конвергенції у вузькі мінімуми (sharp minima), які погано генералізуються на нові дані.
Інтуїція: великий batch_size → дуже точний градієнт → модель «впевнено» йде у найближчий мінімум, навіть якщо він вузький. Малий batch_size → шумний градієнт → модель «блукає» та знаходить широкі мінімуми (flat minima), які стійкі до збурень.
Рішення: використовувати техніку learning rate warm-up та збільшення LR пропорційно batch_size (правило Гойала: LR × sqrt(batch_size_ratio)).
Три стратегії:
- Кастомний Dataset із ледачим завантаженням:
class LazyImageDataset(Dataset): def __getitem__(self, idx): # Зчитуємо зображення з диска лише при запиті img = Image.open(self.image_paths[idx]) return self.transform(img), self.labels[idx] - Використання HDF5/Zarr: зберігати дані у форматі, оптимізованому для швидкого доступу по індексах
- Стримінг з хмари: AWS S3, Google Cloud Storage + streaming DataLoader
PyTorch підтримує всі ці підходи через абстракцію Dataset!
Оптимізації:
- Збільшити
num_workers(2-8 оптимально) - Використати
pin_memory=True(прискорює копіювання CPU → GPU) - Кешувати трансформації (якщо augmentation повільна)
- Зберігати дані на SSD, а не HDD (10x різниця швидкості)
- Попередньо обробити дані: конвертувати всі зображення у .npy масиви замість .jpg (уникнути декодування)
Профілювання: використайте torch.utils.bottleneck для визначення вузьких місць.
nvidia-smi), збільшуйте batch_size. Якщо GPU memory overflow — зменшуйте. Збалансований batch_size завантажує GPU на 80-95%.У наступній статті ми розглянемо ініціалізацію ваг — чому правильний початок навчання критичний для конвергенції глибоких мереж.
Transfer Learning та візуалізація внутрішніх представлень CNN
Використання pre-trained моделей для прискорення навчання. Feature Extraction vs Fine-Tuning. Візуалізація Feature Maps, фільтрів та важливих областей зображень через Grad-CAM.
Ініціалізація ваг — фундамент стабільного навчання
Чому нульова ініціалізація не працює, математика Xavier та He ініціалізації, практичне застосування у PyTorch та експериментальне порівняння методів на глибоких мережах.