mlp cnn mnist

Od MLP do CNN. Sieci neuronowe do rozpoznawania cyfr MNIST

Budujemy i porównujemy cztery architektury sieci neuronowych w PyTorch, wizualizujemy wydajność, badamy złożoność w porównaniu do dokładności i pokazujemy, dlaczego CNN-y są najlepsze w klasyfikacji obrazów.

Daniel Gustaw

Daniel Gustaw

14 min read

Od MLP do CNN. Sieci neuronowe do rozpoznawania cyfr MNIST

Wprowadzenie

Zbiór danych MNIST to klasyczny punkt odniesienia w sztucznej inteligencji, składający się z 70 000 grayscale obrazów ręcznie pisanych cyfr (28×28 pikseli). Jest wystarczająco mały, aby szybko trenować, ale wystarczająco złożony, aby ujawniać różnice w wydajności modeli—idealny do eksperymentów z sieciami neuronowymi.

Podczas gdy Wielowarstwowe Perceptrony (MLP) mogą technicznie klasyfikować dane obrazowe, traktują piksele jako płaskie wektory, ignorując wzorce przestrzenne. Konwolucyjne Sieci Neuronowe (CNN), z drugiej strony, są zaprojektowane do wykorzystania lokalnych struktur w obrazach—krawędzi, krzywych, tekstur—co czyni je znacznie bardziej efektywnymi w zadaniach wizualnych.

W tym wpisie porównam cztery architektury: prosty MLP, minimalny TinyCNN, zbalansowany CNN oraz cięższy StrongCNN. Przyjrzymy się dokładności, czasowi treningu oraz liczbie parametrów, aby zrozumieć kompromisy.

Przygotowanie danych

Jak wspomniano wcześniej, korzystamy ze zbioru danych MNIST, dostępnego wygodnie przez torchvision.datasets. W zaledwie kilku linijkach kodu pobieramy i ładujemy dane, stosujemy podstawową transformację i przygotowujemy je do treningu:

from torchvision import datasets, transforms

transform = transforms.ToTensor()

train_data = datasets.MNIST(
    root="./data", train=True, download=True, transform=transform
)
test_data = datasets.MNIST(
    root="./data", train=False, download=True, transform=transform
)

Jedynym krokiem przetwarzania wstępnego tutaj jest transforms.ToTensor(), który konwertuje każde zdjęcie na tensor PyTorch i normalizuje jego wartości pikseli do zakresu [0.0, 1.0].

from torch.utils.data import DataLoader

BATCH_SIZE = 64

train_loader = DataLoader(train_data, batch_size=BATCH_SIZE, shuffle=True)
test_loader = DataLoader(test_data, batch_size=BATCH_SIZE)

Tasowanie danych treningowych unika zapamiętywania kolejności cyfr. Dla zestawu testowego pomijamy tasowanie, ale nadal używamy pakowania dla efektywności.

Możemy wyświetlić kilka przykładowych obrazów, aby zwizualizować zestaw danych:

import matplotlib.pyplot as plt

images, labels = next(iter(train_loader))

plt.figure(figsize=(6, 6))
for i in range(9):
    plt.subplot(3, 3, i + 1)
    plt.imshow(images[i][0], cmap="gray")
    plt.title(f"Label: {labels[i].item()}")
    plt.axis("off")
plt.tight_layout()
plt.savefig("mnist_digits.svg")
plt.show()

Trening i Ocena

Teraz, gdy nasze dane są gotowe, czas nauczyć nasze modele czytać ręcznie napisane cyfry. Aby to zrobić, definiujemy standardową pętlę treningową i oceny, używając idiomatycznej struktury PyTorch. Będziemy również śledzić złożoność modelu za pomocą prostej licznika parametrów — przydatne podczas porównywania różnych architektur.

Ustawienia Urządzenia i Epoki

Najpierw sprawdzamy, czy dostępny jest GPU. Jeśli tak, trening odbędzie się na CUDA; w przeciwnym razie korzystamy z CPU. Ustawiamy też rozsądny czas trwania treningu:

import torch

EPOCHS = 5
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")

Pięć epok może nie brzmieć jak dużo, ale na MNIST często wystarcza, aby uzyskać zaskakująco dobre wyniki — nawet z podstawowymi modelami.

Pętla treningowa

Oto nasza funkcja train(). To najbardziej standardowy kod: ustawienie modelu w trybie treningowym, pętla po partiach, obliczenie straty i aktualizacja wag.

def train(model, loader, optimizer, criterion):
    model.train()
    for x, y in loader:
        x, y = x.to(DEVICE), y.to(DEVICE)
        optimizer.zero_grad()
        output = model(x)
        loss = criterion(output, y)
        loss.backward()
        optimizer.step()

Ta funkcja nic nie zwraca—po prostu aktualizuje wewnętrzne parametry modelu. Podczas treningu nie zależy nam jeszcze na dokładności. Sprawdzimy to później.

Pętla ewaluacyjna

Po treningu oceniamy na zbiorze testowym. Model jest ustawiony w tryb eval(), gradienty są wyłączone, a my zbieramy dwa metryki: dokładność i średnią stratę krzyżową.

import torch.nn.functional as F

def test(model, loader):
    model.eval()
    correct = 0
    total = 0
    total_loss = 0.0
    with torch.no_grad():
        for x, y in loader:
            x, y = x.to(DEVICE), y.to(DEVICE)
            output = model(x)
            loss = F.cross_entropy(output, y)
            total_loss += loss.item()
            preds = output.argmax(dim=1)
            correct += (preds == y).sum().item()
            total += y.size(0)
    avg_loss = total_loss / len(loader)  # average over batches
    return correct / total, avg_loss

Zauważ, że obliczamy średnią stratę dla partii — a nie pojedynczych przykładów. To dobre zrównoważenie między śledzeniem wydajności a prostotą.

Liczba parametrów

Zanim porównamy architektury, warto wiedzieć, ile trenowalnych parametrów ma każda z nich. To małe narzędzie daje nam ich liczbę:

def count_params(model):
    return sum(p.numel() for p in model.parameters() if p.requires_grad)

Spoiler: StrongCNN ma ponad 450,000 parametrów, podczas gdy TinyCNN radzi sobie z tylko kilkoma tysiącami. To ogromna różnica — i świetny punkt wyjścia do głębszej analizy.

Uruchamiacz eksperymentów

Na koniec łączymy wszystko w jedną funkcję, która trenuje model, mierzy czas procesu, ocenia na zbiorze testowym i drukuje krótkie podsumowanie:

import time
import torch.optim as optim
import torch.nn as nn

def run_experiment(model_class, name):
    model = model_class().to(DEVICE)
    optimizer = optim.Adam(model.parameters())
    criterion = nn.CrossEntropyLoss()

    print(f"\n{name} ({count_params(model)} parameters)")
    start = time.time()
    for epoch in range(EPOCHS):
        train(model, train_loader, optimizer, criterion)
    duration = time.time() - start

    acc, loss = test(model, test_loader)
    print(f"Test Accuracy: {acc * 100:.2f}% | Loss: {loss:.2f} | Learning time: {duration:.1f}s")

Ta struktura jest wystarczająco elastyczna, aby działać z każdą klasą modelu, którą przekażesz — od prostych MLP po głębokie potwory konwolucyjne.

W następnej sekcji zdefiniujemy i przeanalizujemy cztery architektury: MLP, TinyCNN, CNN i StrongCNN.

Model 1: Perceptron wielowarstwowy (MLP)

Najprostsza architektura, którą rozważamy, to klasyczny Perceptron wielowarstwowy (MLP). Traktuje każdy obraz 28×28 jako płaski wektor 784 pikseli, ignorując strukturę przestrzenną, ale wciąż zdolny do uczenia się użytecznych cech poprzez warstwy w pełni połączone.

import torch.nn as nn

class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        h = 32  # number of hidden units
        self.model = nn.Sequential(
            nn.Flatten(),          # Flatten 28x28 image into a vector of length 784
            nn.Linear(28 * 28, h), # Fully connected layer: 784 → 32
            nn.ReLU(),             # Non-linear activation
            nn.Linear(h, 10)       # Output layer: 32 → 10 classes
        )

    def forward(self, x):
        return self.model(x)

Wyjaśnienie

Ten mały MLP ma stosunkowo niewiele parametrów i szybko się uczy, ale nie uchwyca relacji przestrzennych między pikselami, co ogranicza jego dokładność w przypadku danych obrazowych.

Wywoływanie

run_experiment(MLP, "MLP")

powinieneś zobaczyć:

MLP (25450 parameters)
Test Accuracy: 95.96% | Loss: 0.14 | Learning time: 8.7s

To będzie nasz punkt odniesienia do porównania modeli cnn.

Model 2: TinyCNN — Minimalna Konwolucyjna Sieć Neuronowa

Następnie przedstawiamy prostą architekturę TinyCNN, która wykorzystuje warstwy konwolucyjne do uchwycenia wzorców przestrzennych w obrazach. Ten model jest lekki, ale znacznie potężniejszy niż MLP w zadaniach związanych z obrazami.

Poniższy rysunek ilustruje architekturę TinyCNN:

import torch.nn as nn

class TinyCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Conv2d(1, 4, kernel_size=3, padding=1),   # 1x28x28 → 4x28x28
            nn.ReLU(),
            nn.MaxPool2d(2),                             # 4x14x14
            nn.Conv2d(4, 8, kernel_size=3, padding=1),   # 8x14x14
            nn.ReLU(),
            nn.MaxPool2d(2),                             # 8x7x7
            nn.Flatten(),
            nn.Linear(8 * 7 * 7, 10)                     # Direct to output layer
        )

    def forward(self, x):
        return self.net(x)

Przegląd architektury

======================================================================
Layer (type:depth-idx)                   Output Shape          Param #
======================================================================
TinyCNN                                  [64, 10]                  --
├─Sequential: 1-1                        [64, 10]                  --
│    └─Conv2d: 2-1                       [64, 4, 28, 28]           40
│    └─ReLU: 2-2                         [64, 4, 28, 28]           --
│    └─MaxPool2d: 2-3                    [64, 4, 14, 14]           --
│    └─Conv2d: 2-4                       [64, 8, 14, 14]           296
│    └─ReLU: 2-5                         [64, 8, 14, 14]           --
│    └─MaxPool2d: 2-6                    [64, 8, 7, 7]             --
│    └─Flatten: 2-7                      [64, 392]                 --
│    └─Linear: 2-8                       [64, 10]                3,930
======================================================================
Total params: 4,266
Trainable params: 4,266
Non-trainable params: 0
Total mult-adds (Units.MEGABYTES): 5.97
======================================================================
Input size (MB): 0.20
Forward/backward pass size (MB): 2.41
Params size (MB): 0.02
Estimated Total Size (MB): 2.63
======================================================================

Czasami cnn są przedstawiane graficznie jako następujący proces:

Najciekawsze jest to, że uzyskujemy lepsze wyniki niż mlp mając tylko 4266 parametrów zamiast 25450.

Tiny CNN (4266 parameters)
Test Accuracy: 97.96% | Loss: 0.06 | Learning time: 12.3s

Z mniejszą siecią o kilka razy możemy oczekiwać połowy błędów w porównaniu do poprzedniego modelu.

Sprawdźmy, jak nasza sieć poprawiłaby się, gdybyśmy utrzymali podobną liczbę parametrów jak w oryginalnym MLP.

Model 3: CNN — Zrównoważona Konwolucyjna Sieć Neuronowa

Teraz, gdy zobaczyliśmy, co może zrobić minimalny model konwolucyjny, zwiększmy nieco skalę.

Model CNN poniżej został zaprojektowany w celu utrzymania zrównoważonego kompromisu między liczbą parametrów a wydajnością. Rozszerza możliwości ekstrakcji cech TinyCNN, wykorzystując więcej filtrów i ukrytą warstwę liniową przed ostatecznym wyjściem.

class CNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Conv2d(1, 8, kernel_size=3, padding=1),   # 1x28x28 → 8x28x28
            nn.ReLU(),
            nn.MaxPool2d(2),                             # 8x14x14
            nn.Conv2d(8, 16, kernel_size=3, padding=1),  # 16x14x14
            nn.ReLU(),
            nn.MaxPool2d(2),                             # 16x7x7
            nn.Flatten(),
            nn.Linear(16 * 7 * 7, 32),                   # Dense layer with 32 units
            nn.ReLU(),
            nn.Linear(32, 10)                            # Final output layer
        )

    def forward(self, x):
        return self.net(x)

Podział Architektury

W porównaniu do TinyCNN, ten model:

W poniższej tabeli znajdują się wszystkie warstwy, kształty wyjścia i parametry bez wymiaru wsadowego:

WarstwaKształt WyjściaParametry
Conv2d (1→8, 3×3)8×28×2880
ReLU8×28×280
MaxPool2d8×14×140
Conv2d (8→16, 3×3)16×14×141,168
ReLU16×14×140
MaxPool2d16×7×70
Flatten7840
Linear (784 → 32)3225,120
ReLU320
Linear (32 → 10)10330
Razem26,698

Z 26,698 parametrami, ten CNN ma rozmiar porównywalny z MLP (25,450), ale jest znacznie potężniejszy.

CNN (26698 parameters)
Test Accuracy: 98.22% | Loss: 0.05 | Learning time: 14.3s

Kluczowe obserwacje

Ten model demonstruje idealny punkt: dobrą głębokość, rozsądny rozmiar parametrów i doskonałą dokładność. Ale co jeśli w ogóle nie zależałoby nam na rozmiarze i chcieliśmy jeszcze bardziej zwiększyć wydajność?

Dowiedzmy się w następnej sekcji.

Model 4: StrongCNN — Głęboki konwolucyjny potwór

Jak dotąd przyjrzeliśmy się modelom, które balansują wydajność i prostotę. Ale co jeśli usuniemy ograniczenia i postawimy wszystko na wydajność?

StrongCNN to głębsza, bardziej ekspresyjna architektura, która wprowadza wiele warstw konwolucyjnych, wyższą liczbę kanałów i techniki regularizacji, takie jak Dropout, aby zapobiec przeuczeniu. Jest inspirowana najlepszymi praktykami z większych modeli wizji, ale wciąż na tyle kompaktowa, aby szybko trenować na MNIST.

class StrongCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Conv2d(1, 32, 3, padding=1),   # 1x28x28 → 32x28x28
            nn.ReLU(),
            nn.Conv2d(32, 32, 3, padding=1),  # 32x28x28
            nn.ReLU(),
            nn.MaxPool2d(2),                  # 32x14x14
            nn.Dropout(0.25),

            nn.Conv2d(32, 64, 3, padding=1),  # 64x14x14
            nn.ReLU(),
            nn.Conv2d(64, 64, 3, padding=1),  # 64x14x14
            nn.ReLU(),
            nn.MaxPool2d(2),                  # 64x7x7
            nn.Dropout(0.25),

            nn.Flatten(),
            nn.Linear(64 * 7 * 7, 128),
            nn.ReLU(),
            nn.Dropout(0.5),
            nn.Linear(128, 10)
        )

    def forward(self, x):
        return self.net(x)

Rozbicie architektury

Ten model składa się z czterech warstw konwolucyjnych w dwóch blokach, z rosnącą liczbą filtrów (32 → 64). Po każdym bloku:

======================================================================
Layer (type:depth-idx)                   Output Shape          Param #
======================================================================
StrongCNN                                [64, 10]                  --
├─Sequential: 1-1                        [64, 10]                  --
│    └─Conv2d: 2-1                       [64, 32, 28, 28]          320
│    └─ReLU: 2-2                         [64, 32, 28, 28]          --
│    └─Conv2d: 2-3                       [64, 32, 28, 28]        9,248
│    └─ReLU: 2-4                         [64, 32, 28, 28]          --
│    └─MaxPool2d: 2-5                    [64, 32, 14, 14]          --
│    └─Dropout: 2-6                      [64, 32, 14, 14]          --
│    └─Conv2d: 2-7                       [64, 64, 14, 14]       18,496
│    └─ReLU: 2-8                         [64, 64, 14, 14]          --
│    └─Conv2d: 2-9                       [64, 64, 14, 14]       36,928
│    └─ReLU: 2-10                        [64, 64, 14, 14]          --
│    └─MaxPool2d: 2-11                   [64, 64, 7, 7]            --
│    └─Dropout: 2-12                     [64, 64, 7, 7]            --
│    └─Flatten: 2-13                     [64, 3136]                --
│    └─Linear: 2-14                      [64, 128]             401,536
│    └─ReLU: 2-15                        [64, 128]                 --
│    └─Dropout: 2-16                     [64, 128]                 --
│    └─Linear: 2-17                      [64, 10]                1,290
======================================================================
Total params: 467,818
Trainable params: 467,818
Non-trainable params: 0
Total mult-adds (Units.GIGABYTES): 1.20
======================================================================
Input size (MB): 0.20
Forward/backward pass size (MB): 38.61
Params size (MB): 1.87
Estimated Total Size (MB): 40.68
======================================================================

Z niemal pół miliona parametrów, ten model przyćmiewa inne pod względem pojemności. Ale się opłaca.

Strong CNN (467818 parameters)
Test Accuracy: 99.09% | Loss: 0.03 | Learning time: 75.0s

Kluczowe obserwacje

Ten model jest przesadny dla MNIST - ale o to chodzi. Ilustruje, jak daleko można zajść, gdy dokładność jest jedynym celem.

Podsumowanie: Porównanie wszystkich czterech modeli

Podsumujmy z boku:

ModelParametryDokładność testowaStrataCzas trenowania
MLP25 45095,96%0,148,7s
TinyCNN4 26697,96%0,0612,3s
CNN26 69898,22%0,0514,3s
StrongCNN467 81899,09%0,0375,0s

Wnioski

Eksperyment ten pokazuje, jak wybory architektoniczne wpływają na wydajność w sieciach neuronowych. Nawet dla prostego zbioru danych, takiego jak MNIST:

Modele konwolucyjne przewyższają MLP nie dlatego, że są „głębsze” lub „fajniejsze”, ale dlatego, że rozumieją, jak działają obrazy.

Te wyniki odzwierciedlają szersze trendy zauważane w badaniach stanowiących szczyt techniki:

Nasze eksperymenty potwierdzają, że CNN są nie tylko dokładniejsze niż MLP - są również bardziej wydajne i lepiej przystosowane do zadań opartych na obrazach. Chociaż modele SOTA nadal przesuwają granice, nasze praktyczne modele już osiągają wysoką dokładność przy ułamku złożoności.

https://github.com/gustawdaniel/cnn-mnist

Other articles

You can find interesting also.