Zainstaluj torch torchvision numpy marimo matplotlib przez pip. Pobierz notebook z linku poniżej i otwórz go przez marimo edit filename.py. Przykłady używają nn.CrossEntropyLoss na logitach i podziału 80/20 przed tasowaniem. Każdy wariant zaczyna od ziarna 42; wyniki mogą różnić się od zapisanych wykresów.

Poniższe wykresy i obserwacje liczbowe pochodzą z pierwotnych eksperymentów, w tym uruchomień Keras. To pomiary historyczne, a nie wyniki zaktualizowanego kodu PyTorch. Uruchom notebooki ponownie, aby zmierzyć wyniki PyTorch.

W poprzednim artykule zbudowaliśmy sieć w NumPy i PyTorch. Tutaj używamy tej samej konfiguracji bazowej: jednej warstwy ukrytej ze 128 neuronami, współczynnika uczenia 0.1 i batcha 32.

Te wybory to hiperparametry: ustawienia, które dobieramy sami, zamiast parametrów uczonych przez model. Będziemy zmieniać je pojedynczo, aby zobaczyć ich wpływ na trening. Wyniki posłużą jako punkt wyjścia dla tego modelu i zbioru danych; inne zadania mogą wymagać innych ustawień.

Każdy eksperyment poniżej ma interaktywny wykres. Możesz klikać etykiety, żeby pokazywać lub ukrywać poszczególne przebiegi, przełączać się między widokiem straty i trafności, włączyć „show baseline”, żeby dołączyć początkowy pomiar z losowymi wagami (epoka 0), oraz rozwinąć sekcję „Computation log”, żeby zobaczyć surowe wyjście treningu. Wykresy domyślnie pokazują metryki treningowe — nasza analiza opiera się na wartościach treningowych — ale możesz kliknąć „val”, żeby nałożyć metryki walidacyjne dla porównania.

Użyjemy tego samego zbioru danych i tej samej konfiguracji modelu co w poprzednim artykule:

import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
from torchvision.datasets import MNIST
import numpy as np

train_data = MNIST("./data", train=True, download=True)
test_data = MNIST("./data", train=False, download=True)
# NumPy arrays also work with the from-scratch implementation.
X_train = train_data.data.numpy().reshape(-1, 784).astype("float32") / 255.0
X_test = test_data.data.numpy().reshape(-1, 784).astype("float32") / 255.0
y_train = train_data.targets.numpy()
y_test = test_data.targets.numpy()

def make_model(hidden_sizes=(128,), activation=nn.ReLU):
    torch.manual_seed(42)
    layers = []
    n_inputs = 784
    for size in hidden_sizes:
        layers.extend([nn.Linear(n_inputs, size), activation()])
        n_inputs = size
    layers.append(nn.Linear(n_inputs, 10))
    return nn.Sequential(*layers)

def run_epoch(model, X, y, batch_size=32, optimizer=None):
    training = optimizer is not None
    model.train(training)
    device = next(model.parameters()).device
    loader = DataLoader(
        TensorDataset(torch.as_tensor(X, dtype=torch.float32),
                      torch.as_tensor(y, dtype=torch.long)),
        batch_size=batch_size, shuffle=training,
    )
    loss_fn = nn.CrossEntropyLoss()
    total_loss, correct = 0.0, 0
    with torch.enable_grad() if training else torch.no_grad():
        for xb, yb in loader:
            xb, yb = xb.to(device), yb.to(device)
            if training:
                optimizer.zero_grad()
            logits = model(xb)
            loss = loss_fn(logits, yb)
            if training:
                loss.backward()
                optimizer.step()
            total_loss += loss.item() * len(yb)
            correct += (logits.argmax(dim=1) == yb).sum().item()
    return total_loss / len(y), correct / len(y)


def train_torch(model, X, y, epochs=5, lr=0.1, batch_size=32,
                val_fraction=0.2, optimizer=None, include_initial=False):
    split = int(len(X) * (1 - val_fraction))
    X_fit, y_fit = X[:split], y[:split]
    X_val, y_val = X[split:], y[split:]
    if optimizer is None:
        optimizer = torch.optim.SGD(model.parameters(), lr=lr)
    history = {key: [] for key in ("loss", "accuracy", "val_loss", "val_accuracy")}
    for epoch in range(0 if include_initial else 1, epochs + 1):
        loss, accuracy = run_epoch(
            model, X_fit, y_fit, batch_size, optimizer if epoch else None)
        val_loss, val_accuracy = run_epoch(model, X_val, y_val, batch_size)
        for key, value in zip(history, (loss, accuracy, val_loss, val_accuracy)):
            history[key].append(value)
        print(f"Epoch {epoch}/{epochs} — loss: {loss:.4f}, accuracy: {accuracy:.4f}, "
              f"val_loss: {val_loss:.4f}, val_accuracy: {val_accuracy:.4f}")
    return history

Współczynnik uczenia

Gdy przyglądaliśmy się temu, jak uczą się sieci, zobaczyliśmy, że współczynnik uczenia steruje wielkością kroku spadku gradientu. Za mały i trening idzie bardzo wolno; za duży i krok przestrzeliwuje minimum, co utrudnia osiągnięcie niskiej straty. Pokazaliśmy to na modelu z dwoma parametrami i interaktywnym widżecie. Teraz zobaczmy dokładnie to samo zjawisko na prawdziwej sieci ze 100 000+ parametrów.

Porównamy pięć współczynników uczenia przez dziesięć epok:

Pobierz notebook PyTorch (.py)
for lr in [0.001, 0.01, 0.1, 1.0, 10.0]:
    model = make_model()
    history = train_torch(model, X_train, y_train, epochs=10, lr=lr,
                          include_initial=True)

Zobaczmy, jak strata zmienia się z epoki na epokę dla każdego współczynnika uczenia:

Loss by Learning Rate
Computation log

Włącz „show baseline”, aby zobaczyć pomiary sprzed treningu. Początkowa strata wynosi około 2.3, z różnicami wynikającymi z losowej inicjalizacji. Przypisanie każdej z dziesięciu klas prawdopodobieństwa 0.1 daje −log⁡(0.1)≈2.3-\log(0.1) \approx 2.3, ale losowe wagi nie gwarantują dokładnie jednostajnego rozkładu.

Przy lr = 10.0 trening w tym przebiegu się nie udaje. Strata skacze do 12.2 w pierwszej epoce, a później pozostaje w okolicach 2.5. Trafność treningowa utrzymuje się blisko 10%. Te wyniki pokazują, że model nie uczy się rozróżniać cyfr, ale same nie dowodzą, że zwraca tę samą predykcję dla każdego obrazu.

Wyłączmy lr=10.0, klikając jego etykietę — bo jego skok do 12.2 ściska oś Y i utrudnia dostrzeżenie szczegółów pozostałych krzywych. Po ukryciu tej krzywej łatwiej porównać cztery pozostałe przebiegi:

  • lr = 0.001 — strata po dziesięciu epokach wynosi 0.41, więcej niż 0.33 osiągnięte przez lr=0.1 po jednej epoce. Uczenie przy takim kroku jest wolne.
  • lr = 0.01 — strata osiąga 0.18 i nadal maleje. Dłuższy trening może pomóc, ale nie musi doprowadzić do tego samego rozwiązania co lr=0.1.
  • lr = 0.1 — najniższa końcowa strata treningowa w tych przebiegach, około 0.03. Największa początkowa poprawa następuje w pierwszych dwóch–trzech epokach.
  • lr = 1.0 — strata treningowa dochodzi do około 0.15, a walidacyjna waha się w końcowych epokach w okolicach 0.20–0.24. Włącz krzywe walidacyjne, aby zobaczyć również spadek trafności pod koniec treningu.

Współczynnik uczenia zależy też od optymalizatora. Adam korzysta z bieżących oszacowań gradientów i ich kwadratów, aby skalować aktualizacje poszczególnych parametrów, ale nadal wymaga dobrania współczynnika uczenia. Optymalizatory, harmonogramy współczynnika uczenia i warmup omówimy w następnym artykule.

Rozmiar batcha

Porównamy rozmiary batcha 1, 32, 256 i 60 000. Po odłożeniu 20% danych na walidację do treningu pozostaje 48 000 obrazów, więc batch_size=60000 umieszcza wszystkie te obrazy w jednym batchu.

Pobierz notebook PyTorch (.py)
for bs in [1, 32, 256, 60000]:
    model = make_model()
    history = train_torch(model, X_train, y_train, epochs=10, batch_size=bs,
                          include_initial=True)

Zobaczmy, jak strata zmienia się z epoki na epokę dla każdego rozmiaru batcha:

Loss by Batch Size
Computation log

Środek stawki — bs=32 i bs=256 — sprawdza się w obu przypadkach, przy czym bs=32 zbiega szybciej:

  • bs = 32 — strata spada gwałtownie z 2.4 do 0.03 do dziesiątej epoki. Przy 1500 batchach na epokę model dostaje częste aktualizacje z gradientami, które są zaszumione, ale z grubsza poprawne.
  • bs = 256 — wolniej, ale stabilnie. Strata spada do 0.17 po 10 epokach, a krzywa wciąż opada. Każda epoka ma tylko 188 batchy (wobec 1500 przy bs=32), czyli mniej aktualizacji — ale każda opiera się na pewniejszej średniej gradientu.

Najmniejszy i największy batch zachowują się inaczej:

  • bs = 1 — niestabilny trening przy lr=0.1. Każdy gradient pochodzi z jednego obrazu, a duże aktualizacje mogą cofać wcześniejszy postęp. Uśrednianie po większej liczbie obrazów ogranicza tę zmienność.
  • bs = 60000 — strata spada z około 2.4 do 1.6 po dziesięciu epokach. Jedna aktualizacja na epokę daje tylko dziesięć kroków gradientu, wobec 15 000 przy bs=32. Przy tym współczynniku uczenia pełny gradient nie rekompensuje tak małej liczby aktualizacji.

Rozmiar batcha 32 sprawdził się w tym eksperymencie. Większe batche potrzebują więcej pamięci na aktywacje i mogą zwiększyć wydajność sprzętu, ale krótsza epoka nie musi oznaczać szybszego osiągnięcia tej samej trafności walidacyjnej. Porównuj zarówno czas treningu, jak i jakość na walidacji.

Dobieraj rozmiar batcha i współczynnik uczenia razem. W przebiegu z bs=1 obniżenie lr z 0.1 do 0.001 pozwala osiągnąć stratę 0.09. Większe batche mogą korzystać z większego współczynnika uczenia, ale nie jest to uniwersalna reguła skalowania.

Głębokość i szerokość sieci

Nasza bazowa sieć ma jedną warstwę ukrytą ze 128 neuronami. Co się stanie, gdy pójdziemy szerzej, głębiej albo i tak, i tak? Sprawdźmy — wytrenujemy pięć wariantów, trzymając wszystko inne bez zmian (lr=0.1, bs=32, 10 epok):

Pobierz notebook PyTorch (.py)
configs = {
    "narrow (32)":  [32],
    "baseline (128)": [128],
    "wide (512)":   [512],
    "deep (2×128)": [128, 128],
    "deep (3×128)": [128, 128, 128],
}

for name, hidden_sizes in configs.items():
    model = make_model(hidden_sizes)
    history = train_torch(model, X_train, y_train, epochs=10,
                          include_initial=True)

Zobaczmy, jak strata zmienia się dla każdej architektury:

Loss by Architecture
Computation log

Kilka rzeczy rzuca się w oczy:

  • Większa szerokość pomaga w tym eksperymencie: „szerokość” oznacza tu liczbę neuronów w pojedynczej warstwie ukrytej — przejście z 32 na 128, a potem na 512 neuronów systematycznie obniża stratę. Szersza warstwa ma więcej parametrów do wykrywania wzorców. Ale 512 ma 4× więcej parametrów niż 128 przy tylko niewielkiej poprawie straty — malejące zyski.
  • Druga warstwa również pomaga: dodanie drugiej warstwy ukrytej (2×128) osiąga niższą stratę niż jednowarstwowa sieć bazowa, mimo że łączna liczba parametrów jest podobna. Głębsze sieci potrafią uczyć się cech hierarchicznych — pierwsza warstwa może wykrywać krawędzie, druga może łączyć krawędzie w kształty.
  • Trzecia warstwa nie poprawiła wyniku tego przebiegu: końcowa strata jest zbliżona do bazowej, a strata walidacyjna bardziej się waha. Same wykresy nie wskazują przyczyny ani nie dowodzą zanikania gradientów.

Dla tego zadania z MNIST jedna lub dwie warstwy ukryte o szerokości 128–512 to użyteczny punkt wyjścia. Zanim zwiększysz model, porównaj wyniki walidacyjne. Znaczenie ma też rodzaj warstwy: warstwy konwolucyjne wykorzystują strukturę przestrzenną obrazów, którą nasz model w pełni połączony pomija.

Funkcje aktywacji: sigmoida kontra ReLU

Porównamy sigmoidę i ReLU, aby sprawdzić zachowanie gradientów omówione w artykule teoretycznym. Sigmoida wprowadza w każdej warstwie pochodną nie większą niż 0.25; ReLU daje 1 dla neuronów aktywnych i 0 dla nieaktywnych. Te czynniki wpływają na propagację gradientów w głębokiej sieci.

W funkcji tworzącej model wybieramy nn.ReLU lub nn.Sigmoid. Porównujemy sieci z 1, 3 i 5 warstwami ukrytymi:

Pobierz notebook PyTorch (.py)
for n_layers in [1, 3, 5]:
    for activation in [nn.ReLU, nn.Sigmoid]:
        model = make_model([128] * n_layers, activation)
        history = train_torch(model, X_train, y_train, epochs=10,
                              include_initial=True)

Zobaczmy, jak strata zmienia się dla każdej kombinacji głębokości i funkcji aktywacji:

Loss by Activation Function
Computation log

W tych przebiegach sigmoida utrudnia trening wraz ze wzrostem głębokości:

  • 1 warstwa ukryta — obie aktywacje działają dobrze. Strata ReLU spada do 0.03 do dziesiątej epoki, sigmoidy do 0.15. Przy tylko jednej warstwie gradient przechodzi przez jedną aktywację, więc kurczenie gradientu przez sigmoidę nie ma większego znaczenia.
  • 3 warstwy ukryte — sigmoida zaczyna zostawać w tyle. Strata ReLU sięga 0.016, sigmoidy tylko 0.14 — prawie 10× więcej. Sigmoida jest zauważalnie wolniejsza w pierwszych epokach — jej strata w epoce 3 jest wciąż wyższa niż ReLU po epoce 1.
  • 5 warstw ukrytych — sigmoida całkowicie zawodzi. Strata ledwo rusza się z początkowej wartości ~2.3 przez wszystkie 10 epok — model praktycznie niczego się nie nauczył. Tymczasem ReLU z 5 warstwami dochodzi do straty 0.024 — praktycznie tyle samo co przy 1 i 3 warstwach.

Te wyniki są zgodne z problemem zanikającego gradientu. W pięciu warstwach sigmoidy iloczyn samych pochodnych aktywacji wynosi najwyżej 0.255≈0.0010.25^5 \approx 0.001. Na pełny gradient wpływają również macierze wag, więc nie jest to dokładne oszacowanie gradientu docierającego do pierwszej warstwy.

ReLU nie zmniejsza gradientu na etapie aktywacji dla aktywnych neuronów, co pomaga wyjaśnić uzyskane tu wyniki. Nie gwarantuje jednak stabilnych gradientów w całej sieci.

ReLU to dobry punkt wyjścia dla tego modelu. Leaky ReLU i ELU pozwalają na niezerowe gradienty przy ujemnych wejściach i mogą pomóc, gdy neurony pozostają nieaktywne. Inne architektury stosują inne aktywacje, np. GELU w części transformerów. W standardowym LSTM sigmoida steruje bramkami, a tanh służy do obliczania wartości kandydujących i przekształcania stanu komórki na wyjściu.

Liczba epok

We wszystkich powyższych eksperymentach trenowaliśmy przez 10 epok. Ale przez ile epok właściwie należy trenować? Sprawdźmy, trenując naszą najlepszą konfigurację (lr=0.1, rozmiar batcha 32) przez 50 epok i obserwując, co się stanie:

Pobierz notebook PyTorch (.py)
model = make_model()
history = train_torch(model, X_train, y_train, epochs=50, include_initial=True)

Strata treningowa spada z 0.33 w pierwszej epoce do 0.0015 w pięćdziesiątej. Raportowana trafność treningowa osiąga 100% do epoki 29. To bardzo dobre dopasowanie do zbioru treningowego, ale samo nie mówi, jak dobrze model generalizuje.

Strata walidacyjna maleje z 0.19 w epoce 1 do 0.075 około epoki 13, potem przestaje się poprawiać i zaczyna powoli rosnąć — 0.078 w epoce 20, 0.081 w epoce 30, 0.085 w epoce 50. Tymczasem trafność walidacyjna stabilizuje się w okolicach 98% od epoki 13 i ledwo drgnie przez pozostałe 37 epok.

Rosnąca strata walidacyjna jest oznaką przeuczenia. Strata treningowa nadal maleje, ale trafność walidacyjna prawie się nie zmienia, a strata walidacyjna rośnie. Model może przypisywać coraz większą pewność pozostałym błędnym predykcjom na nowych danych.

Zbyt mała liczba epok może prowadzić do niedouczenia, a dalszy trening może z czasem spowodować przeuczenie. Moment zatrzymania zależy od modelu, danych i innych hiperparametrów, dlatego monitorujemy wyniki walidacyjne.

Wczesne zatrzymanie polega na śledzeniu straty walidacyjnej w pętli. Zapisujemy kopię najlepszych wag i zatrzymujemy trening po pięciu kolejnych epokach bez poprawy:

from copy import deepcopy

model = make_model()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
split = int(0.8 * len(X_train))
best_loss, stale_epochs = float("inf"), 0
best_weights = deepcopy(model.state_dict())
for epoch in range(100):
    run_epoch(model, X_train[:split], y_train[:split], optimizer=optimizer)
    val_loss, _ = run_epoch(model, X_train[split:], y_train[split:])
    if val_loss < best_loss:
        best_loss, stale_epochs = val_loss, 0
        best_weights = deepcopy(model.state_dict())
    else:
        stale_epochs += 1
        if stale_epochs >= 5:
            break
model.load_state_dict(best_weights)

Limit wynosi 100 epok. deepcopy(model.state_dict()) zachowuje najlepsze wagi niezależnie od kolejnych aktualizacji, a load_state_dict przywraca je po treningu. Cierpliwość wynosi pięć epok.

Przeuczenie i wczesne zatrzymanie omówimy dokładniej w kolejnych artykułach.

Zautomatyzowane przeszukiwanie hiperparametrów

W tym artykule dobieraliśmy hiperparametry ręcznie — zmieniając po jednym i obserwując efekt. To buduje intuicję, ale nie skaluje się. Gdy masz dziesiątki hiperparametrów i tysiące możliwych kombinacji, potrzebujesz strategii przeszukiwania i narzędzia, które ją zautomatyzuje.

Strategie przeszukiwania różnią się sposobem wyboru kolejnej konfiguracji i wykorzystania dostępnego budżetu ocen:

  • Przeszukiwanie siatki (grid search). Wybierz skończony zestaw wartości dla każdego hiperparametru i oceń wszystkie kombinacje. Trzy parametry po pięć wartości wymagają 125 przebiegów. Ta metoda sprawdza się przy małych przestrzeniach poszukiwań i niedrogich ocenach.
  • Przeszukiwanie losowe (random search). Losuj kombinacje z wybranych zakresów lub rozkładów. Jeśli tylko kilka parametrów silnie wpływa na wynik, przy tym samym budżecie można sprawdzić więcej różnych wartości tych parametrów niż w siatce. Zobacz Bergstra i Bengio, 2012.
  • Optymalizacja współrzędnościowa (coordinate descent). Zacznij od jednej konfiguracji, zmieniaj po jednym parametrze i zachowuj poprawy. Powtarzaj proces, aby ponownie sprawdzać wcześniejsze wybory po zmianie innych parametrów. Pozwala to uniknąć oceny całej siatki, ale metoda może utknąć w punkcie, w którym żadna pojedyncza zmiana nie pomaga, choć zmiana kilku parametrów jednocześnie poprawiłaby wynik.
  • Optymalizacja bayesowska. Dopasuj model probabilistyczny do wcześniejszych konfiguracji i wyników, a następnie wybierz kolejną ocenę, równoważąc badanie nowych obszarów z wykorzystaniem obiecujących wyników. Może to zmniejszyć liczbę kosztownych ocen, ale skuteczność zależy od przestrzeni poszukiwań i modelu.

Strategię dobieraj do kosztu oceny, przestrzeni poszukiwań i dostępnego budżetu. Przeszukiwanie losowe daje użyteczny punkt odniesienia; przy kosztownych ocenach warto rozważyć przeszukiwanie lokalne lub metody oparte na modelu. Na przykład w pracy o AgentDiet dobierano cztery hiperparametry na zestawie 100 zadań dla agenta, zmieniając po jednym parametrze naraz. Po pierwszej rundzie autorzy zmienili dwa ustawienia, a w drugiej nie uzyskali dalszej poprawy.

Weights & Biases Sweeps i Optuna organizują wyszukiwanie hiperparametrów wokół pętli PyTorch i porównują wyniki walidacji dla różnych konfiguracji.

Podgląd obrazu

Zbliżaj palcami lub użyj + / −. Przeciągaj lub użyj strzałek. Esc zamyka podgląd.