Batching Normalization Deep Networks

Dropout та комбіновані стратегії регуляризації

Випадкове вимикання нейронів для боротьби з перенавчанням, математика та інтуїція Dropout, комбінування з BatchNorm і weight decay, експериментальне порівняння стратегій.

Боротьба з перенавчанням — коли модель «запам'ятовує» замість «розуміє»

У попередній статті ми опанували Batch Normalization — потужну техніку стабілізації навчання глибоких мереж. BN дозволяє використовувати великі learning rates, зменшує залежність від ініціалізації та навіть діє як імпліцитна регуляризація. Але що, якщо ваша модель надто добре навчається на тренувальних даних?

Уявіть ситуацію: ви навчаєте CNN на датасеті з 5000 зображень. Після 50 епох train accuracy досягає 99.5% — фантастика! Ви радісно тестуєте модель на новому датасеті і... отримуєте лише 65%. Що пішло не так?

Це класичний випадок перенавчання (overfitting): модель не вивчила загальні закономірності розпізнавання об'єктів, а запам'ятала конкретні тренувальні зображення. Вона «вивчила підручник напам'ять», але не зрозуміла матеріал. При зустрічі з новими даними модель безпорадна.

📚 Аналогія: екзамен у студентів

Студент A (Overfit): Вивчив напам'ять усі приклади з підручника. На екзамені отримує ті самі завдання — 100%. Змініть формулювання — провал.

Студент B (Генералізація): Зрозумів концепції та підходи. На екзамені вирішує нові завдання за аналогією — 85%. Стабільний результат на будь-яких варіантах.

Ми хочемо, щоб наші моделі були як студент B!

💾 Ємність моделі vs розмір датасету

Модель із 10 мільйонами параметрів може «запам'ятати» тренувальний датасет із 10 тисяч прикладів. Це як використовувати 1TB SSD для зберігання одного Word документу — величезний надлишок ємності призводить до memorization.

Перенавчання — одна з центральних проблем машинного навчання. За останні роки розроблено безліч технік боротьби з ним. У цій статті ми детально розглянемо Dropout — елегантну та надзвичайно ефективну техніку, запропоновану у 2012 році, яка досі широко використовується.

🎯 Мета статті

  • Зрозуміти природу перенавчання та його візуальні ознаки
  • Опанувати ідею та математику Dropout: випадкове вимикання нейронів
  • Дослідити, чому Dropout запобігає ко-адаптації нейронів
  • Навчитися правильно використовувати Dropout у PyTorch (train/eval режими)
  • Комбінувати регуляризаційні техніки: Dropout, BatchNorm, Weight Decay, Data Augmentation
  • Провести експеримент на CIFAR-10 з різними стратегіями

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

  • Overfitting (перенавчання): модель занадто точно підлаштовується під тренувальні дані, втрачаючи здатність генералізувати
  • Dropout: випадкове вимикання нейронів з ймовірністю Завантаження... під час навчання
  • Co-adaptation: нейрони «спеціалізуються» на корекції помилок один одного замість вивчення загальних ознак
  • Ensemble Effect: Dropout створює ансамбль експоненціально багатьох підмереж
  • Inverted Dropout: масштабування активацій на Завантаження... під час тренування (стандарт PyTorch)

Нагадування: що таке перенавчання

Перш ніж перейти до розв'язання, переконаймося, що ми чітко розуміємо проблему. Перенавчання виникає, коли модель має занадто багато параметрів відносно кількості тренувальних даних.

Візуалізація перенавчання

Розглянемо класичну задачу поліноміальної регресії. Маємо 20 точок даних, згенерованих з функції Завантаження.... Спробуємо апроксимувати їх поліномами різного ступеня.

overfitting_visualization.ipynb
Python 3.11 (ipykernel)
Code
In [1]:
0.85s
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error

# Генерація даних
np.random.seed(42)
n_samples = 20
X_train = np.sort(np.random.uniform(0, 1, n_samples))
y_train = np.sin(2 * np.pi * X_train) + np.random.normal(0, 0.1, n_samples)

# Тестові дані (справжня функція)
X_test = np.linspace(0, 1, 100)
y_test_true = np.sin(2 * np.pi * X_test)

# Апроксимація поліномами різних ступенів
degrees = [1, 4, 15]
models = {}

for degree in degrees:
    poly_features = PolynomialFeatures(degree=degree)
    X_train_poly = poly_features.fit_transform(X_train.reshape(-1, 1))
    X_test_poly = poly_features.transform(X_test.reshape(-1, 1))
    
    model = LinearRegression()
    model.fit(X_train_poly, y_train)
    
    y_train_pred = model.predict(X_train_poly)
    y_test_pred = model.predict(X_test_poly)
    
    train_mse = mean_squared_error(y_train, y_train_pred)
    test_mse = mean_squared_error(y_test_true, y_test_pred)
    
    models[degree] = {
        'model': model,
        'y_test_pred': y_test_pred,
        'train_mse': train_mse,
        'test_mse': test_mse
    }

# Візуалізація
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

for idx, degree in enumerate(degrees):
    ax = axes[idx]
    
    # Справжня функція
    ax.plot(X_test, y_test_true, 'g--', linewidth=2, alpha=0.7, label='Справжня функція')
    
    # Тренувальні точки
    ax.scatter(X_train, y_train, color='blue', s=80, alpha=0.7, 
               edgecolor='black', linewidth=1.5, label='Тренувальні дані', zorder=5)
    
    # Апроксимація
    ax.plot(X_test, models[degree]['y_test_pred'], 'r-', linewidth=2.5, 
            label=f'Поліном степеня {degree}')
    
    ax.set_xlabel('x', fontsize=12)
    ax.set_ylabel('y', fontsize=12)
    ax.set_ylim([-1.5, 1.5])
    ax.grid(alpha=0.3)
    ax.legend(fontsize=10)
    
    # Статистика
    train_mse = models[degree]['train_mse']
    test_mse = models[degree]['test_mse']
    
    if degree == 1:
        title = f'Underfitting (ступінь {degree})'
        status = '❌ Занадто проста'
    elif degree == 4:
        title = f'Хороша генералізація (ступінь {degree})'
        status = '✅ Баланс'
    else:
        title = f'Overfitting (ступінь {degree})'
        status = '❌ Надто складна'
    
    ax.set_title(f'{title}\nTrain MSE: {train_mse:.4f}, Test MSE: {test_mse:.4f}',
                 fontsize=11, fontweight='bold')
    ax.text(0.5, -1.3, status, ha='center', fontsize=12, 
            bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))

plt.tight_layout()
plt.show()
Out[1]:
Інтерпретація графіків:
  • Ступінь 1 (лінійна): Занадто проста модель, не може захопити синусоїдальну природу даних. Високі помилки і на train, і на test. Це underfitting.
  • Ступінь 4: Оптимальний баланс. Модель захоплює загальну форму синусоїди, але не підлаштовується під шум. Низькі помилки на обох датасетах.
  • Ступінь 15: Модель проходить через кожну тренувальну точку (train MSE ≈ 0), але між ними робить дикі коливання. Це overfitting — модель запам'ятала шум.

Ознаки перенавчання у нейронних мережах

Для нейронних мереж перенавчання має схожі симптоми:

📉 Gap між train та validation loss

Здорова модель: train loss ≈ val loss ± 5-10%
Overfit: train loss значно менша за val loss (gap > 20%)

Графік: train loss продовжує зменшуватися, а val loss зупиняється або зростає.

🎯 Дуже висока точність на train, низька на val

Приклад: Train accuracy = 98%, Val accuracy = 72%

Модель «вивчила напам'ять» тренувальні приклади, але не може розпізнати нові.

🔧 Модель чутлива до малих змін входу

Невелика зміна зображення (поворот на 5°, зміна яскравості) різко змінює передбачення. Це означає, що модель не вивчила інваріантні ознаки.

Dropout — випадкове вимикання нейронів

У 2012 році Geoffrey Hinton зі співавторами запропонували елегантну техніку боротьби з перенавчанням: Dropout. Ідея шокуюче проста: під час навчання випадково вимикати частину нейронів у кожній ітерації.

Ідея та інтуїція

Уявіть команду з 10 інженерів, що працюють над проєктом:

❌ Без Dropout: ко-адаптація

Кожен інженер спеціалізується на вузькій задачі: «Олександр виправляє баги Марії, Марія компенсує недоліки коду Дмитра, Дмитро покладається на перевірки Олександра...»

Проблема: Якщо хтось захворіє, вся система колапсує. Команда ко-адаптувалася — кожен залежить від інших замість самостійної роботи.

✅ З Dropout: незалежність

Кожного дня випадково 50% команди відсутні. Кожен інженер змушений писати робастний код, який працює незалежно від інших.

Результат: Коли вся команда працює разом (inference), система надзвичайно стабільна та універсальна.

Так само працює Dropout у нейронних мережах:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontName "Arial"

!define ACTIVE_COLOR #DCFCE7
!define DROPPED_COLOR #FEE2E2

package "Стандартна мережа (без Dropout)" {
  rectangle "Input Layer\n4 нейрони" as in1 #DBEAFE
  rectangle "Hidden Layer\n6 нейронів" as h1 #ACTIVE_COLOR
  rectangle "Output Layer\n3 нейрони" as out1 #E0E7FF
  
  in1 -down-> h1 : Всі з'єднання активні
  h1 -down-> out1
}

package "З Dropout (p=0.5 під час навчання)" {
  rectangle "Input Layer\n4 нейрони" as in2 #DBEAFE
  
  rectangle "Hidden Layer" as h2_container {
    rectangle "3 активні" as h2_active #ACTIVE_COLOR
    rectangle "3 вимкнені" as h2_dropped #DROPPED_COLOR
  }
  
  rectangle "Output Layer\n3 нейрони" as out2 #E0E7FF
  
  in2 -down-> h2_active : 50% з'єднань
  h2_active -down-> out2
  
  note right of h2_dropped
    Вимкнені нейрони
    НЕ беруть участі
    у forward/backward
  end note
}

@enduml

Математика Dropout

Нехай Завантаження... — активації прихованого шару. Під час навчання:

Крок 1. Генерація маски:

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

Де Завантаження... — dropout rate (ймовірність вимкнення нейрона). Зазвичай Завантаження... для FC шарів, Завантаження... для Conv.

Крок 2. Застосування маски (Inverted Dropout):

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

Чому ділимо на Завантаження...?

Під час навчання в середньому лише Завантаження... нейронів активні. Щоб очікуване значення активацій залишалося незмінним, ми масштабуємо на Завантаження....

Приклад: якщо Завантаження..., то в середньому половина нейронів активна, тому множимо на Завантаження....

Під час inference (eval mode):

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

Всі нейрони активні, без масштабування. Завдяки Inverted Dropout під час тренування, очікувані значення вже правильні.

Два варіанти Dropout:
  1. Standard Dropout: Масштабування на Завантаження... під час inference
  2. Inverted Dropout: Масштабування на Завантаження... під час training (використовується у PyTorch)
Inverted Dropout зручніший, бо inference (який виконується частіше) не потребує додаткових обчислень.

Чому Dropout працює — теоретичне обґрунтування

Dropout не просто «магічно» покращує генералізацію. Існують глибокі теоретичні причини його ефективності.

Причина 1: Запобігання ко-адаптації нейронів

Без Dropout нейрони можуть ко-адаптуватися: один нейрон спеціалізується на виправленні помилок іншого. Це призводить до складних залежностей між нейронами, які не генералізуються на нові дані.

Приклад: Уявімо два нейрона у прихованому шарі:

  • Нейрон A виявляє «очі» на зображенні
  • Нейрон B коригує помилки A, виявляючи «псевдо-очі» (блики, тіні)

Якщо A завжди активний, B стає залежним від A. При новому зображенні, де A помиляється, B також збивається.

З Dropout кожен нейрон змушений бути self-sufficient — працювати навіть коли інші нейрони вимкнені.

Причина 2: Ансамбль експоненціально багатьох моделей

Розглянемо мережу з Завантаження... нейронами у прихованому шарі. Кількість можливих підмереж (комбінацій активних/вимкнених нейронів):

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

Для Завантаження... (типовий розмір шару) це Завантаження... різних моделей!

Dropout під час навчання = тренування ансамблю

Кожна міні-батч ітерація навчає іншу підмережу. Після завершення навчання у нас є величезний ансамбль моделей, які «голосують» разом під час inference.

Ансамблі у машинному навчанні:Відомо, що ансамбль моделей (Random Forest, Gradient Boosting) майже завжди кращий за одну модель. Dropout дозволяє отримати ефект ансамблю без тренування окремих моделей!Це як мати 10^308 експертів, кожен з яких бачив трохи інші дані, і усереднити їхні передбачення.

Причина 3: Додавання шуму для регуляризації

Dropout додає мультиплікативний шум до активацій:

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

Це схоже на data augmentation, але для внутрішніх представлень замість входів. Модель вчиться бути стійкою до випадкових «збоїв» у проміжних шарах.


Dropout у PyTorch — train та eval режими

Як і Batch Normalization, Dropout поводиться по-різному під час навчання та inference. Це критично важливо розуміти!

dropout_pytorch_demo.ipynb
Python 3 (ipykernel)
Code
In [1]:
import torch
import torch.nn as nn

# Модель з Dropout
class MLPWithDropout(nn.Module):
    def __init__(self):
        super(MLPWithDropout, self).__init__()
        self.fc1 = nn.Linear(100, 256)
        self.dropout1 = nn.Dropout(p=0.5)
        
        self.fc2 = nn.Linear(256, 128)
        self.dropout2 = nn.Dropout(p=0.5)
        
        self.fc3 = nn.Linear(128, 10)
        self.relu = nn.ReLU()
    
    def forward(self, x):
        x = self.relu(self.fc1(x))
        x = self.dropout1(x)  # Dropout після активації
        
        x = self.relu(self.fc2(x))
        x = self.dropout2(x)
        
        x = self.fc3(x)  # Останній шар БЕЗ Dropout
        return x

model = MLPWithDropout()
x = torch.randn(4, 100)  # Батч з 4 прикладів

print("🔬 Експеримент: Dropout у train vs eval режимах\n")

# Train mode
model.train()
out1_train = model(x)
out2_train = model(x)

print("📊 Training Mode (Dropout активний):")
print(f"   1-й forward: {out1_train[0, :3].detach().numpy()}")
print(f"   2-й forward: {out2_train[0, :3].detach().numpy()}")
print(f"   Різниця: {torch.norm(out1_train - out2_train).item():.6f}")
print(f"   Виходи РІЗНІ через випадкову маску! ✅\n")

# Eval mode
model.eval()
out1_eval = model(x)
out2_eval = model(x)

print("📊 Evaluation Mode (Dropout вимкнений):")
print(f"   1-й forward: {out1_eval[0, :3].detach().numpy()}")
print(f"   2-й forward: {out2_eval[0, :3].detach().numpy()}")
print(f"   Різниця: {torch.norm(out1_eval - out2_eval).item():.6f}")
print(f"   Виходи ІДЕНТИЧНІ (детермінований inference)! ✅")
Out[1]:

🔬 Експеримент: Dropout у train vs eval режимах

📊 Training Mode (Dropout активний):

1-й forward: [-0.421  1.254 -0.892]
2-й forward: [ 0.184 -0.673  1.109]

Різниця: 2.548721 ← Виходи РІЗНІ через випадкову маску! ✅

📊 Evaluation Mode (Dropout вимкнений):

1-й forward: [-0.105  0.428 -0.312]
2-й forward: [-0.105  0.428 -0.312]

Різниця: 0.000000 ← Виходи ІДЕНТИЧНІ (детермінований inference)! ✅

КРИТИЧНА ПОМИЛКА:
# ❌ НЕПРАВИЛЬНО
model.train()  # Навчання завершено, але режим не змінено
# Тестування з активним Dropout!
for images, labels in test_loader:
    outputs = model(images)  # Випадкові результати кожного разу!
# ✅ ПРАВИЛЬНО
model.eval()  # ОБОВ'ЯЗКОВО перед inference
with torch.no_grad():
    for images, labels in test_loader:
        outputs = model(images)  # Детермінований, стабільний результат

Де розміщувати Dropout у архітектурі

Не всі шари потребують Dropout. Існують перевірені практикою паттерни.

Для Fully Connected мереж (MLP)

Стандартний паттерн:

Linear → ReLU → Dropout(0.5) → Linear → ReLU → Dropout(0.5) → Linear

Правила:

  • Dropout після активації (ReLU)
  • Dropout rate 0.5 для великих FC шарів (512+ нейронів)
  • НЕ застосовувати до останнього output шару

Для Convolutional мереж (CNN)

Рекомендовано:

# Conv блоки: БЕЗ Dropout (BatchNorm достатньо)
Conv → BatchNorm → ReLU → Pool
Conv → BatchNorm → ReLU → Pool

# Classifier (FC шари): З Dropout
Flatten → Linear → ReLU → Dropout(0.5) → Linear

Чому не використовувати Dropout у Conv шарах?

  1. Просторова кореляція: Сусідні пікселі корелюють, тому вимикання окремих feature maps менш ефективне
  2. BatchNorm вже регуляризує: Conv шари з BN рідко перенавчуються
  3. Dropout2d дорогий: Вимикає цілі feature maps, що значно уповільнює навчання
Dropout2d для Conv шарів:PyTorch надає nn.Dropout2d, який вимикає цілі канали замість окремих пікселів:
nn.Dropout2d(p=0.1)  # Вимикає 10% каналів
Це краще працює для зображень, але рідко використовується через BatchNorm.

Таблиця рекомендацій

Тип шаруDropout rateКоментар
Input шар0.1-0.2Рідко використовується (краще data augmentation)
Conv шариНе застосовуватиBatchNorm + data augmentation достатньо
FC шари (великі 512+)0.5Класичне значення з оригінальної статті
FC шари (малі < 256)0.2-0.3Менший rate для менших шарів
Output шарНіколи!Псує калібрацію ймовірностей
LSTM/GRU0.2-0.3Між шарами, не всередині рекурентних з'єднань

Комбінування регуляризаційних технік

Рідко використовується лише одна техніка регуляризації. Найкращі результати досягаються комбінуванням кількох методів.

🛡️ Batch Normalization

Ефект: Стабілізація навчання + імпліцитна регуляризація через шум у батч-статистиці

Застосування: Після кожного Conv/Linear перед активацією

Сила: Середня (регуляризація — побічний ефект, не основна мета)

💧 Dropout

Ефект: Запобігання ко-адаптації нейронів + ансамбль ефект

Застосування: Після активацій у FC шарах (p=0.2-0.5)

Сила: Сильна для великих FC шарів

⚖️ Weight Decay (L2 Regularization)

Ефект: Штраф за великі ваги, підштовхує до простіших розв'язків

Застосування: Параметр оптимізатора (weight_decay=5e-4)

Сила: Помірна, але універсальна

🎨 Data Augmentation

Ефект: Збільшення розмаїття даних, модель бачить варіації кожного зображення

Застосування: Трансформації при завантаженні (transforms.RandomHorizontalFlip())

Сила: Найпотужніша для комп'ютерного зору!

Стратегії комбінування

regularization_combinations_cifar10.ipynb
Python 3 (ipykernel)
Code
md

Експеримент: комбінування регуляризаційних технік на CIFAR-10

Порівняємо 5 стратегій:

  1. Baseline — без регуляризації (очікуємо overfit)
  2. BatchNorm only
  3. Dropout only (p=0.5)
  4. BatchNorm + Dropout (p=0.3, зменшений через BN)
  5. Повний стек: BatchNorm + Dropout + Weight Decay + Data Augmentation
In [1]:
850.2s
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import time

# 5 різних моделей
class CNN_Baseline(nn.Module):
    """Без регуляризації"""
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(128, 256, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
        )
        self.classifier = nn.Sequential(
            nn.Linear(256*4*4, 512), nn.ReLU(),
            nn.Linear(512, 10)
        )
    def forward(self, x):
        x = self.features(x)
        x = x.view(x.size(0), -1)
        return self.classifier(x)

class CNN_BatchNormOnly(nn.Module):
    """Лише BatchNorm"""
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(), nn.MaxPool2d(2),
        )
        self.classifier = nn.Sequential(
            nn.Linear(256*4*4, 512), nn.BatchNorm1d(512), nn.ReLU(),
            nn.Linear(512, 10)
        )
    def forward(self, x):
        x = self.features(x)
        x = x.view(x.size(0), -1)
        return self.classifier(x)

class CNN_DropoutOnly(nn.Module):
    """Лише Dropout"""
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(128, 256, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
        )
        self.classifier = nn.Sequential(
            nn.Linear(256*4*4, 512), nn.ReLU(), nn.Dropout(0.5),
            nn.Linear(512, 10)
        )
    def forward(self, x):
        x = self.features(x)
        x = x.view(x.size(0), -1)
        return self.classifier(x)

class CNN_BN_Dropout(nn.Module):
    """BatchNorm + Dropout (зменшений)"""
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(), nn.MaxPool2d(2),
        )
        self.classifier = nn.Sequential(
            nn.Linear(256*4*4, 512), nn.BatchNorm1d(512), nn.ReLU(), nn.Dropout(0.3),
            nn.Linear(512, 10)
        )
    def forward(self, x):
        x = self.features(x)
        x = x.view(x.size(0), -1)
        return self.classifier(x)

# Трансформації
transform_simple = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))
])

transform_augmented = transforms.Compose([
    transforms.RandomHorizontalFlip(),
    transforms.RandomCrop(32, padding=4),
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))
])

# Датасети
train_simple = datasets.CIFAR10('./data', train=True, download=True, transform=transform_simple)
train_augmented = datasets.CIFAR10('./data', train=True, download=True, transform=transform_augmented)
test_dataset = datasets.CIFAR10('./data', train=False, download=True, transform=transform_simple)

def train_model(model, train_loader, test_loader, epochs=20, lr=0.01, weight_decay=0):
    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, weight_decay=weight_decay)
    scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
    
    history = {'train_acc': [], 'test_acc': []}
    
    for epoch in range(epochs):
        # Train
        model.train()
        train_correct, train_total = 0, 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()
            _, predicted = outputs.max(1)
            train_total += labels.size(0)
            train_correct += predicted.eq(labels).sum().item()
        
        # Test
        model.eval()
        test_correct, test_total = 0, 0
        with torch.no_grad():
            for images, labels in test_loader:
                images, labels = images.to(device), labels.to(device)
                outputs = model(images)
                _, predicted = outputs.max(1)
                test_total += labels.size(0)
                test_correct += predicted.eq(labels).sum().item()
        
        train_acc = 100. * train_correct / train_total
        test_acc = 100. * test_correct / test_total
        history['train_acc'].append(train_acc)
        history['test_acc'].append(test_acc)
        scheduler.step()
    
    return history

# Експеримент
configs = [
    ("Baseline (без регул.)", CNN_Baseline(), train_simple, 0.01, 0),
    ("BatchNorm only", CNN_BatchNormOnly(), train_simple, 0.1, 0),
    ("Dropout only", CNN_DropoutOnly(), train_simple, 0.01, 0),
    ("BN + Dropout", CNN_BN_Dropout(), train_simple, 0.1, 0),
    ("Повний стек", CNN_BN_Dropout(), train_augmented, 0.1, 5e-4),
]

results = {}
test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False, num_workers=2)

print("🚀 Експеримент: порівняння регуляризаційних стратегій\n")

for name, model, train_data, lr, wd in configs:
    print(f"📊 Навчання: {name}")
    torch.manual_seed(42)
    train_loader = DataLoader(train_data, batch_size=128, shuffle=True, num_workers=2)
    history = train_model(model, train_loader, test_loader, epochs=20, lr=lr, weight_decay=wd)
    results[name] = history
    
    final_train = history['train_acc'][-1]
    final_test = history['test_acc'][-1]
    gap = final_train - final_test
    print(f"   Фінал: Train={final_train:.2f}%, Test={final_test:.2f}%, Gap={gap:.2f}%\n")

print("🎉 Експеримент завершено!")
Out[1]:

🚀 Експеримент: порівняння регуляризаційних стратегій

📊 Навчання: Baseline (без регул.)

  • Фінал: Train=92.34%, Test=68.52%, Gap=23.82% ← Сильне перенавчання!

📊 Навчання: BatchNorm only

  • Фінал: Train=89.67%, Test=76.84%, Gap=12.83%

📊 Навчання: Dropout only

  • Фінал: Train=84.23%, Test=74.15%, Gap=10.08%

📊 Навчання: BN + Dropout

  • Фінал: Train=88.45%, Test=79.23%, Gap=9.22%

📊 Навчання: Повний стек

  • Фінал: Train=91.12%, Test=83.67%, Gap=7.45% 🏆

🎉 Експеримент завершено!

In [2]:
0.9s
import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 2, figsize=(16, 6))

colors = {
    "Baseline (без регул.)": '#ef4444',
    "BatchNorm only": '#f59e0b',
    "Dropout only": '#3b82f6',
    "BN + Dropout": '#8b5cf6',
    "Повний стек": '#22c55e'
}

# Графік 1: Test Accuracy
ax1 = axes[0]
for name, history in results.items():
    ax1.plot(range(1, 21), history['test_acc'], 
             linewidth=2.5, label=name, color=colors[name], marker='o', markersize=4)
ax1.set_xlabel('Епоха', fontsize=13)
ax1.set_ylabel('Test Accuracy (%)', fontsize=13)
ax1.set_title('Точність на тестовому датасеті', fontsize=15, fontweight='bold')
ax1.legend(fontsize=10, loc='lower right')
ax1.grid(alpha=0.3)
ax1.set_ylim([60, 90])

# Графік 2: Overfitting Gap
ax2 = axes[1]
final_gaps = {}
for name, history in results.items():
    gap = history['train_acc'][-1] - history['test_acc'][-1]
    final_gaps[name] = gap

names = list(final_gaps.keys())
gaps = list(final_gaps.values())
bars = ax2.barh(names, gaps, color=[colors[n] for n in names], alpha=0.7)

for i, (bar, gap) in enumerate(zip(bars, gaps)):
    ax2.text(gap + 0.5, i, f'{gap:.1f}%', va='center', fontsize=11, fontweight='bold')

ax2.set_xlabel('Train-Test Accuracy Gap (%)', fontsize=13)
ax2.set_title('Міра перенавчання (менше = краще)', fontsize=15, fontweight='bold')
ax2.grid(axis='x', alpha=0.3)
ax2.invert_yaxis()

plt.tight_layout()
plt.show()

# Підсумкова таблиця
print("\n📊 Порівняльна таблиця:\n")
print(f"{'Стратегія':<25} | {'Train Acc':<10} | {'Test Acc':<10} | {'Gap':<8} | {'Overfit?':<10}")
print("-" * 80)
for name, history in results.items():
    train = history['train_acc'][-1]
    test = history['test_acc'][-1]
    gap = train - test
    overfit = "❌ Так" if gap > 15 else "⚠️ Помірно" if gap > 10 else "✅ Ні"
    print(f"{name:<25} | {train:<10.2f} | {test:<10.2f} | {gap:<8.2f} | {overfit:<10}")
Out[2]:

📊 Порівняльна таблиця:

СтратегіяTrain AccTest AccGapOverfit?
Baseline (без регул.)92.34%68.52%23.82%❌ Так
BatchNorm only89.67%76.84%12.83%⚠️ Помірно
Dropout only84.23%74.15%10.08%⚠️ Помірно
BN + Dropout88.45%79.23%9.22%✅ Ні
Повний стек91.12%83.67%7.45%✅ Ні

🏆 Переможець: Повний стек

83.67% test accuracy — найкращий результат!

Композиція:

  • BatchNorm для стабілізації
  • Dropout (p=0.3) для ансамблю
  • Weight Decay (5e-4) для простоти ваг
  • Data Augmentation для розмаїття даних

Gap лише 7.45% — мінімальне перенавчання.

⚠️ Baseline: катастрофа

Gap 23.82% — модель запам'ятала тренувальні дані замість вивчення паттернів.

68.52% test accuracy — гірше за BN+Dropout на 15%!

💡 Ключовий інсайт

Одна техніка добре, дві краще, три — найкраще!

BatchNorm + Dropout краще за кожну окремо.
Додавання Data Augmentation дає ще +4% test accuracy.


Рекомендації для різних розмірів датасетів

Стратегія регуляризації залежить від розміру датасету та складності моделі.

📦 Малий датасет (< 10K)

Приклад: Власний датасет медичних знімків (5000 зображень)

Стратегія:

  • ✅ Data Augmentation (агресивна): horizontal flip, rotation ±15°, color jitter, random erasing
  • ✅ Dropout (p=0.5-0.6): Сильна регуляризація FC шарів
  • ✅ Weight Decay (1e-3): Більший штраф
  • ✅ BatchNorm: Завжди
  • ✅ Transfer Learning: Використовуйте pretrained модель!

Ризик: Дуже високий overfit. Модель може досягти 100% train accuracy при 60% val.

📊 Середній датасет (10K-100K)

Приклад: CIFAR-10 (50K), Fashion-MNIST (60K)

Стратегія:

  • ✅ Data Augmentation (помірна): horizontal flip, random crop
  • ✅ BatchNorm: Обов'язково
  • ✅ Dropout (p=0.2-0.3): Помірний
  • ✅ Weight Decay (5e-4): Стандартний
  • ⚠️ Transfer Learning: Опційно (можна навчити з нуля)

Баланс: Достатньо даних для навчання з нуля, але регуляризація все ще важлива.

🗄️ Великий датасет (> 100K)

Приклад: ImageNet (1.2M), MS COCO (300K)

Стратегія:

  • ✅ Data Augmentation (легка): horizontal flip, minimal crop
  • ✅ BatchNorm: Так
  • ⚠️ Dropout (p=0.1-0.2 або ні): Може не знадобитися
  • ⚠️ Weight Decay (1e-4): Менший
  • ❌ Transfer Learning: Навчаємо з нуля

Особливість: Великий датасет сам по собі регуляризує. Надмірна регуляризація може погіршити результати (underfitting).


Висновки та практичні поради

Універсальний рецепт регуляризації для CNN:
# Conv blocks
Conv → BatchNorm → ReLU → (Pool)

# Classifier
Flatten → Linear → BatchNorm → ReLU → Dropout(0.3) → Linear

# Optimizer
SGD(lr=0.1, momentum=0.9, weight_decay=5e-4)

# Data
RandomHorizontalFlip() + RandomCrop(padding=4) + Normalize()
Ця конфігурація працює для 80% задач комп'ютерного зору!

У наступній статті ми розглянемо проєктування глибоких мереж — як будувати архітектури з 20+ шарами за допомогою Residual Connections та інших архітектурних патернів.

Copyright © 2026