01 - Wprowadzenie do PyTorch i sieci konwolucyjnych

Zaawansowane przetwarzanie obrazów

Politechnika Poznańska, Instytut Robotyki i Inteligencji Maszynowej

Ćwiczenie laboratoryjne 1: Wprowadzenie do PyTorch i sieci konwolucyjnych

Powrót do spisu treści ćwiczeń laboratoryjnych

Wstęp

Na zajęciach wykorzystywać będziemy język programowania Python oraz biblioteki dedykowane trenowaniu i ewaluacji sieci neuronowych.

Deep learning

Deep learning (uczenie głębokie) to klasa algorytmów uczenia maszynowego, która opiera się na wykorzystaniu głębokich sztucznych sieci neuronowych (DNNs). Wyróżnia się tym, że sieci te posiadają wiele warstw ukrytych, co pozwala im na automatyczne uczenie się hierarchicznych reprezentacji cech (ang. feature learning) bezpośrednio z surowych danych. To głębokie odwzorowanie umożliwia rozwiązywanie złożonych problemów, takich jak rozpoznawanie obrazów, przetwarzanie języka naturalnego czy generowanie mowy, często z precyzją przewyższającą metody uczenia maszynowego oparte na płytkich architekturach.

W ostatnich latach deep learning zyskał szczególną popularność dzięki kilku kluczowym zmianom:

Te czynniki pozwoliły na szybkie trenowanie sieci o wielu warstwach, które osiągają wyniki przewyższające inne algorytmy w zadaniach takich jak wizja komputerowa czy przetwarzanie języka naturalnego. Obecnie różne warianty sieci neuronowych dominują w tych dziedzinach, ustanawiając nowe standardy skuteczności.

PyTorch

Znając teorię, nic nie stoi na przeszkodzie, żeby sieci neuronowe zaimplementować przy użyciu numpy czy nawet zupełnie “od zera”. Niemniej jednak, jak to zwykle bywa, istnieją biblioteki, które znacząco to zadanie ułatwiają, i z racji bycia aktywnie wspieranymi przez społeczność są preferowanym wyborem.

PyTorch to popularna biblioteka open-source do uczenia maszynowego, szczególnie ceniona za dynamiczne grafy obliczeniowe, które umożliwiają elastyczne i intuicyjne debugowanie. Jest najczęściej wykorzystywaną biblioteką do tworzenia i trenowania sieci neuronowych, oferując szeroki zakres narzędzi i funkcji, które ułatwiają implementację złożonych modeli uczenia głębokiego. PyTorch jest szczególnie popularny wśród badaczy i inżynierów ze względu na swoją prostotę, elastyczność oraz silne wsparcie dla obliczeń na GPU i innych akceleratorach, co pozwala na efektywne trenowanie dużych modeli.

Pierwsza sieć neuronowa

Instalacja bibliotek

Aby zainstalować bibliotekę PyTorch, należy odwiedzić stronę https://pytorch.org/get-started/locally/ i postępować zgodnie z instrukcjami dostosowanymi do systemu operacyjnego oraz preferencji dotyczących wersji CUDA (jeśli korzystasz z GPU). Przykładowa komenda instalacyjna dla systemu Linux bez dedykowanej karty graficznej wygląda następująco:

pip3 install torch torchvision --index-url https://download.pytorch.org/whl/cu126

Import bibliotek

import torch
import torch.nn as nn
import torchvision

Import danych

Złota zasada, “dane są bardzo przydatne w machine learningu” nadal obowiązuje, zacznijmy więc od zaopatrzenia się w nie. Na obecnym etapie skorzystamy ze zbioru CIFAR10.

Zbiór CIFAR10 składa się z 60 000 kolorowych obrazków o wymiarach 32x32 piksele, podzielonych na 10 klas, takich jak samochód, ptak czy koń. Każda klasa zawiera dokładnie 6 000 przykładów, co czyni go dobrze zbalansowanym zbiorem danych. Jest to popularny benchmark w uczeniu maszynowym, szczególnie w zadaniach związanych z wizją komputerową. Dzięki swojej prostocie i dostępności, CIFAR10 jest często wykorzystywany do testowania nowych architektur sieci neuronowych oraz algorytmów optymalizacji. W torchvision można go łatwo zaimportować i używać, co czyni go idealnym wyborem na początkowe eksperymenty.

convert_to_tensor = torchvision.transforms.ToTensor()

train_cifar10 = torchvision.datasets.CIFAR10('CIFAR10', download=True, train=True, transform=convert_to_tensor)
test_cifar10 = torchvision.datasets.CIFAR10('CIFAR10', train=False, transform=convert_to_tensor)

Wszystkie zbiory danych, które mają być wykorzystane podczas treningu dziedziczą i implementują klasę torch.utils.data.Dataset. Oznacza to, między innymi, że obsługują indeksowanie za pomocą operatora [] oraz sprawdzanie ich rozmiaru poprzez wywołanie funkcji len(...).

Standardowo zbiór danych podczas indeksowania zwraca dwie wartości typu torch.Tensor, który jest odpowiednikiem macierzy numpy i wielu miejscach ma kompatybilny interfejs. W przypadku klasyfikacji pierwsza wartość zawiera próbkę danych, a druga etykietę.


💥 Zadanie 1 💥

Sprawdź następujące podstawowe fakty na temat wczytanych danych ze zbioru CIFAR10:


💥 Zadanie 2 💥

Znamy podstawowe fakty na temat właściwości wczytanych obrazów – natomiast zawsze warto jest też sprawdzić, jak dane wyglądają w rzeczywistości.

Wykorzytując bibliotekę matplotlib, utwórz wizualizację, przedstawiającą kilka(naście) przykładowych zdjęć z datasetu CIFAR10.

Zainspirować można się tutorialem PyTorch.

Lista etykiet dla zbioru CIFAR10:

cifar10_classes = ['airplane', 'car', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck']

Wczytywanie wsadów danych (batch)

Po utworzeniu całego obiektu klasy implementującej torch.utils.data.Dataset mamy już możliwość dostępu do poszczególnych próbek danych. Nie wystarcza to jednak do przeprowadzenia treningu. Ze względów takich jak szybkość i stabilność procesu uczenia, w jednym kroku do sieci podaje się wsad (batch) danych składający się z większej liczby próbek uczących.

Wczytywanie wielu próbek danych, szczególnie jeśli mają one podlegać pewnym modyfikacjom “w locie” może zajmować wiele czasu - warto więc żeby odbywało się w wielu wątkach lub procesach.

PyTorch oferuje warstwę abstrakcji zajmującą się większością wyżej wymienionych czynności za nas. Utwórzmy obiekty klasy torch.utils.data.DataLoader dla zbioru treningowego, walidacyjnego i testowego:

train_loader = torch.utils.data.DataLoader(train_cifar10, batch_size=64, num_workers=2)
val_loader = torch.utils.data.DataLoader(val_cifar10, batch_size=64, num_workers=2)
test_loader = torch.utils.data.DataLoader(test_cifar10, batch_size=64, num_workers=2)

Definiowanie modelu sieci neuronowej

Głębokie sieci neuronowe składają się z wielu warstw, które przetwarzają dane wejściowe w celu wydobycia istotnych cech i dokonania klasyfikacji lub regresji. W PyTorch definiowanie modelu sieci neuronowej odbywa się poprzez tworzenie klas dziedziczących po torch.nn.Module. Wewnątrz tych klas definiujemy warstwy sieci oraz sposób, w jaki dane przepływają przez te warstwy.

Jeśli chcemy zdefiniować prostą sieć neuronową, w której dany przepływają sekwencyjnie przez warstwy, możemy skorzystać z klasy torch.nn.Sequential, która umożliwia łatwe łączenie warstw w jeden model:

image_size = 3 * 32 * 32

model = nn.Sequential(
    # Warstwa "spłaszczająca", która odpowiada za rozwinięcie wszystkich wymiarów tensora wejściowego do jednowymiarowego,
    # ciągłego wektora. Wymagana ze względu na kolejną warstwę.
    nn.Flatten(),

    # Warstwa w pełni połączona - musi przyjąć in_features (tutaj: rozmiar obrazu) wartości i zwrócić out_features wartości
    nn.Linear(in_features=image_size, out_features=512),
    # Warstwa, która warstwą nie jest - aplikuje jedynie funkcję aktywacji ReLU. Argument inplace=True jest optymalizacją - modyfikuje tensor,
    # który otrzymuje zamiast tworzyć nowy.
    nn.ReLU(inplace=True),

    nn.Linear(in_features=512, out_features=256),
    nn.ReLU(inplace=True),

    nn.Linear(in_features=256, out_features=128),
    nn.ReLU(inplace=True),

    # Wartwa "końcowa", mająca tyle jednostek ile przewidywanych klas
    nn.Linear(in_features=128, out_features=len(cifar10_classes)),
    # NIE używamy funkcji aktywacji softmax, która normalizuje
    # wyjścia sieci tak, że sumują się one do 1, a wartości poszczególnych jednostek możemy traktować jako prawdopodobieństwa klas.
    # Podczas uczenia softmax zastosuje za nas funkcja kosztu "CrossEntropyLoss", jednak należy pamiętać
    # że ostatecznie wyjścia sieci nie będą znormalizowane.
)

Sama architektura modelu jest kluczowa, ale nie jest jedynym elementem, który należy precyzyjnie określić.

Oprócz niej szczególnie istotne są również:

Poniższy kod stworzy zarówno optimizer (używający algorytmu Stochastic Gradient Descent – patrz wykład) jak i loss function (cross-entropy) odpowiednie dla zadania klasyfikacji.

learning_rate = 1e-1  # 0.1
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)
loss_function = nn.CrossEntropyLoss()

Po ustaleniu jakiej metody optymalizacji i funkcji kosztu chcemy używać możemy rozpocząć trening modelu.

Trening modelu

Po przygotowaniu definicji modelu możemy sprawdzić jak wygląda całość architektury wyświetlając go:

print(model)

W “surowym” PyTorch to my jesteśmy odpowiedzialni za całość procesu uczenia, walidacji i treningu.

Rozpocznijmy od podstawowej pętli uczącej:

device = torch.device('cuda')  # wybierzmy odpowiednie urządzenie 'cpu' lub 'cuda'
model = model.to(device)  # przenieśmy nasz model na urządzenie

for epoch in range(10):  # przejdźmy po naszym zbiorze uczącym 10 razy
    running_loss = 0.0
    for i, data in enumerate(train_loader):
        # wczytajmy wsad (batch) wejściowy: dane i etykiety
        inputs, labels = data

        # przenieśmy nasze dane na urządzenie
        inputs = inputs.to(device)
        labels = labels.to(device)

        # wyzerujmy gradienty parametrów
        optimizer.zero_grad()

        # propagacja w przód, w tył i optymalizacja
        outputs = model(inputs)
        loss = loss_function(outputs, labels)
        loss.backward()
        optimizer.step()

        # drukowanie statystyk
        running_loss += loss.item()
        if i % 10 == 9:    # drukujmy co dziesiąty batch
            print('[%d, %5d] loss: %.3f' % (epoch + 1, i + 1, running_loss / 10))
            running_loss = 0.0

print('Finished Training')

Po przeprowadzonym treningu czas na testowanie naszego modelu. Jego dokładność (accuracy) musimy obliczyć sami. Podczas testowania możemy wyłączyć obliczanie gradientów, co znacznie przyspieszy obliczenia:


correct = 0
total = 0

with torch.no_grad():
    for data in test_loader:
        images, labels = data
        images = images.to(device)
        labels = labels.to(device)

        # wyznaczamy wyjście modelu
        outputs = model(images)
        # klasę której sieć przypisuje największą wartość uznajemy za wybraną
        _, predicted = torch.max(outputs.data, 1)
        total += labels.size(0)
        correct += (predicted == labels).sum().item()

print('Accuracy of the network on the test images: %d %%' % (100 * correct / total))

💥 Zadanie 3 💥

Przetestuj działanie powyższego kodu. Sprawdź jak zmienia się dokładność modelu w zależności od liczby epok treningowych. Sprawdź jak zmienia się przebieg treningu w zależności od współczynnika uczenia (learning rate).


Po przeprowadzeniu treningu z domyślnymi parametrami notebooka powinniśmy otrzymać wyniki na poziomie accuracy ~40% na zbiorze testowym.

Wyniki nie są takie złe, ~40% to i tak lepiej niż “losowe strzelanie”, które dla 10 zrównoważonych klas daje w teorii tylko 10% skuteczności.

Najgorszym elementem naszego treningu jest jednak brak kontroli nad przeuczeniem - nie używamy zbioru walidacyjnego.


💥 Zadanie 4 💥

Spróbuj wykorzystać zbiór walidacyjny tak, aby po każdej epoce testować na nim nasz model.


💥 Zadanie 5 💥 Spróbuj zmodyfikować architekturę sieci tak, aby uzyskać lepsze wyniki. Możesz spróbować dodać więcej warstw, zmienić liczbę neuronów w warstwach lub dodać warstwy normalizujące (np. BatchNorm). Sprawdź jak zmienia się dokładność modelu w zależności od wprowadzonych zmian.


💥 Zadanie 6 💥

Spróbuj zmienić metodę optymalizacji na inną (np. Adam, RMSprop) i sprawdź jak zmienia się dokładność modelu w zależności od wybranej metody optymalizacji.


💥 Zadanie 7 💥

Czy uczenie sieci w ten sposób jest przyjemne? Spróbuj rozważyć kilka dodatkowych faktów:

Zastanów się jak będzie wyglądała nasza pętla ucząca po dodaniu powyższych funkcjonalności (które i tak nie są wyczerpującym zbiorem wszystkich możliwych - wystaczy wspomnieć trening na wielu GPU lub maszynach).


Lightning

Powyższy kod, choć prosty, jest dość długi i zawiera wiele szczegółów implementacyjnych, które mogą odwracać uwagę od samej idei trenowania modelu. Na szczęście istnieją narzędzia, które pomagają uprościć ten proces. Jednym z nich jest biblioteka Lightning, która wprowadza wyższy poziom abstrakcji nad PyTorch, pozwalając skupić się na architekturze modelu i odpowiednim dobraniu hiperparametrów, a nie na szczegółach treningu.

Biblioteka Lightning ma za zadanie uczynić pracę z biblioteką PyTorch przyjemniejszą. Przede wszystkim porządkuje ona kod i zdejmuje z użytkownika końcowego konieczność ciągłego rozbudowywania i utrzymywania pętli uczącej.

Aby zainstalować bibliotekę Lightning, można użyć następującej komendy:

pip install lightning

💥 Zadanie 8 💥

Zapoznaj się z dokumentacją biblioteki Lightning, w szczególności z sekcją wprowadzającą. Spróbuj przepisać powyższy kod treningu modelu, wykorzystując Lightning. Efektem powinien być kod, który jest krótszy i bardziej przejrzysty, a jednocześnie zachowuje pełną funkcjonalność oryginalnego kodu. Wykonaj pełen trening z walidacją i testowaniem.


Modele dziedziczące po torch.nn.Module

W powyższym przykładzie zdefiniowaliśmy model sieci neuronowej za pomocą torch.nn.Sequential, co jest wygodne dla prostych, liniowych architektur. Jednak w przypadku bardziej złożonych modeli, które wymagają niestandardowego przepływu danych lub mają różne gałęzie, lepszym podejściem jest stworzenie własnej klasy dziedziczącej po torch.nn.Module. W ten sposób możemy zdefiniować zarówno warstwy sieci, jak i sposób, w jaki dane przepływają przez te warstwy, implementując metodę forward. Dodatkowo, możemy łatwo podglądać wielkość tensorów wychodzących z poszczególnych warstw.

Poniżej znajduje się przykład takiego podejścia:

class SimpleNN(nn.Module):
    def __init__(self, input_size=3 * 32 * 32, num_classes=10):
        super().__init__()
        
        # Warstwa spłaszczająca
        self.flatten = nn.Flatten()
        
        # Jedna współdzielona warstwa aktywacji
        self.relu = nn.ReLU(inplace=True)
        
        # Warstwy w pełni połączone
        self.fc1 = nn.Linear(input_size, 512)
        self.fc2 = nn.Linear(512, 256)
        self.fc3 = nn.Linear(256, 128)
        self.fc4 = nn.Linear(128, num_classes)
    
    def forward(self, x):
        # x ma wymiary: [batch_size, 3, 32, 32]
        
        x = self.flatten(x)    # [batch_size, 3072]
        
        x = self.fc1(x)        # [batch_size, 512]
        x = self.relu(x)
        
        x = self.fc2(x)        # [batch_size, 256]
        x = self.relu(x)
        
        x = self.fc3(x)        # [batch_size, 128]
        x = self.relu(x)
        
        x = self.fc4(x)        # [batch_size, 10]
        
        return x

# Utworzenie instancji modelu
model = SimpleNN(input_size=3*32*32, num_classes=10)
print(model)

Taki sposób definiowania modelu daje nam pełną kontrolę nad przepływem danych i ułatwia debugowanie, ponieważ możemy w metodzie forward dodać wypisywanie kształtów tensorów, warunki logiczne czy nawet pętle.


💥 Zadanie 9 💥

Przetestuj działanie modelu z wykorzystaniem biblioteki Lightning, ale tym razem zdefiniuj model jako klasę dziedziczącą po torch.nn.Module, tak jak w powyższym przykładzie. Upewnij się, że cały proces treningu, walidacji i testowania działa poprawnie.


Konwolucyjne sieci neuronowe

Wady sieci w pełni połączonych dla obrazów

Do tej pory w naszych eksperymentach wykorzystywaliśmy sieci w pełni połączone (ang. Fully Connected Networks lub Dense Networks). W tego typu sieciach każdy neuron w danej warstwie jest połączony z każdym neuronem z warstwy poprzedniej. Oznacza to, że jeśli mamy warstwę o 512 neuronach, a dane wejściowe składają się z 3072 wartości (32×32×3 dla kolorowego obrazu CIFAR10), to pierwsza warstwa będzie miała 512 × 3072 = 1,572,864 parametrów (plus 512 biasów).

Chociaż sieci w pełni połączone mogą modelować skomplikowane zależności, mają kilka istotnych wad, szczególnie w kontekście przetwarzania obrazów:

Wprowadzenie do sieci konwolucyjnych

Konwolucyjne sieci neuronowe (ang. Convolutional Neural Networks, CNN) zostały zaprojektowane specjalnie do przetwarzania danych o strukturze siatki, takich jak obrazy. Wykorzystują one operację konwolucji, która pozwala na:


💥 Zadanie 10 💥

Zaimplementuj prostą konwolucyjną sieć neuronową (CNN) do klasyfikacji obrazów z datasetu CIFAR10.

Architektura sieci:

Zaproponuj architekturę składającą się z następujących elementów:

1. **Pierwsza warstwa konwolucyjna:**
   - Dane wejściowe: 3 kanały (RGB)
   - Liczba filtrów: 32
   - Rozmiar kernela: 3×3
   - Padding: 1 (aby zachować wymiary)
   - Po konwolucji: funkcja aktywacji ReLU
   - Max pooling 2×2, który zmniejsza wymiary obrazu o połowę (32×32 → 16×16)

2. **Druga warstwa konwolucyjna:**
   - Dane wejściowe: 32 kanały
   - Liczba filtrów: 64
   - Rozmiar kernela: 3×3
   - Padding: 1
   - Po konwolucji: funkcja aktywacji ReLU
   - Max pooling 2×2 (16×16 → 8×8)

3. **Trzecia warstwa konwolucyjna (opcjonalna):**
   - Dane wejściowe: 64 kanały
   - Liczba filtrów: 128
   - Rozmiar kernela: 3×3
   - Padding: 1
   - Po konwolucji: funkcja aktywacji ReLU
   - Max pooling 2×2 (8×8 → 4×4)

4. **Warstwy w pełni połączone:**
   - Spłaszczenie (Flatten)
   - Warstwa ukryta z 256 neuronami + ReLU
   - Warstwa wyjściowa z 10 neuronami (liczba klas w CIFAR10)

Zaimplementuj powyższą sieć zgodnie z opisem architektury. Porównaj jej wyniki z siecią w pełni połączoną z poprzednich zadań. Eksperymentuj z architekturą: zmień liczbę filtrów, dodaj więcej warstw, wypróbuj dropout (nn.Dropout) lub batch normalization (nn.BatchNorm2d).

Wskazówki:


Własny dataset


💥 Zadanie 11 💥

Pobierz zbiór danych Cats vs Dogs z Chmury. Zbiór ten zawiera obrazy kotów i psów, które można wykorzystać do treningu modelu klasyfikacyjnego. Zaimplemetuj własny dataset dziedziczący po torch.utils.data.Dataset, który będzie wczytywał obrazy z katalogów i przypisywał im odpowiednie etykiety (0 dla kotów, 1 dla psów). Następnie użyj tego datasetu do treningu i ewaluacji modelu CNN w Lightning.

Architektura ResNet


💥 Zadanie 12 💥

Zapoznaj się z architekturą ResNet (Residual Network), która wprowadza pojęcie “residual blocks” umożliwiających trenowanie bardzo głębokich sieci. Zaimplementuj prostą wersję ResNet (np. ResNet-18) i przetestuj jej działanie na zbiorze Cats vs Dogs. Porównaj wyniki z poprzednimi modelami.