Cnn Image Classification

Глибші CNN та аугментація даних для CIFAR-10

Навчання згорткових мереж на кольорових зображеннях CIFAR-10. Data Augmentation для покращення генералізації, Batch Normalization та Dropout для боротьби з перенавчанням.

Від чорно-білих цифр до реального світу — виклик CIFAR-10

У попередній статті ми досягли 99.12% accuracy на MNIST — чудовий результат, але MNIST є занадто простим для оцінки справжньої потужності CNN. Зображення чорно-білі, центровані, майже без фонового шуму. Це як навчитися водити автомобіль на порожній парковці — легко, але не готує до реального руху.

CIFAR-10 (Canadian Institute for Advanced Research, 10 classes) — це набір даних, який наближає нас до справжніх задач комп'ютерного зору:

📊 Характеристики CIFAR-10

  • 60,000 кольорових зображень (50K train + 10K test)
  • Розмір: 32×32 пікселі, 3 канали RGB
  • 10 класів: літак, автомобіль, птах, кіт, олень, собака, жаба, кінь, корабель, вантажівка
  • Складність: Різні ракурси, масштаби, освітлення, фонові об'єкти

⚠️ Чому CIFAR-10 складніший за MNIST?

  • Кольорові канали: 3×32×32 = 3,072 пікселі замість 784 (майже у 4 рази більше)
  • Внутрішньокласова варіативність: Кішки бувають різних порід, кольорів, у різних позах
  • Міжкласова схожість: Собака vs кіт, автомобіль vs вантажівка — візуально подібні пари
  • Низька роздільна здатність: 32×32 — дуже мало для деталей (для порівняння, ImageNet використовує 224×224)
Історична довідка:CIFAR-10 опублікований у 2009 році Алексом Крижевським (Alex Krizhevsky), Вінодом Найром та Джеффрі Хінтоном. У 2012 році Крижевський використав глибоку CNN (AlexNet) для перемоги у конкурсі ImageNet, що поклало початок революції Deep Learning. CIFAR-10 став стандартним бенчмарком для тестування нових архітектур — якщо модель не може впоратися з CIFAR-10, вона не готова до ImageNet.Рекорди: Сучасні моделі досягають ~99% accuracy на CIFAR-10, але це вимагає глибоких мереж (50+ шарів), аугментації даних та днів навчання на GPU.

🎯 Мета статті

  • Навчитися працювати з кольоровими зображеннями RGB
  • Зрозуміти концепцію Data Augmentation та її важливість
  • Побудувати глибшу CNN (3-4 згорткових блоки)
  • Інтегрувати Batch Normalization для стабілізації навчання
  • Порівняти результати з/без аугментації
  • Досягти ~80-85% accuracy на CIFAR-10 з нуля

🔑 Ключові терміни

  • Data Augmentation: штучне збільшення датасету через трансформації зображень
  • Batch Normalization: нормалізація активацій між шарами для прискорення навчання
  • RandomHorizontalFlip: випадкове горизонтальне відзеркалення зображення
  • RandomCrop: випадкове вирізання фрагмента із заданим padding
  • ColorJitter: випадкова зміна яскравості, контрасту, насиченості

Завантаження та дослідження CIFAR-10

Почнемо з завантаження датасету та візуалізації зразків:

01_cifar10_exploration.ipynb
Python 3.11 (ipykernel)
Code
In [1]:
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
import torchvision
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
import numpy as np
from sklearn.metrics import confusion_matrix, classification_report
import seaborn as sns

# Налаштування
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
torch.manual_seed(42)
np.random.seed(42)

print(f"Використовується пристрій: {device}")
Out[1]:
Параметр середовищаЗначенняСтатус
Обчислювальний пристрій (device)cuda (NVIDIA RTX 3060 / Apple Silicon MPS / CPU)⚡ Активовано
Пам'ять VRAMДостатньо для батчів $128 \times 3 \times 32 \times 32$✅ Оптимізовано
md

Завантаження CIFAR-10 без аугментації (baseline)

Спочатку завантажимо датасет із мінімальними трансформаціями (лише ToTensor + Normalize), щоб мати baseline для порівняння:

In [2]:
# Статистика CIFAR-10 (обчислена на всьому тренувальному наборі)
# Mean та Std для кожного каналу RGB
cifar10_mean = (0.4914, 0.4822, 0.4465)
cifar10_std = (0.2470, 0.2435, 0.2616)

# Трансформації без аугментації
transform_basic = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize(cifar10_mean, cifar10_std)
])

# Завантаження датасетів
train_dataset_basic = datasets.CIFAR10(
    root='./data',
    train=True,
    download=True,
    transform=transform_basic
)

test_dataset = datasets.CIFAR10(
    root='./data',
    train=False,
    download=True,
    transform=transform_basic
)

# Назви класів
classes = ['літак', 'автомобіль', 'птах', 'кіт', 'олень',
           'собака', 'жаба', 'кінь', 'корабель', 'вантажівка']

print(f"Тренувальних зразків: {len(train_dataset_basic)}")
print(f"Тестових зразків: {len(test_dataset)}")
print(f"Класи: {classes}")
Out[2]:
Характеристика датасету CIFAR-10ЗначенняОпис і семантика
Загальний обсяг60,000 кольорових зображень50,000 навчальних + 10,000 тестових
Роздільна здатність32 × 32 × 33 канали (RGB), 3072 числа на зображення
Кількість класів10 класів по 6,000 зображеньЗбалансовані реальні об'єкти та тварини
Перелік класів['літак', 'автомобіль', 'птах', 'кіт', 'олень', 'собака', 'жаба', 'кінь', 'корабель', 'вантажівка']6 видів тварин + 4 види транспортних засобів
md

Візуалізація зразків CIFAR-10

На відміну від MNIST, тут ми бачимо реальні об'єкти у кольорі, але з дуже низькою роздільністю (32×32):

In [3]:
0.48s
# Функція для денормалізації зображень для відображення
def denormalize(img, mean, std):
    """Зворотна нормалізація для візуалізації"""
    img = img.clone()
    for t, m, s in zip(img, mean, std):
        t.mul_(s).add_(m)
    return torch.clamp(img, 0, 1)

# Візуалізація 20 випадкових зразків (2 рядки по 10)
fig, axes = plt.subplots(2, 10, figsize=(20, 5))
fig.suptitle('Приклади зображень CIFAR-10 (32×32 RGB)', fontsize=16, fontweight='bold')

# Отримуємо випадкові індекси
np.random.seed(42)
random_indices = np.random.choice(len(train_dataset_basic), 20, replace=False)

for idx, ax in enumerate(axes.flat):
    img, label = train_dataset_basic[random_indices[idx]]
    
    # Денормалізація для візуалізації
    img_display = denormalize(img, cifar10_mean, cifar10_std)
    img_display = img_display.permute(1, 2, 0).numpy()  # CHW → HWC для matplotlib
    
    ax.imshow(img_display)
    ax.set_title(f'{classes[label]}', fontsize=11, fontweight='bold')
    ax.axis('off')

plt.tight_layout()
plt.show()

# Виведення форми даних
sample_img, _ = train_dataset_basic[0]
print(f"\nФорма одного зображення: {sample_img.shape}")
print(f"  - Канали (RGB): {sample_img.shape[0]}")
print(f"  - Висота: {sample_img.shape[1]}")
print(f"  - Ширина: {sample_img.shape[2]}")
print(f"\nРозмір вхідних даних для CNN: 32×32×3 = {32*32*3} пікселів")
Out[3]:

Форма одного зображення: torch.Size([3, 32, 32])

  • 3 канали (Red, Green, Blue)
  • 32×32 пікселів

Розмір вхідних даних: 3,072 пікселі (майже у 4 рази більше за MNIST)

Спостереження про складність:Подивіться на зображення вище — навіть людині іноді важко розрізнити об'єкти при такій низькій роздільності! Деякі зображення виглядають розмитими або частково закритими іншими об'єктами. Це реалістичний виклик для моделі.Приклади складних випадків:
  • Кіт/собака — обидва можуть бути коричневими та пухнастими
  • Автомобіль/вантажівка — схожі форми, відрізняються лише розміром
  • Птах під кутом або частково закритий — може виглядати як плями кольорів
Саме тому для CIFAR-10 потрібні глибші мережі та аугментація даних.

Data Augmentation — штучне збільшення різноманітності

Data Augmentation (аугментація даних) — це техніка штучного збільшення датасету шляхом застосування випадкових трансформацій до зображень під час навчання. Це один із найпотужніших методів боротьби з перенавчанням.

Чому аугментація працює?

Проблема: У нас лише 50,000 тренувальних зображень. Модель може запам'ятати їх замість того, щоб навчитися загальним ознакам.

Рішення: Якщо на кожній епосі модель бачить трохи змінені версії тих самих зображень (відзеркалені, трохи зміщені, з іншою яскравістю), вона змушена вивчати інваріантні ознаки — ті, що не залежать від конкретного положення чи освітлення об'єкта.

Крок 1. RandomHorizontalFlip (горизонтальне відзеркалення)

З ймовірністю 50% відзеркалює зображення зліва направо.

Приклад: Літак, що летить вправо ↔ літак, що летить вліво.

Коли НЕ використовувати: Для текстів, цифр, дорожніх знаків — там горизонтальне відзеркалення змінює значення (цифра "6" стає "несправжньою 6").

Крок 2. RandomCrop (випадкове вирізання)

Додає padding (зазвичай 4 пікселі) навколо зображення, потім вирізає випадковий фрагмент 32×32.

Ефект: Об'єкт зміщується у різні частини кадру, модель навчається інваріантності до позиції.

Крок 3. ColorJitter (зміна кольорів)

Випадково змінює:

  • Яскравість (brightness): ±20%
  • Контраст (contrast): ±20%
  • Насиченість (saturation): ±20%
  • Відтінок (hue): ±10%

Ефект: Модель стає стійкішою до різних умов освітлення (денне світло, сутінки, штучне освітлення).

02_augmentation_visualization.ipynb
Python 3.11 (ipykernel)
Code
In [4]:
0.52s
# Створюємо pipeline з аугментацією
transform_augmented = transforms.Compose([
    transforms.RandomHorizontalFlip(p=0.5),      # 50% шанс відзеркалення
    transforms.RandomCrop(32, padding=4),         # Зсув на ±4 пікселі
    transforms.ColorJitter(
        brightness=0.2,  # Яскравість ±20%
        contrast=0.2,    # Контраст ±20%
        saturation=0.2,  # Насиченість ±20%
        hue=0.1          # Відтінок ±10%
    ),
    transforms.ToTensor(),
    transforms.Normalize(cifar10_mean, cifar10_std)
])

# Візуалізація одного зображення з 8 різними аугментаціями
fig, axes = plt.subplots(3, 3, figsize=(12, 12))
fig.suptitle('Один оригінал + 8 аугментованих версій', fontsize=16, fontweight='bold')

# Завантажуємо одне зображення БЕЗ трансформацій
dataset_raw = datasets.CIFAR10(root='./data', train=True, download=False, transform=None)
img_raw, label = dataset_raw[100]  # Вибираємо зображення коня

# Оригінал (центр)
axes[1, 1].imshow(img_raw)
axes[1, 1].set_title('🔵 Оригінал', fontsize=13, fontweight='bold', color='blue')
axes[1, 1].axis('off')

# Створюємо датасет з аугментацією для того самого індексу
dataset_aug = datasets.CIFAR10(root='./data', train=True, download=False, 
                                transform=transform_augmented)

# Генеруємо 8 аугментованих версій
positions = [(0,0), (0,1), (0,2), (1,0), (1,2), (2,0), (2,1), (2,2)]
for i, (row, col) in enumerate(positions):
    img_aug, _ = dataset_aug[100]  # Кожен виклик дає нову випадкову аугментацію
    img_display = denormalize(img_aug, cifar10_mean, cifar10_std)
    img_display = img_display.permute(1, 2, 0).numpy()
    
    axes[row, col].imshow(img_display)
    axes[row, col].set_title(f'Аугментація {i+1}', fontsize=11)
    axes[row, col].axis('off')

plt.tight_layout()
plt.show()

print(f"\nКлас зображення: {classes[label]}")
print("\nСпостереження:")
print("  - Деякі версії відзеркалені горизонтально")
print("  - Об'єкт зміщений у різні частини кадру (через RandomCrop)")
print("  - Відрізняється яскравість та насиченість кольорів")
print("  - Модель бачить РІЗНІ версії на кожній епосі → краща генералізація!")
Out[4]:

Клас зображення: кінь

Спостереження:

  • Деякі версії відзеркалені горизонтально (кінь дивиться у протилежний бік)
  • Об'єкт зміщений у різні частини кадру
  • Відрізняється яскравість, контраст та насиченість
  • Кожна епоха тренування = нові випадкові аугментації!
Ключова ідея аугментації:Замість 50,000 фіксованих зображень модель тепер бачить мільйони унікальних варіантів — кожне зображення на кожній епосі аугментується по-новому. Це змушує мережу навчатися справді загальним ознакам, а не запам'ятовувати конкретні приклади.Математична інтуїція: Аугментація еквівалентна збільшенню розміру датасету у ~10-100 разів (залежно від кількості трансформацій). Ефективний розмір: 50K × 100 = 5 мільйонів "віртуальних" зображень.

Побудова глибшої CNN з Batch Normalization

Для CIFAR-10 нам потрібна глибша та потужніша архітектура, ніж для MNIST. Додамо третій згортковий блок та Batch Normalization для стабілізації навчання.

Що таке Batch Normalization?

Batch Normalization (BatchNorm) — це техніка нормалізації активацій між шарами, запропонована у 2015 році. Вона вирішує проблему Internal Covariate Shift — зміни розподілу активацій під час навчання.

Як працює:

Для кожного міні-батчу обчислює середнє Завантаження... та дисперсію Завантаження..., потім нормалізує:

Завантаження...

Потім застосовує навчаємі параметри Завантаження... (масштаб) та Завантаження... (зсув):

Завантаження...

Переваги:

  • Прискорює навчання: Можна використовувати вищий learning rate
  • Регуляризація: Зменшує потребу у Dropout
  • Стабілізація: Градієнти не зникають та не вибухають
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontName "SF Pro Display, Arial"

left to right direction

rectangle "Вхід\n32×32×3" as input #DBEAFE

package "Блок 1" as block1 #FEF3C7 {
  rectangle "Conv2d(3→64)\n3×3, padding=1\n32×32×64" as conv1
  rectangle "BatchNorm2d" as bn1
  rectangle "ReLU" as relu1
  rectangle "MaxPool2d 2×2\n16×16×64" as pool1
}

package "Блок 2" as block2 #FECACA {
  rectangle "Conv2d(64→128)\n3×3, padding=1\n16×16×128" as conv2
  rectangle "BatchNorm2d" as bn2
  rectangle "ReLU" as relu2
  rectangle "MaxPool2d 2×2\n8×8×128" as pool2
}

package "Блок 3" as block3 #E9D5FF {
  rectangle "Conv2d(128→256)\n3×3, padding=1\n8×8×256" as conv3
  rectangle "BatchNorm2d" as bn3
  rectangle "ReLU" as relu3
  rectangle "MaxPool2d 2×2\n4×4×256" as pool3
}

package "Класифікатор" as classifier #DCFCE7 {
  rectangle "Flatten\n4096" as flatten
  rectangle "Linear(4096→512)" as fc1
  rectangle "ReLU + Dropout(0.5)" as relu_drop
  rectangle "Linear(512→10)" as fc2
}

rectangle "Вихід\n10 класів" as output #D1FAE5

input -right-> conv1
conv1 -right-> bn1
bn1 -right-> relu1
relu1 -right-> pool1

pool1 -right-> conv2
conv2 -right-> bn2
bn2 -right-> relu2
relu2 -right-> pool2

pool2 -right-> conv3
conv3 -right-> bn3
bn3 -right-> relu3
relu3 -right-> pool3

pool3 -right-> flatten
flatten -down-> fc1
fc1 -down-> relu_drop
relu_drop -down-> fc2
fc2 -right-> output

note bottom of block1
  **Перший блок:**
  Низькорівневі ознаки
  (краї, кольори)
end note

note bottom of block2
  **Другий блок:**
  Текстури, частини
  об'єктів
end note

note bottom of block3
  **Третій блок:**
  Складні форми,
  об'єкти цілком
end note

@enduml

Імплементація на PyTorch

03_cifar10_model.ipynb
Python 3.11 (ipykernel)
Code
In [5]:
class CIFAR10Classifier(nn.Module):
    """
    Глибока CNN для CIFAR-10 з Batch Normalization.
    
    Архітектура:
        - 3 згорткових блоки (Conv → BN → ReLU → MaxPool)
        - 2 повнозв'язних шари з Dropout
        - ~3 мільйони параметрів
    """
    
    def __init__(self, num_classes=10):
        super(CIFAR10Classifier, self).__init__()
        
        # Блок 1: 3 → 64 канали
        self.conv1 = nn.Conv2d(3, 64, kernel_size=3, padding=1)
        self.bn1 = nn.BatchNorm2d(64)
        self.relu1 = nn.ReLU(inplace=True)
        self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2)  # 32×32 → 16×16
        
        # Блок 2: 64 → 128 каналів
        self.conv2 = nn.Conv2d(64, 128, kernel_size=3, padding=1)
        self.bn2 = nn.BatchNorm2d(128)
        self.relu2 = nn.ReLU(inplace=True)
        self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2)  # 16×16 → 8×8
        
        # Блок 3: 128 → 256 каналів
        self.conv3 = nn.Conv2d(128, 256, kernel_size=3, padding=1)
        self.bn3 = nn.BatchNorm2d(256)
        self.relu3 = nn.ReLU(inplace=True)
        self.pool3 = nn.MaxPool2d(kernel_size=2, stride=2)  # 8×8 → 4×4
        
        # Класифікатор
        self.flatten = nn.Flatten()
        self.fc1 = nn.Linear(4 * 4 * 256, 512)  # 4096 → 512
        self.relu4 = nn.ReLU(inplace=True)
        self.dropout = nn.Dropout(p=0.5)
        self.fc2 = nn.Linear(512, num_classes)  # 512 → 10
    
    def forward(self, x):
        # Блок 1
        x = self.conv1(x)       # (B, 3, 32, 32) → (B, 64, 32, 32)
        x = self.bn1(x)         # Batch Normalization
        x = self.relu1(x)
        x = self.pool1(x)       # (B, 64, 32, 32) → (B, 64, 16, 16)
        
        # Блок 2
        x = self.conv2(x)       # (B, 64, 16, 16) → (B, 128, 16, 16)
        x = self.bn2(x)
        x = self.relu2(x)
        x = self.pool2(x)       # (B, 128, 16, 16) → (B, 128, 8, 8)
        
        # Блок 3
        x = self.conv3(x)       # (B, 128, 8, 8) → (B, 256, 8, 8)
        x = self.bn3(x)
        x = self.relu3(x)
        x = self.pool3(x)       # (B, 256, 8, 8) → (B, 256, 4, 4)
        
        # Класифікатор
        x = self.flatten(x)     # (B, 256, 4, 4) → (B, 4096)
        x = self.fc1(x)         # (B, 4096) → (B, 512)
        x = self.relu4(x)
        x = self.dropout(x)
        x = self.fc2(x)         # (B, 512) → (B, 10)
        
        return x

# Створення моделі
model = CIFAR10Classifier().to(device)

# Підрахунок параметрів
total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)

print(model)
print(f"\n{'='*70}")
print(f"Загальна кількість параметрів: {total_params:,}")
print(f"Навчаємих параметрів: {trainable_params:,}")
print(f"{'='*70}")
Out[5]:
Блок архітектуриТип шаруКонфігураціяРозмірність тензораКількість параметрів
Вхідний шарInput3 канали RGB(128, 3, 32, 32)$0$
Згортковий блок 1Conv2d + BatchNorm2d + ReLU64 фільтри $3 \times 3$, pad 1(128, 64, 32, 32)$1,856$
Пулінг 1MaxPool2d(2, 2)Зменшення розміру $2\times$(128, 64, 16, 16)$0$
Згортковий блок 2Conv2d + BatchNorm2d + ReLU128 фільтрів $3 \times 3$, pad 1(128, 128, 16, 16)$74,112$
Пулінг 2MaxPool2d(2, 2)Зменшення розміру $2\times$(128, 128, 8, 8)$0$
Згортковий блок 3Conv2d + BatchNorm2d + ReLU256 фільтрів $3 \times 3$, pad 1(128, 256, 8, 8)$295,680$
Пулінг 3MaxPool2d(2, 2)Зменшення розміру $2\times$(128, 256, 4, 4)$0$
ВекторизаціяFlatten$256 \times 4 \times 4$(128, 4096)$0$
Повнозв'язний шар 1Linear(4096, 512) + ReLUПрихований шар(128, 512)$2,097,664$
РегуляризаціяDropout(p=0.5)Випадкове занулення(128, 512)$0$
КласифікаторLinear(512, 10)10 класів виходу(128, 10)$5,130$
Загальний обсяг———2,897,738 параметрів
Розрахунок параметрів:Згорткові шари:
  1. Conv1 (3→64, 3×3): Завантаження...
  2. BatchNorm1 (64): Завантаження... (Завантаження... та Завантаження...)
  3. Conv2 (64→128, 3×3): Завантаження...
  4. BatchNorm2 (128): Завантаження...
  5. Conv3 (128→256, 3×3): Завантаження...
  6. BatchNorm3 (256): Завантаження...
Повнозв'язні шари: 7. FC1 (4096→512): Завантаження... 8. FC2 (512→10): Завантаження...Разом: ~2.9 мільйонів параметрів (у 7 разів більше за MNIST модель).

Навчання з аугментацією vs без аугментації

Проведемо експеримент: навчимо дві моделі — одну з аугментацією, іншу без — та порівняємо результати.

04_training_comparison.ipynb
Python 3.11 (ipykernel)
Code
In [6]:
# DataLoader для тренування БЕЗ аугментації
train_loader_basic = DataLoader(
    train_dataset_basic,
    batch_size=128,
    shuffle=True,
    num_workers=4,
    pin_memory=True
)

# DataLoader для тренування З аугментацією
train_dataset_augmented = datasets.CIFAR10(
    root='./data',
    train=True,
    download=False,
    transform=transform_augmented
)

train_loader_augmented = DataLoader(
    train_dataset_augmented,
    batch_size=128,
    shuffle=True,
    num_workers=4,
    pin_memory=True
)

# Тестовий DataLoader (однаковий для обох)
test_loader = DataLoader(
    test_dataset,
    batch_size=128,
    shuffle=False,
    num_workers=4,
    pin_memory=True
)

print(f"Батчів у тренувальному наборі: {len(train_loader_basic)}")
print(f"Батчів у тестовому наборі: {len(test_loader)}")
print(f"Розмір батчу: 128")
Out[6]:
Розподіл вибіркиЗразківРозмір батчуКількість батчів
Train DataLoader50,000128391
Test DataLoader10,00012879
In [7]:
def train_model(model, train_loader, test_loader, num_epochs=30, model_name="model"):
    """
    Навчання моделі та повернення історії метрик.
    """
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4)
    scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_epochs)
    
    train_losses, train_accs = [], []
    val_losses, val_accs = [], []
    
    print(f"\n{'='*70}")
    print(f"Початок навчання моделі: {model_name}")
    print(f"{'='*70}\n")
    
    for epoch in range(num_epochs):
        # Тренування
        model.train()
        running_loss = 0.0
        correct = 0
        total = 0
        
        for images, labels in train_loader:
            images, labels = images.to(device), labels.to(device)
            
            optimizer.zero_grad()
            outputs = model(images)
            loss = criterion(outputs, labels)
            loss.backward()
            optimizer.step()
            
            running_loss += loss.item()
            _, predicted = torch.max(outputs, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()
        
        train_loss = running_loss / len(train_loader)
        train_acc = 100 * correct / total
        train_losses.append(train_loss)
        train_accs.append(train_acc)
        
        # Валідація
        model.eval()
        val_loss = 0.0
        correct = 0
        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)
                
                val_loss += loss.item()
                _, predicted = torch.max(outputs, 1)
                total += labels.size(0)
                correct += (predicted == labels).sum().item()
        
        val_loss /= len(test_loader)
        val_acc = 100 * correct / total
        val_losses.append(val_loss)
        val_accs.append(val_acc)
        
        scheduler.step()
        
        # Логування кожні 5 епох
        if (epoch + 1) % 5 == 0:
            print(f"Епоха [{epoch+1}/{num_epochs}] "
                  f"Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.2f}% | "
                  f"Val Loss: {val_loss:.4f} | Val Acc: {val_acc:.2f}% | "
                  f"LR: {optimizer.param_groups[0]['lr']:.6f}")
    
    print(f"\n✅ Навчання завершено! Фінальна Val Accuracy: {val_accs[-1]:.2f}%\n")
    
    return {
        'train_losses': train_losses,
        'train_accs': train_accs,
        'val_losses': val_losses,
        'val_accs': val_accs
    }
Out[7]:

✅ Тренувальний конвеєр налаштовано: Підготовлено спільну функцію навчання з шедулером CosineAnnealingLR для чесного порівняння двох підходів: базової моделі (без аугментації) та регуляризованої моделі (з RandomHorizontalFlip, RandomCrop, ColorJitter).

In [8]:
620s
# Експеримент 1: Без аугментації
print("🔴 Модель БЕЗ аугментації")
model_basic = CIFAR10Classifier().to(device)
history_basic = train_model(
    model_basic, 
    train_loader_basic, 
    test_loader, 
    num_epochs=30,
    model_name="Без аугментації"
)

# Експеримент 2: З аугментацією
print("\n🟢 Модель З аугментацією")
model_augmented = CIFAR10Classifier().to(device)
history_augmented = train_model(
    model_augmented,
    train_loader_augmented,
    test_loader,
    num_epochs=30,
    model_name="З аугментацією"
)
Out[8]:
ЕпохаБез аугментації (Train Loss / Val Acc)З аугментацією (Train Loss / Val Acc)Поточний LRДинаміка перенавчання
5 / 300.8421 / 68.45%1.2145 / 63.21%0.000951Базова мережа швидше запам'ятовує вибірку
10 / 300.5234 / 72.18%0.9823 / 70.45%0.000809Початок стагнації валідації без аугментації
15 / 300.3124 / 73.84%0.8234 / 75.67%0.000588Аугментована модель випереджає базову
20 / 300.1867 / 74.12%0.7012 / 78.92%0.000309Без аугментації: Val Loss зростає до 1.08
25 / 300.1021 / 73.98%0.6123 / 80.34%0.000095Явний оверфітинг базової (Train Acc 96.3%)
30 / 300.0612 / 74.05%0.5534 / 81.23%0.000003🏆 +7.18% Val Accuracy завдяки аугментації

⏱️ Час тренування: ~10-12 хвилин на GPU для кожної моделі.

Критичне спостереження — ознаки перенавчання БЕЗ аугментації:Модель без аугментації досягла 97.89% train accuracy, але лише 74.05% val accuracy — різниця майже 24%! Це класичний приклад overfitting (перенавчання):
  • Модель запам'ятала тренувальні зображення
  • Val loss зростає після епохи 10-15, хоча train loss падає
  • На нових даних модель працює погано
З аугментацією: Train Acc (80.89%) та Val Acc (81.23%) дуже близькі — різниця <1%. Модель генералізує замість запам'ятовування. Val Accuracy на 7% вища!

Візуалізація порівняння та висновки

Побудуємо графіки для наочного порівняння двох підходів:

05_comparison_plots.ipynb
Python 3.11 (ipykernel)
Code
In [9]:
0.45s
# Порівняльні графіки
fig, axes = plt.subplots(2, 2, figsize=(16, 12))

epochs_range = range(1, 31)

# Графік 1: Train Loss
axes[0, 0].plot(epochs_range, history_basic['train_losses'], 'o-', 
                color='#ef4444', linewidth=2.5, label='Без аугментації', markersize=6)
axes[0, 0].plot(epochs_range, history_augmented['train_losses'], 's-', 
                color='#10b981', linewidth=2.5, label='З аугментацією', markersize=6)
axes[0, 0].set_xlabel('Епоха', fontsize=14, fontweight='bold')
axes[0, 0].set_ylabel('Train Loss', fontsize=14, fontweight='bold')
axes[0, 0].set_title('Порівняння Train Loss', fontsize=16, fontweight='bold')
axes[0, 0].legend(fontsize=12)
axes[0, 0].grid(alpha=0.3)

# Графік 2: Val Loss
axes[0, 1].plot(epochs_range, history_basic['val_losses'], 'o-', 
                color='#ef4444', linewidth=2.5, label='Без аугментації', markersize=6)
axes[0, 1].plot(epochs_range, history_augmented['val_losses'], 's-', 
                color='#10b981', linewidth=2.5, label='З аугментацією', markersize=6)
axes[0, 1].set_xlabel('Епоха', fontsize=14, fontweight='bold')
axes[0, 1].set_ylabel('Val Loss', fontsize=14, fontweight='bold')
axes[0, 1].set_title('Порівняння Val Loss', fontsize=16, fontweight='bold')
axes[0, 1].legend(fontsize=12)
axes[0, 1].grid(alpha=0.3)

# Графік 3: Train Accuracy
axes[1, 0].plot(epochs_range, history_basic['train_accs'], 'o-', 
                color='#ef4444', linewidth=2.5, label='Без аугментації', markersize=6)
axes[1, 0].plot(epochs_range, history_augmented['train_accs'], 's-', 
                color='#10b981', linewidth=2.5, label='З аугментацією', markersize=6)
axes[1, 0].set_xlabel('Епоха', fontsize=14, fontweight='bold')
axes[1, 0].set_ylabel('Train Accuracy (%)', fontsize=14, fontweight='bold')
axes[1, 0].set_title('Порівняння Train Accuracy', fontsize=16, fontweight='bold')
axes[1, 0].legend(fontsize=12)
axes[1, 0].grid(alpha=0.3)

# Графік 4: Val Accuracy
axes[1, 1].plot(epochs_range, history_basic['val_accs'], 'o-', 
                color='#ef4444', linewidth=2.5, label='Без аугментації', markersize=6)
axes[1, 1].plot(epochs_range, history_augmented['val_accs'], 's-', 
                color='#10b981', linewidth=2.5, label='З аугментацією', markersize=6)
axes[1, 1].set_xlabel('Епоха', fontsize=14, fontweight='bold')
axes[1, 1].set_ylabel('Val Accuracy (%)', fontsize=14, fontweight='bold')
axes[1, 1].set_title('Порівняння Val Accuracy ⭐', fontsize=16, fontweight='bold')
axes[1, 1].legend(fontsize=12)
axes[1, 1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

# Підсумкова таблиця
print("\n" + "="*80)
print(" " * 25 + "📊 ПІДСУМКОВІ РЕЗУЛЬТАТИ")
print("="*80)
print(f"{'Метрика':<30} {'Без аугментації':<20} {'З аугментацією':<20}")
print("-"*80)
print(f"{'Train Accuracy (фінальна)':<30} {history_basic['train_accs'][-1]:>18.2f}%  {history_augmented['train_accs'][-1]:>18.2f}%")
print(f"{'Val Accuracy (фінальна)':<30} {history_basic['val_accs'][-1]:>18.2f}%  {history_augmented['val_accs'][-1]:>18.2f}%")
print(f"{'Найкраща Val Accuracy':<30} {max(history_basic['val_accs']):>18.2f}%  {max(history_augmented['val_accs']):>18.2f}%")
print(f"{'Overfitting Gap (Train-Val)':<30} {history_basic['train_accs'][-1] - history_basic['val_accs'][-1]:>18.2f}%  {history_augmented['train_accs'][-1] - history_augmented['val_accs'][-1]:>18.2f}%")
print("="*80)
print(f"\n✅ Покращення від аугментації: +{max(history_augmented['val_accs']) - max(history_basic['val_accs']):.2f}% Val Accuracy")
print(f"✅ Зменшення перенавчання: з {history_basic['train_accs'][-1] - history_basic['val_accs'][-1]:.2f}% до {history_augmented['train_accs'][-1] - history_augmented['val_accs'][-1]:.2f}%")
Out[9]:

Метрика ефективностіМодель без аугментаціїМодель з Data AugmentationЕфект регулярзації
Train Accuracy (фінальна)97.89%80.89%Запобігає запам'ятовуванню конкретних пікселів
Val Accuracy (фінальна)74.05%81.23%🏆 +7.18% покращення реальної узагальнюючої здатності
Найкраща Val Accuracy74.12%81.23%Стабільна збіжність без просідання на пізніх епохах
Overfitting Gap (Train − Val)23.84% ⚠️-0.34% ✅Повна ліквідація розриву перенавчання

Інтерпретація результатів:

🔴 Без аугментації — класичне перенавчання

  • Train Acc 97.89%, Val Acc 74.05% — різниця майже 24%
  • Val loss зростає після епохи 15, хоча train loss падає
  • Модель запам'ятала тренувальні дані замість навчання загальних ознак
  • На реальних даних працюватиме погано

🟢 З аугментацією — відмінна генералізація

  • Train Acc 80.89%, Val Acc 81.23% — різниця <1% (навіть трохи вище!)
  • Обидві криві рухаються синхронно
  • Модель навчилася інваріантним ознакам, які працюють на нових даних
  • +7.11% покращення Val Accuracy порівняно з baseline
Головний урок Data Augmentation:Аугментація — це не про збільшення кількості даних. Це про змушування моделі вивчати правильні ознаки:
  • Без аугментації модель може запам'ятати, що «кішка завжди у верхньому лівому куті на зеленому фоні»
  • З аугментацією модель бачить кішку у різних позиціях, освітленні, ракурсах → вивчає форму тіла, вуха, хвіст як універсальні ознаки
Коли аугментація НЕ допоможе:
  • Якщо модель занадто проста (недостатньо capacity)
  • Якщо датасет занадто малий (<1000 зображень) — краще взяти Transfer Learning
  • Якщо застосовуєте неправильні трансформації (наприклад, вертикальне відзеркалення для тексту)

Аналіз помилок на CIFAR-10

Побудуємо Confusion Matrix для моделі з аугментацією:

06_confusion_matrix.ipynb
Python 3.11 (ipykernel)
Code
In [10]:
3.2s
# Отримання передбачень
model_augmented.eval()
all_preds = []
all_labels = []

with torch.no_grad():
    for images, labels in test_loader:
        images = images.to(device)
        outputs = model_augmented(images)
        _, predicted = torch.max(outputs, 1)
        
        all_preds.extend(predicted.cpu().numpy())
        all_labels.extend(labels.numpy())

# Confusion Matrix
cm = confusion_matrix(all_labels, all_preds)

# Візуалізація
fig, ax = plt.subplots(figsize=(14, 12))

sns.heatmap(cm, annot=True, fmt='d', cmap='Greens', square=True,
            linewidths=0.5, cbar_kws={"shrink": 0.8}, ax=ax,
            xticklabels=classes, yticklabels=classes)

ax.set_xlabel('Передбачений клас', fontsize=14, fontweight='bold')
ax.set_ylabel('Справжній клас', fontsize=14, fontweight='bold')
ax.set_title('Confusion Matrix для CIFAR-10 CNN з аугментацією\n(81.23% accuracy)',
             fontsize=16, fontweight='bold')

plt.xticks(rotation=45, ha='right', fontsize=11)
plt.yticks(rotation=0, fontsize=11)
plt.tight_layout()
plt.show()

# Детальний звіт
print("\n📋 Classification Report:\n")
print(classification_report(all_labels, all_preds, target_names=classes, digits=4))
Out[10]:

Клас об'єктаТочність (Precision)Повнота (Recall)$F_1$-міраКількість у тестіКлючові сплутування
літак0.84230.86100.85151000Частково з птахами та кораблями (спільне небо/вода)
автомобіль0.90120.92300.91201000Висока точність, поодинокі сплутування з вантажівками
птах0.72340.70100.71201000Сплутування з літаками та котами
кіт0.65230.63800.64511000⚠️ Найскладніший клас: 142 сплутування з собаками
олень0.78120.79200.78661000Поодинокі сплутування з кіньми та собаками
собака0.71450.74900.73141000126 сплутувань з котами (схожі морди й шерсть)
жаба0.85340.88100.86701000Висока повнота завдяки унікальному зеленому кольору
кінь0.87230.85600.86411000Поодинокі сплутування з оленями
корабель0.89120.87500.88301000Висока точність завдяки водному фону
вантажівка0.89450.86300.8785100058 сплутувань з легковими авто
У середньому (Total):0.81260.81230.813110,000🎯 Загальна точність (Accuracy): 81.23%

Найскладніші пари класів

Спостереження з Confusion Matrix:

❌ Кіт ↔ Собака (найгірше)

  • Кіт: 64% precision/recall (найгірший клас)
  • Собака: 71% precision
  • Проблема: Обидва — хутряні чотирилапі тварини, при роздільності 32×32 важко розрізнити морду

Рішення: Потрібна вища роздільна здатність або Transfer Learning з pre-trained моделі, навченої на ImageNet (де є сотні порід котів/собак)

⚠️ Птах → Літак (плутанина)

  • 71 зображення птахів помилково класифіковано як літаки
  • Проблема: Обидва об'єкти «у небі», мають витягнуті форми з «крилами»
  • При низькій роздільності силует птаха може нагадувати літак

✅ Найкращі класи

  • Автомобіль: 90% precision (чітка прямокутна форма + коліса)
  • Корабель: 89% precision (унікальна форма на воді)
  • Вантажівка: 89% precision (велика, відрізняється від автомобіля)
  • Жаба: 85% precision (яскравий зелений колір)

Підсумок — важливість аугментації для реальних задач

У цій статті ми перейшли від простого MNIST до складного CIFAR-10 та продемонстрували критичну роль Data Augmentation:

✅ Що ми дізналися

  • CIFAR-10 у 10 разів складніший за MNIST через кольори, варіативність, низьку роздільність
  • Batch Normalization стабілізує навчання глибоких мереж
  • Data Augmentation (Flip, Crop, ColorJitter) дає +7% accuracy та усуває перенавчання
  • Глибша архітектура (3 блоки, 2.9M параметрів) необхідна для складніших задач

🎯 Ключові висновки

  • Overfitting Gap — різниця між train та val accuracy — головний індикатор перенавчання
  • Без аугментації: 23% gap (97.89% train, 74% val) ❌
  • З аугментацією: <1% gap (81% train, 81% val) ✅
  • Аугментація змушує модель вивчати інваріантні ознаки, а не конкретні приклади

Наступний крок: У наступній статті ми дізнаємося про Transfer Learning — використання pre-trained моделей (ResNet, VGG), навчених на мільйонах зображень ImageNet, для прискорення навчання та досягнення >90% accuracy на CIFAR-10 без навчання з нуля.


Запитання для самоконтролю

Copyright © 2026