GRU и LTSM сети

GRU – управляемый рекуррентный нейрон

Здравствуйте и вновь добро пожаловать на занятия по теме «Рекуррентные нейронные сети на языке Python: глубокое обучение, часть 5».

В этой статье мы поговорим о более мощном рекуррентном нейроне, нежели нейрон Элмана – о так называемом управляемом рекуррентном нейроне, сокращённо GRU. Управляемый рекуррентный нейрон появился в 2014 году, тогда как сети долгой краткосрочной памяти – в 1997-м. Обычно при изучении рекуррентных нейронных сетей сначала изучают сети долгой краткосрочной памяти, возможно, потому что они более популярны, а возможно – потому что они были открыты первыми.

GRU можно рассматривать как более простую версию сетей долгой краткосрочной памяти. Он включает в себя много схожих понятий, но имеет куда меньше параметров, а потому при одном и том же размере скрытого слоя обучается быстрее. В то же время управляемый рекуррентный нейрон сложнее, чем обсуждавшийся перед этим обновляемый. Поэтому вместо того, чтобы сначала вводить сети долгой краткосрочной памяти, в этих лекциях материал подаётся по мере повышения сложности. Недавние исследования также показали, что управляемый рекуррентный нейрон и сети долгой краткосрочной памяти имеют сопоставимую точность, а в некоторых случаях GRU даже точнее. Таким образом, нет строгого правила, указывающего, что именно выбирать. Это как выбор лучшей функции активации для обычных нейронных сетей – нужно попробовать разные варианты и посмотреть, что лучше работает для ваших конкретных данных.

Опишем строение управляемого рекуррентного нейрона. Прежде всего будем всё рассматривать по частям. Представим себе содержимое между предыдущим и следующим слоями в виде «чёрного ящика». В простейшей нейронной сети прямого распространения этот «чёрный ящик» содержит лишь некоторую нелинейную функцию, например гиперболический тангенс или relu. В простой рекуррентной сети мы просто подсоединяем выход из «чёрного ящика» к нему самому с задержкой во времени в минус единицу. В обновляемой рекуррентной сети у нас была ещё операция обновления между выходом простой рекуррентной сети и предыдущим исходящим значением. Эту новую операцию можно рассматривать как логический вентиль (gate). Поскольку он принимает значения от 0 до 1, а второй вентиль должен быть равен единица минус это значение, то это вентиль, выбирающий между двумя вариантами: либо принять старое значение, либо принять новое. В результате мы получаем смесь их обоих.

Переходим теперь к собственно управляемому рекуррентному нейрону, GRU. Его строение требует просто добавления ещё одного вентиля. Некоторым проще понять с помощью картинки (см. слайд), а некоторым – с помощью формул:

Заметьте, здесь нет никакой новой математики – лишь то, что мы уже изучали о матрицах весовых коэффициентов, умноженных на входные данные и пропущенных через нелинейные функции и вентили. Мы вновь видим обновление z(t), с которым встречались в обновляемом рекуррентном нейроне. Оно по-прежнему определяет, что из предыдущего скрытого значения, а что из нового кандидата в скрытый слой скомбинируется в новом скрытом значении.

Дополнительным тут является только rt – вентиль сброса. Он имеет в точности такую же функциональную форму, что и вентиль обновления, и все его весовые коэффициенты имеют тот же размер, но его положение в нашем «чёрном ящике» несколько отличается. Вентиль сброса умножается на предыдущее значение скрытого состояния. Он контролирует, что из предыдущего скрытого состояния будет учитываться при создании нового кандидата в значение скрытого состояния. Другими словами, он имеет возможность сбросить скрытое значение. Если r(t) = 0, то

Это как если бы x(t) было началом новой последовательности.

Обратите внимание, что это неполная картина, поскольку  является лишь кандидатом в новые ht, поскольку само новое ht будет комбинацией  и h(t-1), что управляется вентилем обновления z(t).

Если всё это звучит чересчур сложно, то можно посмотреть по-другому, более прагматично. Что получится, если мы будем просто добавлять больше параметров в нашу модель, делая её более выразительной? Добавление большего количества параметров позволяет нам приспосабливаться к более сложным закономерностям. С точки зрения кода, написание GRU должно быть относительно несложным, если вы уже понимаете простой и обновляемый рекуррентные нейроны – мы просто добавляем больше весовых коэффициентов и видоизменяем рекурсию.

Следующим этапом при улучшении нашего кода является его модульность. Я говорил про GRU как про «чёрный ящик». В моих предыдущих курсах мы помещали скрытый и свёрточный слои в класс, чтобы их можно было хранить повторно использовать. С GRU мы сделаем то же самое – превратим в класс и абстрагируемся. Сделав так, вы оставляете за собой возможность хранить GRU, и теперь всюду, где вы будете использовать простую нейронную сеть, вы сможете вставить GRU. Это просто объект, который принимает входные данные и выводит результат. То обстоятельство, что он содержит память о предыдущих входных данных, является лишь внутренней особенностью «чёрного ящика».

Вот отрывок псевдокода, который описывает, что мы будем делать:

class GRU:

    def __init__ (Mi, Mo):

        Wxr = random (Mi, Mo)

         …

    def recurrence (x_t, h_t1):

        r = sigmoid(x_t.dot(Wxr) + h_t1.dot(Whr) + br)

        …

        return (1 – z) * h_t1 + z * hhat

    def output(x):

        return scan(recurrence, x)

GRU в коде

Мы не будем проводить каких-то экспериментов на каких-либо данных. Мы просто создадим GRU, чтобы потом использовать его в качестве «чёрного ящика» в последующем коде.

Итак, вначале импортируем библиотеки Numpy, Theano и Theano.tensor; из файла util нам нужна лишь функция init_weight.

import numpy as np

import theano

import theano.tensor as T

from util import init_weight

class GRU:

    def __init__(self, Mi, Mo, activation):

        self.Mi = Mi

        self.Mo = Mo

        self.f  = activation

Инициируем весовые коэффициенты.

        Wxr = init_weight(Mi, Mo)

        Whr = init_weight(Mo, Mo)

        br  = np.zeros(Mo)

        Wxz = init_weight(Mi, Mo)

        Whz = init_weight(Mo, Mo)

        bz  = np.zeros(Mo)

        Wxh = init_weight(Mi, Mo)

        Whh = init_weight(Mo, Mo)

        bh  = np.zeros(Mo)

        h0  = np.zeros(Mo)

Следующий этап – создание переменных Theano.

        self.Wxr = theano.shared(Wxr)

        self.Whr = theano.shared(Whr)

        self.br  = theano.shared(br)

        self.Wxz = theano.shared(Wxz)

        self.Whz = theano.shared(Whz)

        self.bz  = theano.shared(bz)

        self.Wxh = theano.shared(Wxh)

        self.Whh = theano.shared(Whh)

        self.bh  = theano.shared(bh)

        self.h0  = theano.shared(h0)

        self.params = [self.Wxr, self.Whr, self.br, self.Wxz, self.Whz, self.bz, self.Wxh, self.Whh, self.bh, self.h0]

Теперь можно приступать к функции recurrence.

    def recurrence(self, x_t, h_t1):

        r = T.nnet.sigmoid(x_t.dot(self.Wxr) + h_t1.dot(self.Whr) + self.br)

        z = T.nnet.sigmoid(x_t.dot(self.Wxz) + h_t1.dot(self.Whz) + self.bz)

        hhat = self.f(x_t.dot(self.Wxh) + (r * h_t1).dot(self.Whh) + self.bh)

        h = (1 – z) * h_t1 + z * hhat

        return h

Функция output, с использованием Theano scan. Входящие X должны быть двухмерной матрицей.

    def output(self, x):

        h, _ = theano.scan(

            fn=self.recurrence,

            sequences=x,

            outputs_info=[self.h0],

            n_steps=x.shape[0],

        )

return h

Это всё, что касается GRU.

LSTM – сети долгой краткосрочной памяти

В данном разделе статьи мы обсудим еще одну тему:

LSTM расшифровывается как сеть долгой краткосрочной памяти и представляет собой тип рекуррентного нейрона, ставший в последние годы очень популярным в связи со своими превосходными характеристиками и тем обстоятельством, что он не так подвержен проблеме исчезающего градиента.

Как вы могли заметить, все изучавшиеся до сих пор рекуррентные нейроны становились всё более сложными, и каждый из них включал в себя понятия из предыдущих. LSTM является наиболее сложным рекуррентным нейроном, который вы изучите в рамках этого курса.

Впрочем, сложность заключается не в трудности понимания основной идеи или математики; просто количество составляющих тут выше. Суть в том, что теперь у нас будет три различных вентиля, плюс мы добавим ещё один внутренний модуль, который будет существовать наряду со скрытым состоянием и иногда называется ячейкой памяти, или просто ячейкой. Три вентиля носят названия входного вентиля, выходного вентиля и вентиля забывания. Ячейку памяти же можно рассматривать как то, что занимает место , то есть кандидата в скрытые значения, когда мы обсуждали GRU:

Входной вентиль i(t) и вентиль забывания f(t) должны напомнить вам о вентиле обновления из GRU. До этого мы использовали z(t) и 1 – z(t), но теперь у нас будет два отдельных вентиля. Входной вентиль контролирует, какая часть нового значения переходит в ячейку памяти, а вентиль забывания – какая часть предыдущего значения ячейки переходит в текущее значение ячейки.

Кандидат в новое значение ячейки памяти c(t) очень похож на значение простого рекуррентного нейрона перед умножением на входной вентиль.

И наконец выходной вентиль принимает в расчёт всё: вход в момент t, предыдущее скрытое состояние и текущее значение ячейки памяти.

Новое же скрытое состояние h(t) является всего лишь гиперболическим тангенсом значения ячейки памяти, умноженным на выходной вентиль.

Поскольку параметров модели очень много, будет нелишним взглянуть на их всех. Это поможет нам организовать параметры, что всё не казалось таким сложным.

Входной вентиль имеет следующие параметры: Wxi, Whi, Wci и bi. Он зависит от x(t), h(t-1) и c(t-1).

Вентиль забывания имеет соответствующие параметры: Wxf, Whf, Wcf и bf. Он зависит опять-таки от x(t), h(t-1) и c(t-1).

Далее, чтобы вычислить новое значение ячейки памяти у нас есть Wxc, Whc и bc. Зависит от x(t) и h(t-1).

И наконец выходной вентиль с четырьмя параметрами: Wxo, Who, Wco и bo. Зависит от x(t), h(t-1) и c(t).

Обратите внимание, что выходной вентиль отличается от входного и вентиля забывания – он зависит от значения ячейки памяти в момент t, а не t-1.

В целом в «чёрном ящике» получается 15 новых весовых коэффициентов и свободных членов. Мы далеко ушли от нейронных сетей прямого распространения, в которых лишь по одному весовому коэффициенту и одному свободному члену!

Если вы всё же находите сложность зашкаливающей, то я подчеркну: на это можно смотреть как на простое добавление дополнительных параметров, которые делают модель более выразительной. Как и в случае с GRU, мы создадим класс и сделаем LSTM модульной. Я покажу, что поскольку они оба являются «чёрными ящиками», их можно взаимозаменять и использовать для решения одних и тех же задач.

На этот момент вы уже должны твёрдо знать, как создавать рекуррентные нейроны. Мы вновь просто добавляем несколько новых переменных и видоизменяем функцию recurrence. В действительности вы уже должны быть в состоянии самим написать класс LSTM, исходя из представленных мною формул. Я настоятельно рекомендую так и сделать, прежде чем переходить к коду.

LSTM в коде

Я вновь призываю сначала попробовать сделать это самостоятельно, а потом уже смотреть, как это делаю я. Это очень просто и очень похоже на GRU, код для которого мы уже написали.

Действительно, все импорты те же самые, а всё, что мы делаем, – просто изменяем формулу в классе.

import numpy as np

import theano

import theano.tensor as T

from util import init_weight

Итак, создадим наш класс LSTM:

class LSTM:

    def __init__(self, Mi, Mo, activation):

        self.Mi = Mi

        self.Mo = Mo

        self.f  = activation

Определяем параметры. Первые три – для входного вентиля плюс четвёртный свободный член. Аналогично с остальными.

        Wxi = init_weight(Mi, Mo)

        Whi = init_weight(Mo, Mo)

        Wci = init_weight(Mo, Mo)

        bi  = np.zeros(Mo)

        Wxf = init_weight(Mi, Mo)

        Whf = init_weight(Mo, Mo)

        Wcf = init_weight(Mo, Mo)

        bf  = np.zeros(Mo)

        Wxc = init_weight(Mi, Mo)

        Whc = init_weight(Mo, Mo)

        bc  = np.zeros(Mo)

        Wxo = init_weight(Mi, Mo)

        Who = init_weight(Mo, Mo)

        Wco = init_weight(Mo, Mo)

        bo  = np.zeros(Mo)

        c0  = np.zeros(Mo)

        h0  = np.zeros(Mo)

Создаём переменные Theano.

        self.Wxi = theano.shared(Wxi)

        self.Whi = theano.shared(Whi)

        self.Wci = theano.shared(Wci)

        self.bi  = theano.shared(bi)

        self.Wxf = theano.shared(Wxf)

        self.Whf = theano.shared(Whf)

        self.Wcf = theano.shared(Wcf)

        self.bf  = theano.shared(bf)

        self.Wxc = theano.shared(Wxc)

        self.Whc = theano.shared(Whc)

        self.bc  = theano.shared(bc)

        self.Wxo = theano.shared(Wxo)

        self.Who = theano.shared(Who)

        self.Wco = theano.shared(Wco)

        self.bo  = theano.shared(bo)

        self.c0  = theano.shared(c0)

        self.h0  = theano.shared(h0)

        self.params = [

            self.Wxi,

            self.Whi,

            self.Wci,

            self.bi,

            self.Wxf,

            self.Whf,

            self.Wcf,

            self.bf,

            self.Wxc,

            self.Whc,

            self.bc,

            self.Wxo,

            self.Who,

            self.Wco,

            self.bo,

            self.c0,

            self.h0,

        ]

Теперь можем заняться функцией recurrence.

    def recurrence(self, x_t, h_t1, c_t1):

        i_t = T.nnet.sigmoid(x_t.dot(self.Wxi) + h_t1.dot(self.Whi) + c_t1.dot(self.Wci) + self.bi)

        f_t = T.nnet.sigmoid(x_t.dot(self.Wxf) + h_t1.dot(self.Whf) + c_t1.dot(self.Wcf) + self.bf)

        c_t = f_t * c_t1 + i_t * T.tanh(x_t.dot(self.Wxc) + h_t1.dot(self.Whc) + self.bc)

        o_t = T.nnet.sigmoid(x_t.dot(self.Wxo) + h_t1.dot(self.Who) + c_t.dot(self.Wco) + self.bo)

        h_t = o_t * T.tanh(c_t)

        return h_t, c_t

Обратите внимание, что мы постоянно пользуемся гиперболическим тангенсом в качестве функции активации. Это потому, что все виденные мною примеры используют именно его; мне никогда не встречалось использование других нелинейных функций. Поэтому мы тоже будем придерживаться гиперболического тангенса. Но вы можете попробовать и другие функции активации.

Ну, и теперь определим функцию output.

    def output(self, x):

        [h, c], _ = theano.scan(

            fn=self.recurrence,

            sequences=x,

            outputs_info=[self.h0, self.c0],

            n_steps=x.shape[0],

        )

return h

Не забывайте, что мы тут не экспериментируем. Мы просто создаём класс, чтобы воспользоваться им позже.

Понравилась статья? Поделить с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: