ML: Рекурентні мережі на Keras


Вступ

Іноді навчальні дані є впорядкованою послідовністю. Наприклад, часові ряди (котирування акцій, показання сенсора) або послідовність слів природної мови. У цих випадках має сенс використовувати рекурентні нейронні мережі RNN (recurrent neural network).

Як приклади на Python нижче ми будемо використовувати бібліотеки numpy і keras:

import numpy as np                                # робота з тензорами

from keras.models import Sequential, Model
from keras.layers import SimpleRNN, LSTM, Dense, Embedding, Concatenate, Bidirectional
Усі приклади можна знайти у файлі:ML_RNN_Keras.ipynb. Обговорення RNN на фреймворку PyTorch можна знайти тут.


SimpleRNN

Проста рекурентна мережа складається з inputs пов’язаних комірок з однаковими параметрами. Мережа отримує впорядковану послідовність довжиною inputs. Кожен елемент послідовності є f-вимірним вектором $\mathbf{x}=\{x_0,...,x_{f-1}\}$ ознак (features). Перший вектор надходить на вхід першої комірки, другий - на вхід другої і т.д. Кожна комірка характеризується units-вимірним вектором прихованого стану: $\mathbf{h}=\{h_0,...,h_{u-1}\}$. Цей вектор є виходом комірки (стрілка вгору), і він же відправляється в наступну комірку. Всередині комірки проводиться таке обчислення:

$$ \begin{array}{rcl} \mathbf{h}^{(t)} &=& f(\mathbf{x}^{(t)},\mathbf{h}^{(t-1)}) \\[3mm] &=& \tanh(\mathbf{x}^{(t)}\cdot \mathbf{W} + \mathbf{h}^{(t-1)}\cdot \mathbf{H} + \mathbf{b}) \end{array} $$

Оскільки всі комірки однакові, число входів inputs на число параметрів не впливає. Розмірності (shape) матриць дорівнюють: $\mathbf{W}:$(features, units), $~\mathbf{H}:$(units, units), $~\mathbf{b}:$( units, ), а загальне число параметрів:

   params = (features + units + 1) * units

Початковий вектор прихованого стану $\mathbf{h}^{(-1)}$ (що входить у першу комірку) або дорівнює нулю $\mathbf{0}$, або вважається рівним прихованому стану останньої комірки на прикладі попередньої послідовності (або батчі послідовностей). Розглянемо детальніше матричні множення при обчисленні прихованого стану.


Матричні множення

Фактично в RNN комірці вхідний вектор $\mathbf{x}^{(t)}$ і вектор прихованого стану попередньої комірки $\mathbf{h}^{(t-1)}$ об’єднуються (конкатенуються) і пропускаються через повнозв’язний шар з функцією активації than. На його виході і виходить прихований стан поточної комірки. Розглянемо детальніше, як відбуваються матричні обчислення.

Вектор ознак $\mathbf{x}_t$, як звичайно, множиться на матрицю ваг $\mathbf{W}$ зліва, щоб можна було додавати до нього рядки батча. Наприклад, для розмірності входу features=3 і розмірності виходу (прихованого стану) units=2 маємо:

$$ \begin{array}{|c|c|c|} \hline x^{(t)}_{0} & x^{(t)}_{1} & x^{(t)}_{2} \\ \hline \end{array} \cdot \begin{array}{|c|c|} \hline W_{00} & W_{01} \\ \hline W_{10} & W_{11} \\ \hline W_{20} & W_{21} \\ \hline \end{array} ~~ + ~~ \begin{array}{|c|c|} \hline h^{(t-1)}_{0} & h^{(t-1)}_{1} \\ \hline \end{array} \cdot \begin{array}{|c|c|} \hline H_{00} & H_{01} \\ \hline H_{10} & H_{11} \\ \hline \end{array} ~~+~~ \begin{array}{|c|c|} \hline b_{0} & b_{1} \\ \hline \end{array} ~~=~~ \begin{array}{|c|c|} \hline r^{(t)}_0 & r^{(t)}_1 \\ \hline \end{array} $$

Подібне матричне множення можна зробити більш компактним, виконавши конкатенацію векторів $\mathbf{x}^{(t)}$, $\mathbf{h}^{(t-1)}$ і приєднавши матрицю $\mathbf{H}$ до матриці $\mathbf{W}$ знизу:

$$ \begin{array}{|c|c|c|c|c|} \hline x^{(t)}_{0} & x^{(t)}_{1} & x^{(t)}_{2} & h^{(t-1)}_{0} & h^{(t-1)}_{1}\\ \hline \end{array} \cdot \begin{array}{|c|c|} \hline W_{00} & W_{01} \\ \hline W_{10} & W_{11} \\ \hline W_{20} & W_{21} \\ \hline H_{00} & H_{01} \\ \hline H_{10} & H_{11} \\ \hline \end{array} ~~+~~ \begin{array}{|c|c|} \hline b_{0} & b_{1} \\ \hline \end{array} ~~=~~ \begin{array}{|c|c|} \hline r^{(t)}_0 & r^{(t)}_1 \\ \hline \end{array} $$

Саме в цьому сенсі вище намальовані злиті в одну стрілки векторів $\mathbf{x}^{(t)},\mathbf{h}^{(t-1)}$. Обчисливши гіперболічний тангенс від результату множення $\mathbf{r}^{(t)}$, ми отримаємо вектор прихованого стану $\mathbf{h}^{(t)}= \tanh(\mathbf{r}^{(t)})$. Якщо множення виконується одразу для всіх прикладів батча, то вони додаються як рядки (нижче batch_size=3):

$$ \begin{array}{|c|c|c|c|c|} \hline x^{(t)}_{00} & x^{(t)}_{01} & x^{(t)}_{02} & h^{(t-1)}_{00} & h^{(t-1)}_{01}\\ \hline x^{(t)}_{10} & x^{(t)}_{11} & x^{(t)}_{12} & h^{(t-1)}_{10} & h^{(t-1)}_{11}\\ \hline x^{(t)}_{20} & x^{(t)}_{21} & x^{(t)}_{22} & h^{(t-1)}_{20} & h^{(t-1)}_{21}\\ \hline \end{array} \cdot \begin{array}{|c|c|} \hline W_{00} & W_{01} \\ \hline W_{10} & W_{11} \\ \hline W_{20} & W_{21} \\ \hline H_{00} & H_{01} \\ \hline H_{10} & H_{11} \\ \hline \end{array} ~~+~~ \begin{array}{|c|c|} \hline b_{0} & b_{1} \\ \hline \end{array} ~~=~~ \begin{array}{|c|c|} \hline r^{(t)}_{00} & r^{(t)}_{01} \\ \hline r^{(t)}_{10} & r^{(t)}_{11} \\ \hline r^{(t)}_{20} & r^{(t)}_{21} \\ \hline \end{array} $$

У цьому випадку рядок зміщення $(b_1~b_2)$ форми (1,2) додається до кожного рядка матриці форми (3,2) за правилом розширення (broadcasting), прийнятим у numpy.


SimpleRNN у Keras

При створенні рекурентного шару обов’язково вказується розмірність прихованого стану units і (якщо шар перший) розмірності вхідних векторів features (не перший шар отримає їх від попередніх шарів):

units    = 4          # розмірність прихованого стану   = dim(h)
features = 2          # розмірність входів               = dim(x)
inputs   = 3          # число входів (комірок RNN шару)

model = Sequential()                  
model.add(SimpleRNN(units=units, input_shape=(inputs, features))) # Output Shape: (None, 4)
За замовчуванням такий шар повертає прихований стан лише останньої комірки шару (див. наступний розділ). Тому тензор, який повертає шар, має розмірність (None, units) і не залежить від числа входів (комірок) inputs і їхньої розмірності features. None відповідає довільному числу прикладів у батчі.

Оскільки всі комірки однакові, число входів можна не вказувати, поставивши None:

model = Sequential()     
model.add(SimpleRNN(units=units, input_shape=(None, features)))   # Output Shape: (None, 4)
Тоді одна й та сама модель може обробляти не лише різні батчі, але й батчі з послідовностями різної довжини (але однієї для кожного прикладу даного батча):
                         
model.predict( np.zeros( (2, 3, features)  ))
model.predict( np.zeros( (2, 4, features)  ))

У першому predict батч з двох "прикладів" з трьома входами. У другому приклади батча з чотирма входами.

Іноді при визначенні моделі необхідно жорстко задати розмір батча. Тоді, як звичайно, використовуємо параметр batch_input_shape замість input_shape:

                         
model = Sequential()
model.add(SimpleRNN(units=units, batch_input_shape=(10, inputs, features)))  #    (10, 4)


Many-to-one або Many-to-many

Рекурентний шар може повертати прихований стан лише останньої комірки (return_sequences = False) або приховані стани всіх комірок шару (return_sequences = True):

Залежно від режиму змінюється розмірність виходу рекурентного шару. Якщо return_sequences = True (усі комірки повертають), то розмірність виходу шару це (batch_size, inputs, units):

model = Sequential()
model.add(SimpleRNN(units=4, input_shape=(None,2), return_sequences = True ))

Output Shape: (None, None, 4) 
При return_sequences = FalseKeras за замовчуванням), то розмірність виходу шару це (batch_size, units):
model = Sequential()
model.add(SimpleRNN(units=4, input_shape=(None, 2), return_sequences = False ))

Output Shape:  (None, 4)
Число параметрів шару (у даному прикладі 28) від значення return_sequences не залежить (це число елементів матриць комірки).

Якщо після рекурентного шару з return_sequences = True стоїть, наприклад, повнозв’язний шар Dense, то він з одними й тими самими параметрами застосовується до виходу кожної комірки:

model = Sequential()
model.add(SimpleRNN(units=4, input_shape=(None,2), return_sequences=True))
model.add(Dense(units=8))

Output Shape: (None, None, 8) 
Таким чином на виході маємо матрицю (batch_size, rnn_inputs, dense_units). Вектор прихованого стану $\mathbf{h}$ кожної комірки, проходячи через шар Dense, переходить у вектор іншої розмірності (вище dense_units=8). Якби було return_sequences = False, то на виході моделі була б матриця (batch_size, dense_units).

Аналогічним чином замість Dense можна використовувати інші рекурентні шари, створюючи з них стопку для глибокого навчання.


Ще трохи параметрів

При заданні рекурентного шару доступні також такі параметри:


Іграшковий приклад

Розглянемо лінійну модель $h_t = x_t+ h_{t-1}$ (усі "вектори" в ній однокомпонентні, тому часові індекси пишемо внизу). Як входи $x_t$ будемо використовувати два батчі по одному прикладу в кожному $\{1,2,3\}$ і $\{4,5,6\}$. Якщо ми працюємо в режимі stateful = False, то перед кожним батчем $h_{-1}=0$. Коли stateful = True, для першого батча $h_{-1}=0$, а для другого $h_{-1}$ дорівнює останньому прихованому стану:

$$ \begin{array}{ll} \mathrm{stateful} & \mathrm{=~False} \\ x_0 = 1, & h_1 = x_0 + h_0 = 1 + \mathbf{0} = 1\\ x_1 = 2, & h_2 = x_1 + h_1 = 2 + 1 = 3\\ x_2 = 3, & h_3 = x_3 + h_2 = 3 + 3 = \mathbf{6}\\ \hline x_0 = 4, & h_4 = x_0 + h_3 = 4 + \mathbf{0} = 4\\ x_1 = 5, & h_2 = x_1 + h_1 = 5 + 4 = 9\\ x_2 = 6, & h_3 = x_3 + h_2 = 6 + 9 = \mathbf{15}\\ \end{array} $$
$$ \begin{array}{ll} \mathrm{stateful} & \mathrm{=~True} \\ x_0 = 1, & h_1 = x_0 + h_0 = 1 + \mathbf{0} = 1\\ x_1 = 2, & h_2 = x_1 + h_1 = 2 + 1 = 3\\ x_2 = 3, & h_3 = x_3 + h_2 = 3 + 3 = \mathbf{6}\\ \hline x_0 = 4, & h_4 = x_0 + h_3 = 4 + \mathbf{6} = 10\\ x_1 = 5, & h_2 = x_1 + h_1 = 5 + 10 = 15\\ x_2 = 6, & h_3 = x_3 + h_2 = 6 + 15 = \mathbf{21}\\ \end{array} $$

Проробимо ці обчислення за допомогою рекурентної мережі:

model = Sequential()
model.add(SimpleRNN(units=1, batch_input_shape=(1, 3, 1), 
                    activation="linear", stateful = True, name='rnn') )
Оскільки за замовчуванням return_sequences=False, мережа повертатиме прихований стан лише останньої комірки (вище в прикладі цифри жирним шрифтом).

Задамо значення "матриць" рекурентної комірки. У нашому прикладі $\mathbf{W}$ і $\mathbf{H}$ складаються з одного елемента і мають форму (1,1). Зміщення $\mathbf{b}$ дорівнює нулю. Параметри в шарі упаковані в список у порядку [ W, H, b ]:

W, H, b  = np.array([[1]]), np.array([[1]]), np.array([0])
model.get_layer('rnn').set_weights(([ W, H, b ]))              # міняємо ваги
Тепер можна проводити обчислення:
res = model.predict(np.array([ [ [1], [2], [3] ]  ] )) 
print("res:",np.reshape(res, -1) )                             # res: [6.]

res = model.predict(np.array([ [ [4], [5], [6] ]  ] )) 
print("res:",np.reshape(res, -1) )                             # res: [21.]
Якщо покласти stateful = False, то результати будуть рівні 6 і 15.


LSTM

Стандартні RNN погано справляються з "довгостроковими залежностями" і в основному ловлять кореляції між сусідніми членами послідовності. З довгостроковими залежностями краще справляється рекурентний шар з LSTM комірками.

Крім прихованого стану $\mathbf{h}$ від комірки до комірки в LSTM шарі передається стан пам’яті $\mathbf{c}$. Цей вектор має розмірність units, як і звичайний прихований стан $\mathbf{h}$. Вектори $\mathbf{c}$ регулюють, які фічі треба забути, а які запам’ятати при передачі до наступної комірки. За допомогою цього потоку реалізується довгострокова пам’ять.

Всередині LSTM-комірки замість одного шару нейронної мережі (як у SimpleRNN) існує чотири шари. Сигмоїдні шари $\sigma$ на виході мають вектор зі значеннями $[0...1]$, а гіперболічний тангенс $\tanh$ - зі значеннями $[-1...1]$.

$$ \left\{ \begin{array}{lcl} \mathbf{h}_t &=& \tanh(\mathbf{c}_{t}) * f(\mathbf{x}_t,\,\mathbf{h}_{t-1}),\\[3mm] \mathbf{c}_t &=& g(\mathbf{x}_t,\,\mathbf{h}_{t-1},\, \mathbf{c}_{t-1}) \end{array} \right. $$

Прихований стан $\mathbf{h}$ обчислюється аналогічно SimpleRNN (але з сигмоїдою замість $\tanh$, див. останній прямокутник). Після цього він множиться на $\tanh(\mathbf{c})$. Гіперболічний тангенс береться незалежно від кожної компоненти вектора $\mathbf{c}$, роблячи при множенні відповідну компоненту $\mathbf{h}$ додатною або від’ємною (або, можливо, її зануляючи, якщо дана фіча-компонента не важлива для подальшого).

Перед цим обчисленням відбувається зміна значення вектора пам’яті $\mathbf{c}^{(t-1)}$. Спочатку $\mathbf{x}^{(t)}, \mathbf{h}^{(t-1)}$ потрапляють у повнозв’язний шар з сигмоїдою [0...1] на виході. Він називається гейтом забування. Розмірність виходу цього шару дорівнює units (це розмірність $\mathbf{h}$ і $\mathbf{c}$). Компоненти вихідного вектора множаться на компоненти попереднього вектора пам’яті $\mathbf{c}^{(t-1)}$ (без згортки!). При множенні якісь ознаки в $\mathbf{c}^{(t-1)}$ забуваються (якщо помножили на 0), а якісь рухаються далі (якщо їх помножили на 1). Приклад необхідності забування: "She put on a red hat and a green skirt" ("green" після іменника може забути "red")

Подібним чином працюють наступні два гейти, що реалізують запам’ятовування. Ті фічі, які необхідно запам’ятати, додаються у вектор $\mathbf{c}$. Шар з $\tanh$ формує "фічі-кандидати", масштабуючи їх в інтервал [-1...1]. Шар з сигмоїдом [0...1] підсилює або послаблює роль запам’ятовуваної фічі.

Більш детально обчислення в LSTM комірці виглядають таким чином: $$ \left\{ \begin{array}{lclclcl} \mathbf{F} &=& ~~~~~~\sigma(\mathbf{x}^{(t)}\, \mathbf{W}_{f} &+& \mathbf{h}^{(t-1)}\, \mathbf{H}_{f} &+& \mathbf{b}_f),\\ \mathbf{I} &=& ~~~~~~\sigma(\mathbf{x}^{(t)}\, \mathbf{W}_{i} &+& \mathbf{h}^{(t-1)}\, \mathbf{H}_{i} &+& \mathbf{b}_i),\\ \mathbf{R} &=& \text{tanh}(\mathbf{x}^{(t)} \mathbf{W}_{r} &+& \mathbf{h}^{(t-1)}\, \mathbf{H}_{r} &+& \mathbf{b}_r),\\ \mathbf{O} &=& ~~~~~~\sigma(\mathbf{x}^{(t)}\, \mathbf{W}_{o} &+& \mathbf{h}^{(t-1)}\, \mathbf{H}_{o} &+& \mathbf{b}_o), \end{array} \right. ~~~~~~~~~~~~~~~~~~ \left\{ \begin{array}{lcl} \mathbf{c}^{(t)} &=& \mathbf{F} * \mathbf{c}^{(t-1)} + \mathbf{I} * \mathbf{R},\\[2mm] \mathbf{h}^{(t)} &=& \tanh\bigr(\mathbf{c}^{(t)}\bigr) * \mathbf{O}. \end{array} \right. $$

Розмірності матриць дорівнюють [feature$=\dim(\mathbf{x}),~~~~$ units$=\dim(\mathbf{h}),~\dim(\mathbf{c})$]: $$ \begin{array}{llllll} \mathbf{W}_{i}, &\mathbf{W}_{f}, &\mathbf{W}_{r}, &\mathbf{W}_{o}: &\mathrm{(features, units)},\\ \mathbf{H}_{i}, &\mathbf{H}_{f}, &\mathbf{H}_{r}, &\mathbf{H}_{o}: &\mathrm{(units, units)},\\ \mathbf{b}_i, &\mathbf{b}_f, &\mathbf{b}_r, &\mathbf{b}_o: &\mathrm{(1, units)}. \end{array} $$

Bidirectional шар

Іноді в послідовностях існує вплив на теперішнє не лише минулого, але й майбутнього. Наприклад, сенс даного слова в реченні визначається всім реченням, а не лише попередніми до нього словами.

У цьому випадку доречно спільно використовувати два рекурентні шари, у першому з яких приховані стани поширюються зліва направо, а в наступному шарі - справа наліво. Вхідні вектори подаються незалежним чином на кожен шар, а їхні виходи конкатенуються. Ваги комірок кожного шару різні.

Якщо return_sequences = False, то, як і раніше, на вихід Bidirectional шару надходить лише конкатенація виходів останніх комірок кожного шару.

VOC_SIZE, VEC_DIM, inputs = 100, 8, 3

model = Sequential()  
model.add(Embedding(input_dim=VOC_SIZE, output_dim=VEC_DIM, input_length=inputs)) 

model.add(Bidirectional(SimpleRNN(units=4, return_sequences=True)))      # (None,3,8)

model.add(Dense(units = 1, activation='sigmoid'))                        # (None,3,1)

Вище першим стоїть шар Embedding, який отримує розмір словника VOC_SIZE і розмірність векторного простору VEC_DIM і створює 2-вимірний масив (VOC_SIZE, VEC_DIM). Embeddibg переводить ціле число на вході (індекс слова) у вектор, який відповідає цьому слову на виході. У Embedding можна задавати число входів input_length, і тоді воно, як і число прикладів у пачці (batch_size), вважається невизначеним (мережа може обробляти будь-яку кількість входів).

Три вектори (inputs=3) розмірності VEC_DIM=8 подаються на 3 входи Bidirectional шару. Оскільки розмірність прихованого стану units=4, то на вході кожної комірки буде по 8-вимірному вектору (конкатенація 4+4=8).


Параметр return_state

При функціональному визначенні мережі можна повернути не лише виходи комірок, але й усі приховані стани останньої комірки

units    = 4                   # розмірність прихованого стану   = dim(h)
features = 2                   # розмірність входів               = dim(x)
inputs   = 3                   # число входів (комірок RNN шару)

inp    = Input( shape=(inputs, features) )
rnn    = LSTM (units, return_sequences=True, return_state=True)(inp)

model  = Model(inp, rnn)       #   [(None, 3, 4), (None, 4), (None, 4)]
Список матриць відповідає всім виходам (return_sequences=True), прихованому стану h і прихованому стану пам’яті c.
inp    = Input(shape=(inputs, features) )
rnn    = LSTM (units, return_sequences = True, return_state=True)

_,h,s  = rnn(inp)              # ігноруємо виходи (перша матриця)

con    = Concatenate()([h,s])  # (None, 8)

model  = Model(inp, con)

У випадку двонапрямленого шару

inp    = Input(shape=(inputs, features) )
rnn    = LSTM (units, return_sequences = True, return_state=True)
bid    = Bidirectional(rnn)(inp)

Output shape: [(None, 3, 8), (None, 4), (None, 4), (None, 4), (None, 4)]