Sieć konwolucyjna do rozpoznawania ciągu cyfr część 2

Jest to  druga część serii przedstawiającej sposób pracy z sieciami konwolucyjnymi (Conv Nets) z wykorzystaniem TensorFlow. Dokonamy w niej rozpoznania oraz klasyfikacji szeregu cyfr jednocześnie, co będzie wiązało się z kilkoma istotnymi zmianami w skrypcie w stosunku do poprzedniego wpisu. Stanowi to także dobry przykład do omówienia jednego z kluczowych elementów skutecznego uczenia czyli inicjalizacji wag w sieci.

W części pierwszej (Sieć konwolucyjna do rozpoznawania ciągu cyfr z obrazów) postawiliśmy sobie za cel rozpoznanie wygenerowanych cyfr na obrazie, lecz do tej pory omówiliśmy budowę i architekturę sieci do rozpoznawania tylko jednej cyfry. Niniejszy wpis wprowadzi cię na wyższy poziom, a mianowicie zamiast jednej cyfry będziemy rozpoznawać 2 a później aż 6, co uczyni się TensorFlow ninja w rozpoznawaniu ciągów cyfr 🙂

Czytając powyższy akapit, mogłeś(-aś) sobie pomyśleć, że skoro w poprzednim wpisie udało się nam rozpoznawać jedną cyfrę to z kolejnymi pójdzie już łatwo. No nie tak szybko. Rozpoznawanie kilku cyfr naraz wymagać od nas będzie modyfikacji architektury sieci, zmiany warstwy wyjściowej oraz sposobu obliczania funkcji straty. Dodatkowo uwypukli ono problemy związane z samym uczeniem, zobaczysz jak ważna jest inicjalizacja wag w sieci aby w trakcie optymalizacji nie zaistniał problem znikających lub eksplodujących gradientów (ang. vanishing gradients).

Dane – obrazy z ciągiem cyfr

W tym poście wykorzystamy dwa wygenerowane zbiory danych (odsyłam do poprzedniego posta):

Przykładowe dane wyglądają następująco:

Wygenerowane losowe ciągi cyfr

Przykładowe dane, wygenerowane losowe ciągi cyfr z zbioru Digit_6f3

Wyjście sieci – zmodyfikowana wersja one-hot encoding

Poniższe przykłady zostaną zaprezentowane dla ciągu z dwoma cyframi, czyli na wyjściu sieci będziemy przewidywać dwie cyfry. Niesie to za sobą konsekwencje w postaci zmiany sposobu reprezentacji wczytanych etykiet, wcześniej gdy rozpatrywaliśmy jedną cyfrę to użyliśmy podejścia one-hot encoding, teraz musimy określić prawidłowe wyjście dla dwóch cyfr.

Jednym z rozwiązań jest połączenie dwóch wektorów określających każdą z cyfr, a więc gdy chcemy zakodować liczbę np. 35 to otrzymamy wektor o 2*10 wymiarach [0,0,0,1,0,0,0,0,0,0,  0,0,0,0,0,1,0,0,0,0]. Pierwsze 10 wymiarów określa kodowanie dla pierwszej cyfry (3) a kolejne dla następnej cyfry (5). Podobnie postąpimy przy 6 cyfrach, w wyniku otrzymamy wektor o 60 wymiarach. Łatwo zauważyć, że kodowanie to nie jest idealne, gdyż dla długich ciągów wymiar tego wektora będzie duży. Problem staje się jeszcze istotniejszy w sytuacji gdy kodujemy nie tylko cyfry ale i litery. Stosując to podejście na jeden znak przypadnie zdecydowanie większa część kodująca (10 cyfr+26 liter małych+26 liter wielkich) mnożąc to razy ilość znaków szybko otrzymamy wektory o rozmiarze przekraczającym setki . Nie jest to najefektywniejsza metoda, lecz dla ciągów do kilku, kilkunastu znaków skuteczna i wystarczająca (rozwiązaniem tego problemu mogą być sieci rekurencyjne).

Definicja funkcji straty

Zmieniając reprezentację etykiet oraz wyjście sieci, musimy także inaczej podejść do określania błędu jaki nasza sieć popełniła. Nie możemy już zastosować funkcji tf.nn.softmax_cross_entropy_with_logitsgdyż jak mówi dokumentacja TF:

Measures the probability error in discrete classification tasks in which the classes are mutually exclusive (each entry is in exactly one class). For example, each CIFAR-10 image is labeled with one and only one label: an image can be a dog or a truck, but not both.

Czyli jednym słowem nasze wektory kodujące powinny wskazywać na jedną klasę (powinna być jedna jedynka). Jak możemy podejść do tematu?
Możemy tak zmodyfikować funkcję straty, aby rozbijała wektor na poszczególne cyfry następnie obliczała softmax_cross_entropy dla każdej cyfry z osobna w ciągu, by na koniec je zsumować lub wyciągnąć średnią z wartości funkcji strat dla części. I takie było moje pierwsze podejście

# Construct model
pred = conv_net(x, weights, biases, keep_prob)

#split prediction for each digit, we have 6 digits
split_pred = tf.split(1,6,pred)
split_y = tf.split(1,6,y)


#compute partial softmax cost, for each digit
costs = list()
for i in range(6):
    costs.append(tf.nn.softmax_cross_entropy_with_logits(split_pred[i],split_y[i]))
    
#reduce cost for each digit
rcosts = list()
for i in range(20):
    rcosts.append(tf.reduce_mean(costs[i]))
   
# global reduce    
loss = tf.reduce_sum(rcosts)

Jednak bardzo szybko natknąłem się na funkcję tf.nn.sigmoid_cross_entropy_with_logits (uważnie się przyjrzyjcie na początku jest sigmoid a nie softmax), która właśnie bardzo dobrze nadaję się do zadań z wieloma etykietami (u nas jest to kilka cyfr), dokumentacja opisuje to następująco:

Measures the probability error in discrete classification tasks in which each class is independent and not mutually exclusive. For instance, one could perform multilabel classification where a picture can contain both an elephant and a dog at the same time

A nasza funkcja określająca uczenie, upraszcza się do:

# Construct model
pred = conv_net(x, weights, biases, keep_prob)
cross_entropy = tf.nn.sigmoid_cross_entropy_with_logits(pred,y)
loss = tf.reduce_mean(cross_entropy)

i to z niej będziemy dalej korzystać.

Architektura sieci

Sama architektura nie zmieniła się w stosunku do przykładu z jedną cyfrą, nadal sieć składa się z

  • dwóch warstw konwolucyjnych (conv1, conv2)
  • dwóch warstw dokonujących max-pool’ing
  • jednej warstwy w pełni połączonej (full connect – fc)
  • jednej warstwie wyjściowej (output)
def conv_net(_X, _weights, _biases, _dropout):
    # Reshape input picture
    _X = tf.reshape(_X, shape=[-1, img_h, img_w, 1])

    # Convolution Layer 3x3x32 first, layer with relu
    conv1 = conv2d(_X, _weights['wc1'], _biases['bc1'])
    # Max Pooling (down-sampling), change input size by factor of 2 
    conv1 = max_pool(conv1, k=2)
    # Apply Dropout
    conv1 = tf.nn.dropout(conv1, _dropout)
    
    # Convolution Layer, 3x3x64
    conv2 = conv2d(conv1, _weights['wc2'], _biases['bc2'])
    # Max Pooling (down-sampling)
    conv2 = max_pool(conv2, k=2)
    # Apply Dropout
    conv2 = tf.nn.dropout(conv2, _dropout)

    # Fully connected layer
    dense1 = tf.reshape(conv2, [-1, _weights['wd1'].get_shape().as_list()[0]]) # Reshape conv2 output to fit dense layer input
    dense1 = tf.nn.relu(tf.add(tf.matmul(dense1, _weights['wd1']), _biases['bd1'])) # Relu activation
    dense1 = tf.nn.dropout(dense1, _dropout) # Apply Dropout

    # Output, class prediction
    out = tf.add(tf.matmul(dense1, _weights['out']), _biases['out'])
    #out = tf.nn.softmax(out)
    return out

Niuanse mają znaczenie podczas uczenia sieci

W poprzednim wpisie poszliśmy trochę na żywioł. Po pierwsze nie dokonaliśmy normalizacji danych, a po drugie nie przejmowaliśmy się wcale inicjalizacją wag w sieci. Oba te elementy są niezwykle istotne w trakcie uczenia. Na szczęście dla nas poprzedni problem był na tyle prosty, że te kwestie nie odegrały wielkiej roli i sieć wyuczyła się rozpoznawania cyfr. Niestety w tym przykładzie tak różowo już nie jest, bez tych dwóch elementów sieć będzie dawała bardzo słabe wyniki (możecie się o tym sami przekonać komentując poszczególne bloki kodu).

Pierwszą rzeczą, którą wykonamy jest normalizacja danych. Na samym początku skryptu znormalizujmy nasze dane do rozkładu normalnego N(0,1) (średnia 0, std=1).

Xdata,Y,files = dh.load_dataset('shared/Digits_2f1',(img_w,img_h),digits)
# standarization 
#compute mean across the rows, sum elements from each column and divide
x_mean = Xdata.mean(axis=0)
x_std  = Xdata.std(axis=0)
X = (Xdata-x_mean)/(x_std+0.00001)

Kluczem do uczenia jest inicjalizacja wag w sieci neuronowej

W poprzednim poście wagi dla poszczególnych warstw były inicjalizowane losowo z rozkładu normalnego o średniej 0 i odchyleniu standardowym 1. Tak duże wartości wag mogą powodować problem zanikających lub explodujących gradientów, bardzo dobrze ten temat został opisany w rodziale online’owej książki Why are deep neural networks hard to train?

Z tego powodu należy zainicjalizować wagi bardzo uważnie. W literaturze obecnie można spotkać dwa sposoby. Sposób pierwszy – eksperymentlanie poszukać rzędu wielkości dla wag, ja to robię poprzez ustalenie parametru alpha=0.005, który służy jako mnożnik zmniejszający wagi. Sposób drugi opisany został w publikacji „Delving Deep into Rectifiers: Surpassing Human-Level Performance on ImageNet Classification” i polega na obliczeniu mnożnika skalującego wagi dla każdej warstwy zgodnie z wzorem

\(init\_factor=\sqrt{\frac{2}{input\_size}}\)
#first initialization way
alpha=0.005
init_wc1 = alpha
init_wc2 = alpha 
init_wd1 = alpha
init_out = alpha

#second init way
#relu init sqrt(2/node_input)
#init_wc1 = np.sqrt(2.0/(img_w*img_h)) #
#init_wc2 = np.sqrt(2.0/(3*3*32)) 
#init_wd1 = np.sqrt(2.0/(8*40*64))
#init_out = np.sqrt(2.0/1024)

weights = {
    'wc1': tf.Variable(init_wc1*tf.random_normal([3, 3, 1, 32]),name='wc1'), # 3x3 conv, 1 input, 32 outputs
    'wc2': tf.Variable(init_wc2*tf.random_normal([3, 3, 32, 64]),name='wc2'), # 3x3 conv, 32 inputs, 64 outputs
    'wd1': tf.Variable(init_wd1*tf.random_normal([8*14*64, 1024]),name='wd1'), 
    'out': tf.Variable(init_out*tf.random_normal([1024, n_classes]),name='w_out') # 1024 inputs, 2*10 output
}

biases = {
    'bc1': tf.Variable(0.1*tf.random_normal([32]),name='bc1'),
    'bc2': tf.Variable(0.1*tf.random_normal([64]),name='bc2'),
    'bd1': tf.Variable(0.1*tf.random_normal([1024]),name='bd1'),
    'out': tf.Variable(0.1*tf.random_normal([n_classes]),name='b_out')
}

Ze względu na różne wymiary obrazów w wykorzystywanych zbiorach dancyh, wagi w warstwie przedostatniej będą miały różne wymiary:

  • dla zbioru Digit_2f1 – w którym obrazy mają wymiar (56,32) – po zastosowaniu dwóch operacji pooling, wymiary zmniejszą się za każdym razem dwukrotnie czyli z 56 zrobi się 14(56/2/2), wd1 będzie miało postać
        'wd1': tf.Variable(init_wd1*tf.random_normal([8*14*64, 1024]),name='wd1')
    
  • dla zbioru Digit_6f3 – obrazy mają wymiary (160,32), więc wd1 będzie miało postać:
        'wd1': tf.Variable(init_wd1*tf.random_normal([8*40*64, 1024]),name='wd1'),

Wyniki

Uruchamiając skrypt zachęcam was do poeksperymentowania z różnymi sposobami oraz wartościami początkowymi wag w sieci. Poniżej prezentuję wykresy prezentujące wartość funkcji straty oraz dokładność dla zbioru Digit_6f3 dla różnych parametrów. Skrypty uruchamiałem na platformie PLON na 16 rdzeniach i jedno uruchomienie 1500 iteracji zajmowało mi ok 20h, więc musicie uzbroić się w cierpliwość (chyba że macie dostęp do GPU wtedy pójdzie to zdecydowanie szybciej). Zwróćcie uwagę na szybkość z jaką zbiegają wykresy dla poszczególnych wartości parametrów

Rozpoznawanie 6 cyfr, inicjalizacja wag sieci N(0,0.1)

Rozpoznawanie 6 cyfr, wykres loss i accuracy dla inicjalizacji wag w warstwach z rozkładu normalnego. Parametr alpha=0.1

 

Rozpoznawanie 6 cyfr, inicjalizacja wag alpha=0.05

Rozpoznawanie 6 cyfr, wykres loss i accuracy dla inicjalizacji wag w warstwach z rozkładu normalnego. Parametr alpha=0.05

Rozpoznawanie ciągu cyfr, wykres loss i accuracy

Rozpoznawanie 6 cyfr, wykres loss i accuracy dla inicjalizacji wag w warstwach z rozkładu normalnego. Parameter alpha=0.005

Rozpoznawanie 6 cyfr, wykres loss i accuracy dla inicjalizacji wag w warstwach dla sieci z RELU

Rozpoznawanie 6 cyfr, wykres loss i accuracy dla inicjalizacji wag w warstwach dla sieci z RELU

Powyższe 4 przykłady, w których wagi były odpowiednio inicjowane z coraz mniejszymi wartościami parametru alpha=0.1, 0.05, 0.005 oraz z wykorzystaniem inicjalizacji dla warstw z nieliniowością ReLU, pokazują jaki wpływ na uczenie ma poprawna inicjalizacja. Zależy od niej szybkość uczenie, a w wielu przypadkach także to czy w procesie uczenia (aktualizacji wag) sieć będzie w stanie wogóle czegoś się nauczyć. Z powyższych wykresów można odczytać że najszybciej sieć uczyła się dla parametru alpha=0.05 oraz dla inicjalizacji ReLu. Inicjalizacja ReLu ma tą ogromną zaletę, że nie trzeba zgadywać tylko podstawiamy do wzoru i mamy wynik.

Podsumowanie

Cały kod udostępniony jest na githubie w ramach projektu numbers_recognition, natomiast niniejszy wpis opiera się głównie na skrypcie conv_net_many_digits.py. Dodatkowo projekt można uruchomić wprost w przeglądarce, korzystając z serwisu PLON.

Idee zawarte w tym oraz poprzednim wpisie po kilku przeróbkach można wykorzystać w wielu projektach np. przy rozpoznawaniu numerów startowych sportowców w biegach, do rozpoznawania tablic rejestracyjncyh, do rozpoznawania sygnatury akt itp. Mam nadzieję, że przedstawione skrypty uda się wam z pożytkiem wykorzystać. Jeżeli macie pytania to śmiało zadawajcie je tutaj lub poprzez kanały w social media (Facebook – About DataTwitter – ksopylaSnapchat – ksopyla)

 

Ciekawe, wartościowe, podziel się proszę opinią!

This site uses Akismet to reduce spam. Learn how your comment data is processed.