Sieć MLP do aproksymacji funkcji: implementacja oraz eksperymenty

Adam Rajfer (adam.rajfer@gmail.com), 2020

Cel projektu

Celem projektu było wykonanie programu do badania wpływu liczby warstw sieci MLP, a także liczby neuronów w poszczególnych warstwach, na jakość aproksymacji funkcji. Ponadto zbadano wpływ parametryzacji algorytmu propagacji wstecznej na wartość błędu modelu. Badania zostały przeprowadzone dla trzech nieliniowych funkcji wieloargumentowych, zwracających liczby rzeczywiste (liczba argumentów odpowiednio: 3, 5 i 7). Program umożliwia również śledzenie dokładności aproksymacji dla kolejnych epok i iteracji uczenia.

Program został zaimplementowany z wykorzystaniem wyłącznie biblioteki standardowej Pythona 3.

Obszar badań

W badaniach uwzględniono wpływ następujących parametrów architektury sieci MLP:

a także wpływ następujących parametrów zastosowanego optymalizatora SGD (stochastic gradient descent):

Wszystkie neurony, poza ostatnim neuronem wyjściowym sieci MLP, mają aktywację ReLU. Neuron wyjściowy sieci MLP ma aktywację liniową. Zaimplementowano dwie funkcje kosztu: błąd średniokwadratowy (MSE) oraz średni błąd absolutny (MAE). Każda z tych funkcji może zostać zastosowana jako funkcja kosztu modelu i zarazem jego metryka. Obie metryki są tym lepsze, im ich wartość jest bliższa zeru i tym gorsze, im są większe od zera. Dlatego niska wartość tych metryk będzie oznaczała pozytywną jakość aproksymacji, a wysoka wartość—negatywną jakość aproksymacji.

Dane mogą być przekazywane do modelu na dwa sposoby:

Funkcjonalność programu

Program umożliwia wizualizację jakości aproksymacji uczenia:

Wprowadzono również funkcjonalność early stopping, polegającą na zakończeniu uczenia w momencie, w którym od dłuższego czasu jakość aproksymacji na zbiorze testowym nie uległa poprawie.

Program umożliwia zadawanie takich parametrów, jak:

Wykonanie

Program został zaimplementowany w języku Python 3. Jest kompatybilny dla wersji 3.6+ i nie wymaga instalacji żadnych zewnętrznych bibliotek. Można go wywołać z linii komend systemu UNIX.

Program został wykonany w postaci katalogu z plikami wykonywalnymi:

a także z katalogami:

Katalog źródłowy programu zawiera pliki, w których zaimplementowana została logika modelu:

Implementacja

base.py

W tym pliku zaimplementowana została klasa BaseValue, która reprezentuje wartość skalarną. Posiada publiczne atrybuty data (wartość) oraz grad (gradient). Posiada również publiczne metody, umożliwiające modyfikację tych atrybutów.

Instancjonowanie klasy:

a = BaseValue(5)

engine.py

W tym pliku zaimplementowana została klasa Value, dziedzicząca po klasie BaseValue. Ma zaimplementowane operacje arytmetyczne, takie jak:

Instancjonowanie klasy:

a = Value(5)

Poza domyślnymi atrybutami data i grad, klasa Value zawiera również prywatne atrybuty:

Domyślnie atrybutowi _op przypisywany jest obiekt bez działania Op(), a atrybutowi _children przypisywany jest zbiór pusty.

Każda operacja arytmetyczna zwraca nowy obiekt klasy Value wraz z informacją o jego poprzednikach, czyli wartościach, użytych do zdefiniowana tej wartości (parametr children), a także z informacją o zastosowanej operacji (parametr op). Przykład instancjonowania nowego obiektu klasy Value podczas operacji mnożenia:

a = Value(5)
b = Value(-2)
c = Value(op="*", children=(a, b))  # równoważne z: c = a * b

Dzięki temu, że każdy nowo utworzony za pomocą operacji arytmetycznej obiekt posiada informację o zastosowanej operacji i swoich poprzednikach, możliwe jest zbudowanie drzewa, łączącego wartości wejściowe z wartościami wyjściowymi. Drzewo zostaje zbudowane na początku wywołania metody backward, a operacja budowania takiego drzewa to sortowanie topologiczne, za które odpowiada metoda _find_topology. W postaci pseudokodu metoda ta ma następującą postać:

def find_topology(value, topology, visited_values):
    if value not in visited_values:
        visited_values.add(value)
        for child in value.children:
            find_topology(child, topology, visited_values)
        topology.append(value)

Jest to funkcja rekurencyjna, która zostaje wywołana w następujący sposób:

topology []  
visited_values = set()
find_topology(value, topology, visited_values)

W liście topology zawarte zostają obiekty klasy Value począwszy od tych, które wystąpiły najwcześniej i skończywszy na tych, które wystąpiły najpóźniej. Dzięki temu, że każdy obiekt klasy Value posiada wiedzę o tym, jak obliczyć i zakumulować gradient w swoich poprzednikach (atrybut _op), możliwe jest wykonanie propagacji wstecznej:

for v in reversed(topology):  
    v._op.backward()

Podsumowując, metoda backward ma następujące działanie:

def backward(value):
    topology []
    visited_values = set()
    find_topology(value, topology, visited_values)
    value.grad = 1.0
    for v in reversed(topology):
        v._op.backward()

op.py

W tym pliku zaimplementowana została klasa Op oraz wszystkie jej podklasy, odpowiadające za poszczególne operacje arytmetyczne:

Klasa ta zawiera dwie metody:

Akumulacja gradientów polega na dodaniu do gradientu poprzednika wartości gradientu następnika, przemnożonej przez przez gradient następnika względem poprzednika:

xgrad=xgrad+dydxygradx_{grad}=x_{grad}+\dfrac{dy}{dx}\cdot y_{grad}

nn.py

W tym pliku zaimplementowana została abstrakcyjna klasa Module, której obiekt, przy wywołaniu, przyjmuje listę argumentów dla pojedynczej obserwacji (w postaci listy obiektów klasy Value) i zwraca predykcję dla tej obserwacji (w postaci obiektu klasy Value). Klasa ta zawiera również abstrakcyjną właściwość (ang. property) parameters, czyli listę wszystkich uczalnych parametrów modelu. Właściwość tę będzie mógł później wykorzystać optymalizator w celu wykonania kroku uczenia (modyfikacji wag modelu).

Klasa Neuron, dziedzicząca po klasie Module, przyjmuje przy wywołaniu listę wartości wejściowych i zwraca wartość wyjściową, zgodnie z zasadą działania neuronu:

y=act(wTx+b)y = act(\textbf{\textit w}^T\textbf{x} + b).

Klasa Layer, dziedzicząca po klasie Module, przyjmuje przy wywołaniu listę wartości wejściowych i zwraca listę wartości wyjściowych dla każdego neuronu:

y=act(Wx+b)\textbf{\textit y} = act(\textbf{\textit W}\textbf{x} + \textbf{\textit b}).

Klasa MLP, dziedzicząca po klasie Module, przyjmuje przy wywołaniu listę wartości wejściowych i zwraca listę wartości wyjściowych (bądź pojedynczą wartość wyjściową) po przepuszczeniu wartości wejściowych przez każdą warstwę sieci:

y=f(n)(...f(3)(f(2)(f(1)(x))))\textbf{\textit y} = f^{(n)}(...f^{(3)}(f^{(2)}(f^{(1)}(\textbf{x})))).

Jeżeli lista wartości wyjściowych ma długość 1 (1 neuron wyjściowy), wtedy zwrócona zostaje wartość skalarna:

y=f(n)(...f(3)(f(2)(f(1)(x))))y = f^{(n)}(...f^{(3)}(f^{(2)}(f^{(1)}(\textbf{x})))).

optimizer.py

W tym pliku zaimplementowany został optymalizator SGD. Umożliwia on przeprowadzenie uczenia z zadanymi początkowym i końcowym współczynnikiem uczenia. W trakcie epoki wartość aktualnego współczynnika uczenia będzie liniowo maleć od wartości lrstartlr_{start} do wartości lrendlr_{end}. Ponadto możliwe jest zastosowanie momentum, które umożliwi wprowadzenie pędu do procesu uczenia modelu. Wartość momentum (α\alpha) powinna się zawierać w zakresie [0.0,1.0)[0.0, 1.0), gdzie α=0.0\alpha=0.0 oznacza brak momentum. Im większe momentum, tym silniej uwzględniane będą zakumulowane wartości gradientów z poprzednich iteracji. Ważną odpowiedzialnością optymalizatora jest zerowanie gradientów wszystkich uczalnych parametrów po wykonaniu kroku optymalizacji. Optymalizację SGD opisuje poniższy pseudokod:

def update_parameters(v, alpha, lr, lr_start, lr_end, N, mlp):
    for i, param in enumerate(mlp.parameters):
        v[i] = alpha * v[i] + (1 - alpha) * param.grad
        param.update_data(-lr * v[i])
        param.set_grad(0)
    new_lr = lr - (lr_start - lr_end) / N
    return new_lr

generator.py

W tym pliku zaimplementowana została klasa DataGenerator. Posiada ona prywatną metodę _generate_data, służącą do generacji danych przez losowanie argumentów X\textbf X z rozkładu jednostajnego U(a,b)\mathcal U(\textbf {\textit a}, \textbf {\textit {b}}) oraz obliczanie dla nich wartości zadanych y\textbf y. Klasa posiada również dwie metody publiczne:

Liczba danych w wygenerowanym zbiorze zależy ponadto od zadanego docelowego rozmiaru zbioru danych oraz od współczynnika podziału treningowo-testowego danych. Przykładowo, dla funkcji:

y(x1,x2)=x12+x2y(x_1, x_2)=x_1^2+x_2,

dla docelowej wielkości zbioru danych równej 8, dla wartości współczynnika podziału treningowo-testowego równego 0.25 oraz dla przedziałów:

{x1(1,3)x2(3,5)}\{x_1\in(1, 3) \land x_2\in(-3, 5)\},

wygenerowane mogą zostać następujące dane :

X_train, y_train, X_test, y_test = DataGenerator(
    fn=lambda x1, x2: x1 ** 2 + x2,
    dataset_size=8,
    train_test_ratio=0.25,
).train_test_split_same_ranges({"x1": (1, 3), "x2": (-3, 5)})

# X_train, y_train mogą wynosić:
[[1.25, -1.64],
 [2.23,  3.56],
 [1.02, -2.63]
 [1.68,  4.56],
 [2.82,  0.25]
 [1.50,  1.18]], [-0.008, 8.533, -1.580, 7.382, 8.202, 3.430]
 
 # X_test, y_test mogą wynosić:
[[2.24, -2.13],
 [1.27,  3.95]], [2.888, 5.563]

dataset.py

W tym pliku zaimplementowana została klasa Dataset. Służy ona jako iterator po zbiorze danych. W każdej iteracji zwracana jest para (x,y)(\textbf x, \mathrm y), gdzie x\textbf x - argumenty funkcji, y\mathrm y - wartość zadana. Ponadto, po każdej iteracji możliwa jest zmiana kolejności danych w zbiorze, za pomocą metody on_epoch_end.

model.py

W tym pliku zaimplementowana jest klasa Model, która jako samodzielny moduł służy do trenowania oraz ewaluacji sieci MLP, a także do wykonywania predykcji wytrenowanego modelu oraz estymacji błędu.

Konstruktor klasy model ma następujące parametry:

Klasa Model zawiera następujące metody:

Trening modelu opisuje poniższy pseudokod:

def fit(train_set, test_set, epochs, batch_size, sgd, mlp, loss):
    for epoch in range(epochs):
        loss = 0.0
        for i in range(num_steps):
            loss += step(train_set, i, batch_size, sgd, mlp, loss)
        loss /= num_steps
        test_loss = score(test_set, mlp, loss)
        train_set.on_epoch_end()
        optimizer.on_epoch_end()  

Pojedynczy krok uczenia opisuje poniższy pseudokod:

def step(data_set, step, batch_size, sgd, mlp, loss):
    start = step * batch_size
    end = (step + 1) * batch_size
    batch = data_set.iloc[start:end]
    batch_loss = score(batch, mlp, loss)
    batch_loss.backward()
    sgd.update_parameters()
    return batch_loss.data

Koszt jest liczony w następujący sposób:

def score(batch, mlp, loss):
    scores = []
    for x, y in batch:
        loss_value = loss(y, mlp(x))
        scores.append(loss_value)
    return sum(scores) / len(scores)

trainer.py

W tym pliku zaimplementowana jest klasa Trainer służąca do uruchomienia programu w celu przeprowadzenia uczenia sieci MLP.

Plik wsadowy programu

Aby uruchomić program, należy przekazać mu plik o rozszerzeniu JSON. Plik musi mieć następującą strukturę:

{
  "function": "lambda x1, x2, x3: x1 + x2 + x3 + 5.0",
  "dataset_size": 8192,
  "train_test_ratio": 0.75,
  "data_ranges": {
    "same": {
      "x1": [-8, 15],
      "x2": [-3, 28],
      "x3": [4, 10]
    },
    "different": {
      "train": {
        "x1": [13, 48],
        "x2": [2, 7],
        "x3": [-4, 18]
      },
      "test": {
        "x1": [-2, 6],
        "x2": [18, 27],
        "x3": [19, 35]
      }
    }
  },
  "model_parameters": {
    "layer_sizes": [5, 3],
    "start_learning_rate": 0.01,
    "end_learning_rate": 0.001,
    "momentum": 0.8,
    "loss_function": "mse",
    "epochs": 15,
    "batch_size": 32,
    "patience": 5,
    "min_delta": 0.005,
    "display_freq": 0.25,
    "verbose": 2,
    "random_state": 42
  }
}

Opis programu wykonywalnego

Program wykonywalny demo.py znajduje się w głównym katalogu projektu. W celu zapoznania się z opcjami programu, można użyć następującej komendy:

python demo.py --help

Zostanie wyświetlona następująca instrukcja:

usage: demo.py [-h] [-d] [-n] [-L LAYER_SIZES [LAYER_SIZES ...]] [-S START_LEARNING_RATE] [-E END_LEARNING_RATE] [-M MOMENTUM] [-l {mse,mae}] [-e EPOCHS] [-b BATCH_SIZE] [-p PATIENCE] [-m MIN_DELTA]
               [-f DISPLAY_FREQ] [-v VERBOSE] [-r RANDOM_STATE]
               function_file

MLP LEARNING DEMO

positional arguments:
  function_file                                                                  json file with function to be learned

optional arguments:
  -h, --help                                                                     show this help message and exit
  -d, --different-ranges                                                         whether to train and test model on different data ranges (default: False)
  -n, --not-load-parameters                                                      whether not to load model parameters from json file (default: False)
  -L LAYER_SIZES [LAYER_SIZES ...], --layer-sizes LAYER_SIZES [LAYER_SIZES ...]  MLP layer sizes EXCLUDING first and last layer (default: [])
  -S START_LEARNING_RATE, --start-learning-rate START_LEARNING_RATE              learning rate at the beginning of an epoch (default: 0.01)
  -E END_LEARNING_RATE, --end-learning-rate END_LEARNING_RATE                    learning rate at the end of an epoch (default: 0.001)
  -M MOMENTUM, --momentum MOMENTUM                                               momentum (default: 0.8)
  -l {mse,mae}, --loss-function {mse,mae}                                        loss function (default: mae)
  -e EPOCHS, --epochs EPOCHS                                                     number of training epochs (default: 20)
  -b BATCH_SIZE, --batch-size BATCH_SIZE                                         size of a training batch (default: 32)
  -p PATIENCE, --patience PATIENCE                                               patience before early stopping (default: 5)
  -m MIN_DELTA, --min-delta MIN_DELTA                                            early stopping sensivity (default: 0.01)
  -f DISPLAY_FREQ, --display-freq DISPLAY_FREQ                                   frequency of displaying training loss during an epoch (default: 0.2)
  -v VERBOSE, --verbose VERBOSE                                                  verbosity mode (default: 3)
  -r RANDOM_STATE, --random-state RANDOM_STATE                                   random state (default: 42)

Aby uruchomić program, należy przekazać mu jako pierwszy argument ścieżkę do pliku JSON:

python demo.py <your-function-file-name>.json

Spowoduje to uruchomienie programu z parametrami, zawartymi w przekazanym pliku.

Domyślnie zbiór danych zostanie wygenerowany ze zdefiniowanego wspólnego rozkładu danych, a następnie zostanie dokonany podział treningowo-testowy danych. Jeżeli użytkownik zechce wykonać trening na danych treningowych wygenerowanych ze zdefiniowanego przedziału treningowego, a testowanie na danych testowych wygenerowanych ze zdefiniowanego przedziału testowego, wtedy powinien przekazać flagę --different-ranges:

python demo.py <your-function-file-name>.json --different-ranges

Domyślnie parametry modelu zostaną załadowane z pola model_parameters pliku wsadowego. Parametrom nieobecnym w tym polu zostaną przypisane wartości domyślne. Jeżeli użytkownik zechce zrezygnować z przekazywania modelowi parametrów z pliku, wtedy powinien przekazać flagę --not-load-parameters. Wtedy będzie miał manualną kontrolę nad przekazywanymi do modelu parametrami:

python demo.py <your-function-file-name>.json --not-load-parameters

Pozostałe parametry programu zostały wcześniej omówione i ich wartości mogą być modyfikowane przez przypisanie ich flagom odpowiednich wartości. Przykładowo, jeżeli użytkownik zechce przetrenować model:

wtedy powinien wpisać następującą komendę:

python demo.py 3-arg-function.json --not-load-parameters -L 10 20 5 -l mae -e 15

Po uruchomieniu programu zostanie wyświetlona zastosowana konfiguracja parametrów, a w dalszej kolejności wypisane zostaną wyniki kolejnych etapów uczenia. Model zakończy działanie w dwóch przypadkach:

Po zakończeniu działania programu wyświetlona zostanie osiągnięta jakość aproksymacji funkcji przez model zarówno na zbiorze treningowym, jak i na zbiorze testowym.

Użytkownik może kontrolować ilość wyświetlanych informacji podczas treningu za pomocą flagi --verbose:

Domyślnie ustawiona jest wartość 33.

Eksperymenty

Przeprowadzono 3 eksperymenty na funkcjach odpowiednio: 3, 5 i 7-argumentowych.

Eksperyment 1. Funkcja 3-argumentowa

Badana funkcja:

y=0.24x12x31.05x2+x2x32+0.25x1x20.03x23x1x2x3y=0.24x_1^2x_3-1.05x_2+x_2x_3^2+0.25x_1x_2-0.03x_2^3-x_1x_2x_3

Funkcja, mimo iż ma tylko 3 argumenty, jest nieliniowa i może być trudna do nauczenia przez sieć MLP. Jest zdefiniowana w pliku 3-arg-function.json

Konfiguracja 1.

Wystąpił early stopping po 23 epokach—najlepsza jakość modelu w epoce 17. Osiągnięto błąd treningowy o wartości 75.732221 i błąd testowy o wartości 79.800746. Model szybko przestał się uczyć.

Konfiguracja 2.

Różnica w porównaniu z konfiguracją 1. jest taka, że zastosowano wyższy współczynnik momentum (0,8 zamiast 0,2).

Wystąpił early stopping po 19 epokach—najlepsza jakość modelu w epoce 13. Osiągnięto błąd treningowy o wartości 82.992590 i błąd testowy o wartości 72.759102. Mimo, iż błąd treningowy jest większy niż przy konfiguracji 1., to błąd testowy jest znacznie mniejszy. Ponadto, uczenie trwało o 5 epok krócej. Oznacza to, że zastosowanie wyższego współczynnika momentum przyspiesza uczenie i zwiększa skuteczność modelu, zostawiając jednak niższą jakość aproksymacji na zbiorze treningowym.

Konfiguracja 3.

Różnica w porównaniu z konfiguracją 1. jest taka, że zastosowano spadek współczynnika uczenia (z wartości 0.01 na początku epoki do wartości 0.001 na końcu epoki).

Wystąpił early stopping po 38 epokach—najlepsza jakość modelu w epoce 32. Osiągnięto błąd treningowy o wartości 64.210033 i błąd testowy o wartości 60.935513. Błąd testowy jest znacznie mniejszy niż przy konfiguracji 1. i 2. Oznacza to, że wprowadzenie spadku współczynnika uczenia wpływa pozytywnie na jakość aproksymacji modelu, jednak dzieje się to kosztem znacznie dłuższego czasu uczenia. Kolejny wniosek jest taki, że aby uczenie z momentum było skuteczne, musi ono mieć możliwie dużą wartość (bliską 1.0).

Konfiguracja 4.

Różnica w porównaniu z konfiguracją 3. różnica jest taka, że zwiększono liczbę warstw sieci MLP (dodano jedną warstwę o rozmiarze 5).

Wystąpił early stopping po 42 epokach—najlepsza jakość modelu w epoce 36. Osiągnięto błąd treningowy o wartości 63.158436 i błąd testowy o wartości 57.317041. Zwiększenie liczby warstw spowodowało znaczny wzrost jakości aproksymacji modelu zarówno na zbiorze treningowym, jak i na zbiorze testowym. Jednak zarówno liczba epok uległa zwiększeniu, jak i średni czas trwania epoki uległ wydłużeniu.

Konfiguracja 5.

Różnica w porównaniu z konfiguracją 4. jest taka, że zwiększono współczynnik momentum (wynosi 0,9).

Wystąpił early stopping po 36 epokach—najlepsza jakość modelu w epoce 30. Osiągnięto błąd treningowy o wartości 62.587896 i błąd testowy o wartości 56.603535. Jakość aproksymacji uległa nieznacznej poprawie. Jest to kolejny dowód na to, że zastosowanie wysokiego momentum skutecznie zwiększa jakość aproksymacji modelu.

Konfiguracja 6.

Różnica w porównaniu z konfiguracją 4. jest taka, że zwiększono liczbę neuronów we wszystkich warstwach z 5 na 7.

Early stopping nie wystąpił. Uczenie zakończyło się po 50 epokach—najlepsza jakość modelu w epoce 50. Osiągnięto błąd treningowy o wartości 56.901206 i błąd testowy o wartości 45.891594. Jest to bardzo duża poprawa w porównaniu z poprzednimi konfiguracjami. Dzięki zwiększeniu liczby neuronów model nauczył się znajdować więcej zależności między zmiennymi. Kolejny wniosek jest taki, że nie jeśli nie wystąpił early stopping, to znaczy, że sieć mogłaby się dalej uczyć i prawdopodobnie uzyskałaby lepsze wyniki.

Konfiguracja 7.

Różnica w porównaniu z konfiguracją 4. jest taka, że zastosowano oddzielny rozkład danych treningowych i oddzielny rozkład danych testowych.

Wystąpił early stopping po 22 epokach—najlepsza jakość modelu w epoce 11. Osiągnięto błąd treningowy o wartości 360.968210 i błąd testowy o wartości 385.835361. Okazuje się, że jakoś aproksymacji w znacznym stopniu zależy od rozkładu danych, na jakim trenowany jest model. Ponadto, przy tej konfiguracji błąd testowy jest większy niż błąd treningowy, co oznacza, że model nie generalizuje poprawnie. Istnieje ryzyko, że przy poprzednich konfiguracjach (losowanie z tego samego rozkładu) model również był przeuczony, ale nie dało się tego zaobserwować.

Eksperyment 2. Funkcja 5-argumentowa

Badana funkcja:

y=10sin(x1x2π)+20x3+10x1x4+5x5210y=10sin(x_1x_2\pi)+20x_3+10x_1x_4+5x_5^2-10

Konfiguracja 1.

Early stopping nie wystąpił. Uczenie zakończyło się po 50 epokach—najlepsza jakość modelu w epoce 49. Osiągnięto błąd treningowy o wartości 7.952855 i błąd testowy o wartości 8.031169. Można wnioskować, że model się lekko przeuczył, chociaż błąd testowy jest jedynie nieznacznie większy od błędu treningowego. Inny wniosek jest taki, że skoro nie nastąpił early stopping, to uczenie przebiegało zbyt wolno i istnieje ryzyko, że wystąpił problem utknięcia w lokalnym minimum.

Konfiguracja 2.

Różnica w porównaniu z konfiguracją 1. jest taka, że zwiększono współczynnik uczenia początkowy (z 0.01 na 0.1) i końcowy (z 0.001 na 0.01).

Wystąpił early stopping po 41 epokach—najlepsza jakość modelu w epoce 30. Osiągnięto błąd treningowy o wartości 7.913943 i błąd testowy o wartości 7.912220. Błędy są nieznacznie mniejsze od błędów w konfiguracji 1. Ponadto można wnioskować, że skoro wystąpił early stopping, to oznacza, że sieć uczyła się szybciej niż poprzednio. Może to oznaczać, że słuszne było zastosowanie większej wartości współczynników uczenia.

Konfiguracja 3.

Różnica w porównaniu z konfiguracją 1. jest taka, że zwiększono liczbę warstw oraz liczbę neuronów w warstwach na: [10,10][10, 10]

Early stopping nie wystąpił. Uczenie zakończyło się po 50 epokach—najlepsza jakość modelu w epoce 43. Osiągnięto błąd treningowy o wartości 6.492558 i błąd testowy o wartości 6.522868. Zarówno błąd treningowy, jak i testowy, są mniejsze niż w konfiguracjach 1. i 2. Oznacza to, że zwiększenie liczby warstw i neuronów umożliwiło nauczenie się przez sieć większej liczby zależności między zmiennymi.

Eksperyment 3. Funkcja 7-argumentowa

Badana funkcja:

y=x12+3x2+x3x4+2x51.5x6x7y = x_1^2 + 3x_2 + x_3x_4 + 2x_5 - 1.5x_6x_7

Badana funkcja ma najwięcej zmiennych, ale jest najprostsza spośród wszystkich do tej pory aproksymowanych.

Konfiguracja 1.

Early stopping nie wystąpił. Uczenie zakończyło się po 20 epokach—najlepsza jakość modelu w epoce 18. Osiągnięto błąd treningowy o wartości 11.491088 i błąd testowy o wartości 11.215366. Sieć poprawnie generalizuje na danych ze zbioru testowego.

Konfiguracja 2.

Konfiguracja ta różni się tym od konfiguracji 1., że zastosowano w niej dwie warstwy neuronowe o rozmiarze 5.

Early stopping nie wystąpił. Uczenie zakończyło się po 20 epokach—najlepsza jakość modelu w epoce 20. Osiągnięto błąd treningowy o wartości 24.037630 i błąd testowy o wartości 23.814204. Osiągnięto błąd treningowy oraz testowy większy niż w konfiguracji 1. Oznacza to, że sieć jest zbyt głęboka i uczy się nadmiernej liczny zależności między zmiennymi.

Konfiguracja 3.

Różnica w porównaniu z konfiguracją 1. jest taka, że zastosowano oddzielny rozkład danych treningowych i oddzielny rozkład danych testowych. Zwiększono również rozmiar jedynej warstwy z 5 na 10.

Early stopping nie wystąpił. Uczenie zakończyło się po 20 epokach—najlepsza jakość modelu w epoce 20. Osiągnięto błąd treningowy o wartości 17.219267 i błąd testowy o wartości 56.211568. Mimo niskiej wartości błędu na zbiorze treningowym, wartość błędu na zbiorze testowym jest wysoka. Oznacza to, że model się przeuczył. Wniosek jest taki, że warto badać jakość aproksymacji modelu również na danych z innego rozkładu niż rozkład treningowy.

Wnioski

Rozmiar i liczba warstw sieci MLP mają istotny wpływ na jakość aproksymacji funkcji. Im więcej warstw, tym więcej nieliniowych zależności model będzie w stanie się nauczyć. Z kolei rozmiar warstw wpłynie pozytywnie na znalezienie korelacji między zmiennymi. Jednak zarówno zwiększenie liczby warstw, jak i liczby neuronów, niosą za sobą róznież negatywne konsekwencje. Przede wszystkim wraz ze wzrostem wielkości sieci zwiększa się czas jej uczenia, ale również może się zdarzyć, że model się przeuczy i nie będzie poprawnie generalizował na danych testowych.

Istotne jest wprowadzenie do procesu optymalizacji momentum, ponieważ znacznie przyspiesza to proces uczenia sieci MLP. Dzięki temu model uwzględnia nie tylko aktualny gradient z batcha, ale również ma informację o gradientach z poprzednich iteracji. Zwiększa to dynamikę uczenia. Również istotne jest, żeby prawidłowo dodać współczynnik uczenia. Badania dowiodły, że liniowy spadek wartości współczynnika uczenia w trakcie trwania epoki zwiększa skuteczność modelu.

Eksperymenty dowiodły również, że warto testować modele na innych rozkładach danych wejściowych niż rozkłady danych treningowych. Metryka testowa jest wtedy bardziej miarodajna i może nas ostrzec o wystąpieniu przeuczenia modelu.

Uruchomienie eksperymentów

Poniższe komendy umożliwią odtworzenie przeprowadzonych eksperymentów dla konfiguracji, w których jakość aproksymacji funkcji była największa.

Funkcja 3-argumentowa

python demo.py 3-arg-function.json

Funkcja 5-argumentowa

python demo.py 5-arg-function.json

Funkcja 7-argumentowa

python demo.py 7-arg-function.json

Uruchomienie testów

Poniższa komenda uruchomi testy sprawdzające, czy propagacja wprzód i propagacja wsteczna działają poprawnie, a także test integracyjny sprawdzający, czy program wykonuje się poprawnie.

python test.py

Opis matematyczny

Specyfikacja problemu

Rozważymy problem regresji: uczenia się nieliniowej funkcji f:RMRf: \mathbb{R}^M \to \mathbb{R} na podstawie zbioru uczącego Dtrain\mathcal{D}_\text{train} złożonego z par jej argumentów xRM\bm{x} \in \mathbb{R}^M i wartości yRy \in \mathbb{R}:

Dtrain={(xi,yi)}0i<Ntrain,\mathcal{D}_\text{train} = \{ (\bm{x}_i, y_i)\}_{0 \leq i < N_{\text{train}}},

gdzie NtrainN_{\text{train}} to liczba punktów w zbiorze treningowym. ff jest aproksymowana przez funkcję f^θ:RMR\hat{f}_\bm{\theta}: \mathbb{R}^M \to \mathbb{R} sparametryzowaną przez wektor parametrów θ\bm{\theta}. Rozwiązanie problemu stanowi taki wektor θ\bm{\theta} dla którego f^θf\hat{f}_\bm{\theta} \approx f. To, jak dobrze f^θ\hat{f}_\bm{\theta} aproksymuje ff, sprawdzane jest na zbiorze Dtest\mathcal{D}_\text{test} przykładów niewykorzystanych do dopasowania θ\bm{\theta}:

Dtest={(xi,yi)}0i<Ntest, \mathcal{D}_\text{test} = \{ (\bm{x}_i, y_i)\}_{0 \leq i < N_{\text{test}}},

gdzie NtestN_{\text{test}} to liczba punktów w zbiorze testowym. Istotne jest następujące założenie:

DtrainDtest=. \mathcal{D}_\text{train} \cap \mathcal{D}_\text{test} = \emptyset.

Innymi słowy, interesuje nas takie f^θ\hat{f}_\bm{\theta}, które będzie generalizowało się na nowe punkty należące do dziedziny ff.

Jakość aproksymacji f^θf\hat{f}_\bm{\theta} \approx f, inaczej metryka, mierzona jest za pomocą błędu średniokwadratowego:

Lθ(D)=1D(xi,yi)D(f^θ(xi)yi)2 \mathcal{L}_\bm{\theta}(\mathcal{D}) = \frac{1}{|\mathcal{D}|} \sum_{(\bm{x}_i, y_i) \in \mathcal{D}} (\hat{f}_\bm{\theta}(\bm{x}_i) - y_i)^2

lub średniego błędu absolutnego:

Lθ(D)=1D(xi,yi)Df^θ(xi)yi. \mathcal{L}_\bm{\theta}(\mathcal{D}) = \frac{1}{|\mathcal{D}|} \sum_{(\bm{x}_i, y_i) \in \mathcal{D}} |\hat{f}_\bm{\theta}(\bm{x}_i) - y_i|.

Specyfikacja rozwiązania

f^θ\hat{f}_\bm{\theta} została zaimplementowana jako wielowarstwowa sieć neuronowa typu MLP (multi-layer perceptron). θ\bm{\theta} dobierany jest metodą schodzenia po gradiencie funkcji kosztu Lθθ\frac{\partial{\mathcal{L}_\bm{\theta}}}{\partial \bm{\theta}}.

Architektura sieci neuronowej

Dla uproszczenia notacji pominięta zostanie zależność f^\hat{f} od jej parametrów θ\bm{\theta}. Przyjęte zostanie, że f^θ\hat{f}_\bm{\theta} stanowi złożenie DD funkcji postaci:

f^(i):RIiROi\hat{f}^{(i)}: \mathbb{R}^{I_i} \to \mathbb{R}^{O_i}

gdzie 1i<D1 \leq i < D, to znaczy:

f^=f^(D)f^(D1)f^(1).\hat{f} = \hat{f}^{(D)} \circ \hat{f}^{(D-1)} \circ \dots \circ \hat{f}^{(1)}.

Każda f^(i)\hat{f}^{(i)} nazwana zostanie warstwą sieci neuronowej. IiI_i nazwane zostanie liczbą neuronów warstwy i1i-1, a OiO_i nazwane zostanie liczbą neuronów warstwy ii. DD nazwane zostanie głębokością sieci neuronowej, to znaczy liczbą warstw. Dodatkowo przyjęte zostanie założenie, że I1=MI_1 = M (wejściem do pierwszej warstwy sieci neuronowej są argumenty ff) oraz OD=1O_D = 1 (ostatnia warstwa sieci neuronowej składa się z jednego neuronu, ponieważ wartości ff są skalarami) oraz IiI_i = Oi1O_{i-1} dla każdego ii.

Budowa warstwy

Każda funkcja f^(i)\hat{f}^{(i)} ma następującą postać

f^(i)=y^(i)=gi(z(i))=gi(W(i)x(i)+b(i)), \hat{f}^{(i)}=\hat{\bm{y}}^{(i)}=g_i(\bm{z}^{(i)})=g_i(\bm{W}^{(i)}\bm{x}^{(i)}+\bm{b}^{(i)}),

gdzie x(i)RIi\bm{x}^{(i)} \in \mathbb{R}^{I_i}, W(i)RIi×Oi\bm{W}^{(i)} \in \mathbb{R}^{I_i \times O_i}, b(i)ROi\bm{b}^{(i)} \in \mathbb{R}^{O_i}. Macierz W(i)\bm{W}^{(i)} wyznacza siły połączeń między neuronami warstwy ii. gig_i to funkcja aktywacji. Wszystkie warstwy oprócz ostatniej mają nieliniową funkcję aktywacji ReLU\text{ReLU}:

ReLU(z)=max(z,0).\text{ReLU}(\bm{z}) = \text{max}(\bm{z}, \bm{0}).

Ostatnia warstwa ma funkcję aktywacji tożsamościową i\text{i}:

i(z)=z.i(\bm{z}) =\bm{z}.

Zatem:

gi(z)={ReLU(z),1iD1i(z),i=D g_i(\bm{z})= \begin{cases} \text{ReLU}(\bm{z}), 1 \leq i \leq D-1 \\ i(\bm{z}), i = D \end{cases}

Co istotne, wejście każdej warstwy jest wyjściem poprzedniej warstwy, z wyjątkiem warstwy pierwszej, której wejściem jest argument x\bm{x}:

x(i)={y^(i1),2iDx,i=1 \bm{x}^{(i)}= \begin{cases} \hat{\bm{y}}^{(i-1)}, 2 \leq i \leq D \\ \bm{x}, i = 1 \end{cases}

Diagram poniżej przedstawia przykładową architekturę sieci neuronowej typu MLP, gdzie D=2,I1=3,O1=I2=4,O2=1D=2, I_1 = 3, O_1 = I_2 = 4, O_2 = 1. Dla uproszczenia, na diagramie nie zostały uwzględnione parametry b\bm{b} sieci neuronowej.

w11
w21
w12
w13
w22
w23
w24
w3

Funkcja kosztu

Funkcją kosztu, podobnie jak metryką, jest błąd średniokwadratowy lub średni błąd absolutny. Parametrami sieci neuronowej są macierze W(i)\bm{W}^{(i)} oraz wektory b(i)\bm{b}^{(i)} dla każdej z warstw ii, to znaczy:

θ={W(1),b(1),,W(D),b(D)}. \bm{\theta} = \{\bm{W}^{(1)}, \bm{b}^{(1)}, \ldots, \bm{W}^{(D)}, \bm{b}^{(D)} \}.

Minimalizacja funkcji kosztu

Funkcja ff jest aproksymowana poprzez rozwiązywanie problemu optymalizacyjnego:
arg minθ Lθ(Dtrain)\text{arg min}_\bm{\theta} \ \mathcal{L}_\bm{\theta}(\mathcal{D}_\text{train})

metodą schodzenia po gradiencie Lθ(Dtrain)θ\frac{\partial{\mathcal{L}_\bm{\theta}(\mathcal{D}_\text{train}})}{\partial \bm{\theta}}. Innymi słowy, w każdej iteracji treningu, θ\bm{\theta} jest modyfikowany według następującego równania:

θ:=θαLθ(Dtrain)θ, \bm{\theta} := \bm{\theta} - \alpha \frac{\partial{\mathcal{L}_\bm{\theta}}(\mathcal{D}_\text{train})}{\partial \bm{\theta}},

gdzie α\alpha to współczynnik uczenia. W praktyce zastosowany został algorytm stochastycznego schodzenia po gradiencie (SGD): zbiór Dtrain\mathcal{D}_\text{train} rozbijany jest na podzbiory B1,,BPB_1, \ldots, B_P zwane batchami, i stosowane jest powyższe równanie do każdego batcha z osobna. Iteracja przez cały zbiór Dtrain\mathcal{D}_\text{train} odbywa się kilkukrotnie.

Aby uwzględnić dynamikę uczenia, można zastosować algorytm uczenia z tzw. momentum. Podejście to polega na obliczeniu wynikowego kierunku minimalizacji funkcji jako średniej ważonej aktualnego gradientu oraz wynikowego kierunku minimalizacji z poprzedniej iteracji.

Wsteczna propagacja błędu

Ze względu na wybraną architekturę (fakt, że f^=f^(D)f^(D1)f^(1)\hat{f} = \hat{f}^{(D)} \circ \hat{f}^{(D-1)} \circ \dots \circ \hat{f}^{(1)} i różniczkowalność f^(i)\hat{f}^{(i)} po W(i)\bm{W}^{(i)} i b(i)\bm{b}^{(i)} dla każdego ii), istnieje szybki algorytm obliczania gradientów funkcji kosztu po każdym z parametrów: wsteczna propagacja błędu. Wykorzystana została reguła łańcuchowa rachunku różniczkowego, która mówi, że:

[f^(i)f^(i1)]W(i1)=f^(i)f^(i1)f^(i1)W(i1). \frac{\partial \big[ \hat{f}^{(i)} \circ \hat{f}^{(i-1)} \big]}{\partial \bm{W}^{(i-1)}} = \frac{\partial \hat{f}^{(i)}}{\partial \hat{f}^{(i-1)}} \frac{\partial \hat{f}^{(i-1)}}{\partial \bm{W}^{(i-1)}}.

W ogólności, chcąc obliczyć f^(i)W(j)\frac{\partial \hat{f}^{(i)} }{\partial \bm{W}^{(j)}} dla i>ji > j, można (ij)(i-j)-krotnie zaaplikować regułę łańcuchową, to znaczy skorzystać z faktu, że:

f^(i)W(j)=f^(i)f^(i1)f^(i1)f^(i2)f^(j+1)f^(j)f^(j)W(j), \frac{\partial \hat{f}^{(i)} }{\partial \bm{W}^{(j)}} = \frac{\partial \hat{f}^{(i)} }{\partial \hat{f}^{(i-1)} } \frac{\partial \hat{f}^{(i-1)} }{\partial \hat{f}^{(i-2)} } \ldots \frac{\partial \hat{f}^{(j+1)} }{\partial \hat{f}^{(j)} } \frac{\partial \hat{f}^{(j)} }{\partial \bm{W}^{(j)}},

i analogicznie:

f^(i)b(j)=f^(i)f^(i1)f^(i1)f^(i2)f^(j+1)f^(j)f^(j)b(j). \frac{\partial \hat{f}^{(i)} }{\partial \bm{b}^{(j)}} = \frac{\partial \hat{f}^{(i)} }{\partial \hat{f}^{(i-1)} } \frac{\partial \hat{f}^{(i-1)} }{\partial \hat{f}^{(i-2)} } \ldots \frac{\partial \hat{f}^{(j+1)} }{\partial \hat{f}^{(j)} } \frac{\partial \hat{f}^{(j)} }{\partial \bm{b}^{(j)}}.

Można zauważyć, że zachodzi fakt:
f^(i)W(i)=giW(i)=giz(i)z(i)W(i),\frac{\partial \hat{f}^{(i)} }{\partial \bm{W}^{(i)}} = \frac{\partial g_i }{\partial \bm{W}^{(i)}}= \frac{\partial g_i}{\partial \bm{z}^{(i)}} \frac{\partial \bm{z}^{(i)}}{\partial \bm{W}^{(i)}},

oraz analogiczny fakt dla b(i)\bm{b}^{(i)}.

Znane są również pochodne giz(i)\frac{\partial g_i }{\partial \bm z^{(i)}} dla obu funkcji aktywacji:

ReLU(z)z={0,z<01,z>0, \frac{\partial \text{ReLU}(\bm{z})}{\partial \bm z} = \begin{cases} 0, \bm{z} < 0 \\ 1, \bm{z} > 0 \end{cases},

i(z)z=1,\frac{\partial i (\bm{z})}{\partial \bm z} = 1,
oraz pochodna funkcji kosztu (dla pojedynczego batcha BmB_m):

L(Bm)f^=2Bm(xi,yi)Bm(f^(xi)yi). \frac{\partial \mathcal{L}(B_m)} {\partial \hat{f} } = \frac{2}{|B_m|} \sum_{(\bm{x}_i, y_i) \in B_m} ( \hat{f}(\bm{x}_i) - y_i).

Znane są wreszcie pochodne ziW(i)\frac{\partial \bm z_i }{\partial \bm{W}^{(i)}} i zib(i)\frac{\partial \bm z_i }{\partial \bm{b}^{(i)}} dla każdej warstwy ii:

ziW(i)=xi,\frac{\partial \bm z_i }{\partial \bm{W}^{(i)}} = \bm x_i,

zib(i)=1.\frac{\partial \bm z_i }{\partial \bm{b}^{(i)}} = \bm 1.

Obliczając wartości tych równań dla każdej warstwy można obliczyć pochodne funkcji kosztu po parametrach Wi\bm W_i i bi\bm b_i dla każdego ii. Zrobione to zostało wydajnie, poprzez zapamiętywanie kolejnych pochodnych f^(i)f^(i1)\frac{\partial \hat{f}^{(i)}}{\partial \hat{f}^{(i-1)}} i podstawianie ich policzonych wartości przy obliczaniu f^(i)f^(j)\frac{\partial \hat{f}^{(i)}}{\partial \hat{f}^{(j)}} dla i>ji > j.

Podziękowania

Biblioteka micrograd, autorstwa Andreja Karpathy’ego, była pomocna podczas pracy nad projektem.