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}\}$. Цей вектор є виходом комірки (стрілка вгору), і він же відправляється в наступну комірку. Всередині комірки проводиться таке обчислення:
Оскільки всі комірки однакові, число входів 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 = False (у Keras за замовчуванням), то розмірність виходу шару це (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)
Аналогічним чином замість Dense можна використовувати інші рекурентні шари, створюючи з них стопку для глибокого навчання.
Ще трохи параметрів
При заданні рекурентного шару доступні також такі параметри:
- activation="linear" - лінійна функція комірки (замість "tanh" за замовчуванням). Можна використовувати будь-які доступні активаційні функції. Значення функції tanh знаходяться в діапазоні [-1...1]. Це, зазвичай, краще за [0...1] ("sigmoid") або [0...infty] ("relu"). При проходженні прихованого стану по шару він у кожній комірці множиться на одну й ту саму матрицю. Тому, якщо компоненти вектора $\mathbf{h}$ будуть завжди додатними, то прихований стан ризикує стати дуже великим або скотитися в нуль.
- stateful = True - не скидати в нуль прихований стан $\mathbf{h}^{(-1)}$ після кожного батча, а ставити замість нього стан останньої комірки на попередній ітерації (див. приклад нижче).
Іграшковий приклад
Розглянемо лінійну модель $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}$ дорівнює останньому прихованому стану:
Проробимо ці обчислення за допомогою рекурентної мережі:
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]$.
Прихований стан $\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)]