Adam Rajfer (adam.rajfer@gmail.com), 2020
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.
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:
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:
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:
demo.py
: plik wykonywalny programu,test.py
: plik wykonywany testów,a także z katalogami:
mlp/
: katalog źródłowy programu,test_mlp/
: katalog źródłowy testów.Katalog źródłowy programu zawiera pliki, w których zaimplementowana została logika modelu:
base.py
: definicja wartości skalarnej, posiadającej wiedzę o swoim gradiencie,engine.py
: wyżej omówiona definicja wartości skalarnej rozszerzona o wiedzę o tym, jak dana wartość skalarna oddziałuje na operacje arytmetyczne z innymi wartości skalarnymi,op.py
: definicja operacji arytmetycznych między wartościami skalarnymi, z uwzględnieniem obliczania i akumulacji gradientów,nn.py
: definicja neuronu, warstwy neuronowej oraz sieci MLP,optimizer.py
: definicja optymalizatora SGD,generator.py
: definicja procesu generacji danych oraz podziału treningowo-testowego,dataset.py
: definicja zbioru danych,model.py
: definicja modelu,trainer.py
: definicja programu do uczenia sieci MLP.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:
__add__
),__neg__
),__mul__
),__pow__
),__abs__
),relu
).Instancjonowanie klasy:
a = Value(5)
Poza domyślnymi atrybutami data
i grad
, klasa Value
zawiera również prywatne atrybuty:
_op
: obiekt, odpowiedzialny za wykonanie operacji arytmetycznej i akumulację gradientów (instancja klasy Op
z pliku op.py
)_children
: zbiór poprzedników danego obiektu klasy Value
, czyli zbiór wartości, z których powstał ten obiekt.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:
AddOp
: dodawanie,MulOp
: mnożenie,PowOp
: potęgowanie,AbsOp
: wartość bezwzględna,ReLUOp
: aktywacja ReLUKlasa ta zawiera dwie metody:
forward
: metoda wywoływana w konstruktorze, której zadaniem jest obliczenie i nadpisanie atrybutu data
wartości wyjściowej Value
,bakcward
: metoda wywoływana podczas wywoływana algorytmu propagacji wstecznej (wcześniej omówionej metody backward
klasy Value
). Jej zadaniem jest obliczenie gradientów wartości wyjściowej po swoich poprzednikach i zakumulowanie ich w gradientach tych poprzedników.Akumulacja gradientów polega na dodaniu do gradientu poprzednika wartości gradientu następnika, przemnożonej przez przez gradient następnika względem poprzednika:
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:
.
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:
.
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:
.
Jeżeli lista wartości wyjściowych ma długość 1 (1 neuron wyjściowy), wtedy zwrócona zostaje wartość skalarna:
.
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 do wartości . Ponadto możliwe jest zastosowanie momentum, które umożliwi wprowadzenie pędu do procesu uczenia modelu. Wartość momentum () powinna się zawierać w zakresie , gdzie 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 z rozkładu jednostajnego oraz obliczanie dla nich wartości zadanych . Klasa posiada również dwie metody publiczne:
train_test_split_same_ranges
: losuje dane z jednego przedziału, a następnie dzieli je na zbiory treningowy i testowy,train_test_split_different_ranges
: losuje dane treningowe z przedziału treningowego i dane testowe z przedziału testowego.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:
,
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:
,
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 , gdzie - argumenty funkcji, - 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:
layer_sizes
: lista z rozmiarami kolejnych warstw sieci MLP. Co istotne, lista ta nie uwzględnia warstwy wejściowej i wyjściowej (wejściowa warstwa zależeć będzie od zadanej funkcji, a wyjściowa warstwa zawsze będzie miała rozmiar 1),optimizer
: instancja klasy SGD
,loss
: nazwa funkcji kosztu (możliwe nazwy: “mse”, “mae”),patience
: cierpliwość uczenia, czyli liczba epok, po których, jeżeli nie ma poprawy metryki testowej, uczenie zostanie przerwane,min_delta
: wrażliwość uczenia, czyli minimalna wartość zmiany metryki treningowej, którą model traktuje jako poprawę,display_freq
: procent liczby kroków w epoce, po którym nastąpi wyświetlenie wartości błędu treningowego,verbose
: miara tego, jak wiele informacji zostanie wyświetlonych podczas treningu (0—brak wyświetlania informacji, 3—wyświetlanie wszystkich informacji).Klasa Model
zawiera następujące metody:
fit
: metoda wywołująca trening (wraz z ewaluacją) sieci MLP i zwracająca wytrenowany model. Należy jej podać dwa obiekty klasy Dataset
(zbiór treningowy i zbiór testowy), a także liczbę epok treningowych i rozmiar batcha treningowego,predict
: metoda zwracająca predykcje modelu dla zbioru list argumentów ,predict_one
: metoda zwracająca predykcję modelu dla pojedynczej listy argumentów ,score
- metoda zwracająca błąd modelu dla zbiorów ,score_one
- metoda zwracająca błąd modelu dla pary .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.
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
}
}
function
musi zawierać definicję zadanej funkcji w postaci wyrażenia lambda zapisanego w postaci zmiennej tekstowej. Argumenty funkcji muszą mieć nazwy kolejno: , gdzie - liczba argumentów. Pole to musi mieć przypisaną wartość.dataset_size
oznacza całkowitą liczbę par , jakie zostaną wygenerowane. Pole to musi mieć przypisaną wartość.train_test_ratio
określa, jaki procent wszystkich par trafi do zbioru treningowego. Pole to musi mieć przypisaną wartość.data_ranges
definiuje przedziały, z których losowane będą argumenty funkcji. Pole to musi mieć przypisaną wartość. Ma następujące pola:
same
definiuje przedziały argumentów, które będą losowane w trybie losowania danych z jednego przedziału:
x1
oznacza przedział dla argumentu 1,x2
oznacza przedział dla argumentu 2,xn
oznacza przedział dla argumentu n,different
definiuje przedziały argumentów, które będą losowane w trybie losowania danych treningowych z przedziału treningowego i danych testowych z przedziału testowego:
train
oznacza przedziały treningowe:
x1
oznacza przedział dla argumentu 1,x2
oznacza przedział dla argumentu 2,xn
oznacza przedział dla argumentu n,test
oznacza przedziały testowe,
x1
oznacza przedział dla argumentu 1,x2
oznacza przedział dla argumentu 2,xn
oznacza przedział dla argumentu n,model_parameters
definiuje domyślne wartości parametrów modelu. Można w nim umieścić takie klucze, jak: layer_sizes
, start_learning_rate
, end_learning_rate
, momentum
, loss_function
, epochs
, batch_size
, patience
, min_delta
, display_freq
, verbose
oraz random_state
. Można w nim nie umieszczać żadnego parametru (przekazać pusty słownik). Domyślnie wczytane zostaną wszystkie parametry modelu, zdefiniowane w tym polu. Jeżeli jakiegoś parametru nie ma zdefiniowanego w tym polu, wtedy przyjmie on wartość domyślną.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:
3-arg-function.json
,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ść .
Przeprowadzono 3 eksperymenty na funkcjach odpowiednio: 3, 5 i 7-argumentowych.
Badana funkcja:
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
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ć.
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.
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).
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.
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.
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.
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ć.
Badana funkcja:
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.
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.
Różnica w porównaniu z konfiguracją 1. jest taka, że zwiększono liczbę warstw oraz liczbę neuronów w warstwach na:
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.
Badana funkcja:
Badana funkcja ma najwięcej zmiennych, ale jest najprostsza spośród wszystkich do tej pory aproksymowanych.
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 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.
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.
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.
Poniższe komendy umożliwią odtworzenie przeprowadzonych eksperymentów dla konfiguracji, w których jakość aproksymacji funkcji była największa.
python demo.py 3-arg-function.json
python demo.py 5-arg-function.json
python demo.py 7-arg-function.json
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
Rozważymy problem regresji: uczenia się nieliniowej funkcji na podstawie zbioru uczącego złożonego z par jej argumentów i wartości :
gdzie to liczba punktów w zbiorze treningowym. jest aproksymowana przez funkcję sparametryzowaną przez wektor parametrów . Rozwiązanie problemu stanowi taki wektor dla którego . To, jak dobrze aproksymuje , sprawdzane jest na zbiorze przykładów niewykorzystanych do dopasowania :
gdzie to liczba punktów w zbiorze testowym. Istotne jest następujące założenie:
Innymi słowy, interesuje nas takie , które będzie generalizowało się na nowe punkty należące do dziedziny .
Jakość aproksymacji , inaczej metryka, mierzona jest za pomocą błędu średniokwadratowego:
lub średniego błędu absolutnego:
została zaimplementowana jako wielowarstwowa sieć neuronowa typu MLP (multi-layer perceptron). dobierany jest metodą schodzenia po gradiencie funkcji kosztu .
Dla uproszczenia notacji pominięta zostanie zależność od jej parametrów . Przyjęte zostanie, że stanowi złożenie funkcji postaci:
gdzie , to znaczy:
Każda nazwana zostanie warstwą sieci neuronowej. nazwane zostanie liczbą neuronów warstwy , a nazwane zostanie liczbą neuronów warstwy . nazwane zostanie głębokością sieci neuronowej, to znaczy liczbą warstw. Dodatkowo przyjęte zostanie założenie, że (wejściem do pierwszej warstwy sieci neuronowej są argumenty ) oraz (ostatnia warstwa sieci neuronowej składa się z jednego neuronu, ponieważ wartości są skalarami) oraz = dla każdego .
Każda funkcja ma następującą postać
gdzie , , . Macierz wyznacza siły połączeń między neuronami warstwy . to funkcja aktywacji. Wszystkie warstwy oprócz ostatniej mają nieliniową funkcję aktywacji :
Ostatnia warstwa ma funkcję aktywacji tożsamościową :
Zatem:
Co istotne, wejście każdej warstwy jest wyjściem poprzedniej warstwy, z wyjątkiem warstwy pierwszej, której wejściem jest argument :
Diagram poniżej przedstawia przykładową architekturę sieci neuronowej typu MLP, gdzie . Dla uproszczenia, na diagramie nie zostały uwzględnione parametry sieci neuronowej.
Funkcją kosztu, podobnie jak metryką, jest błąd średniokwadratowy lub średni błąd absolutny. Parametrami sieci neuronowej są macierze oraz wektory dla każdej z warstw , to znaczy:
Funkcja jest aproksymowana poprzez rozwiązywanie problemu optymalizacyjnego:
metodą schodzenia po gradiencie . Innymi słowy, w każdej iteracji treningu, jest modyfikowany według następującego równania:
gdzie to współczynnik uczenia. W praktyce zastosowany został algorytm stochastycznego schodzenia po gradiencie (SGD): zbiór rozbijany jest na podzbiory zwane batchami, i stosowane jest powyższe równanie do każdego batcha z osobna. Iteracja przez cały zbiór 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.
Ze względu na wybraną architekturę (fakt, że i różniczkowalność po i dla każdego ), 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:
W ogólności, chcąc obliczyć dla , można -krotnie zaaplikować regułę łańcuchową, to znaczy skorzystać z faktu, że:
i analogicznie:
Można zauważyć, że zachodzi fakt:
oraz analogiczny fakt dla .
Znane są również pochodne dla obu funkcji aktywacji:
oraz pochodna funkcji kosztu (dla pojedynczego batcha ):
Znane są wreszcie pochodne i dla każdej warstwy :
Obliczając wartości tych równań dla każdej warstwy można obliczyć pochodne funkcji kosztu po parametrach i dla każdego . Zrobione to zostało wydajnie, poprzez zapamiętywanie kolejnych pochodnych i podstawianie ich policzonych wartości przy obliczaniu dla .
Biblioteka micrograd, autorstwa Andreja Karpathy’ego, była pomocna podczas pracy nad projektem.