Рекурсивная нейронная сеть в Theano и TensorFlow с рекурсией

Рекурсивная нейронная сеть в Theano

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

В этой статье мы реализуем нейронную сеть деревьев в Theano и TensorFlow для решения задачи анализа тональности текста.

Итак, начинаем с импортов. Кстати, функция get_ptb_data была вашим домашним заданием и вы должны были написать её самостоятельно.

import sys

import numpy as np

import matplotlib.pyplot as plt

import theano

import theano.tensor as T

from sklearn.utils import shuffle

from util import init_weight, get_ptb_data, display_tree

from datetime import datetime

Создаём класс для нашей сети.

class RecursiveNN:

    def __init__(self, V, D, K):

        self.V = V

        self.D = D

        self.K = K

Определим функцию fit. Тут нет как таковых X и Y – они содержатся в trees, так что входом у нас является trees. И не стоит особо волноваться насчёт текущих значений коэффициента обучения, импульса и регуляризации – позже мы их поменяем. Впрочем, вы можете поиграть с этими значениями сами. Кроме того, у нас будет флаг train_inner_nodes. Он предназначен для обучения на всех метках каждого угла или же для обучения только на корневом узле.

    def fit(self, trees, learning_rate=3*10-4, mu=0.99, reg=10e-5, epochs=15, activation=T.nnet.relu, train_inner_nodes=False):

        D = self.D

        V = self.V

        K = self.K

        self.f = activation

        N = len(trees)

        We = init_weight(V, D)

        Wh = np.random.randn(2, D, D) / np.sqrt(2 + D + D)

        bh = np.zeros(D)

        Wo = init_weight(D, K)

        bo = np.zeros(K)

Следующее – создаём переменные Theano для всего вышенаписанного.

        self.We = theano.shared(We)

        self.Wh = theano.shared(Wh)

        self.bh = theano.shared(bh)

        self.Wo = theano.shared(Wo)

        self.bo = theano.shared(bo)

        self.params = [self.We, self.Wh, self.bh, self.Wo, self.bo]

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

        words = T.ivector(‘words’)

        parents = T.ivector(‘parents’)

        relations = T.ivector(‘relations’)

        labels = T.ivector(‘labels’)

Следующее – функция recurrence. Её аргументы n – значение текущей последовательности, hiddens, являющимся выходом, и words, parents и relations, последовательностями не являющимися. В ней используем функцию switch.

        def recurrence(n, hiddens, words, parents, relations):

            w = words[n]

            hiddens = T.switch(

                T.ge(w, 0),

                T.set_subtensor(hiddens[n], self.We[w]),

                T.set_subtensor(hiddens[n], self.f(hiddens[n] + self.bh))

            )

            r = relations[n]

            p = parents[n]

            hiddens = T.switch(

                T.ge(p, 0),

                T.set_subtensor(hiddens[p], hiddens[p] + hiddens[n].dot(self.Wh[r])),

                hiddens

            )

            return hiddens

        hiddens = T.zeros((words.shape[0], D))

        h, _ = theano.scan(

            fn=recurrence,

            outputs_info=[hiddens],

            n_steps=words.shape[0],

            sequences=T.arange(words.shape[0]),

            non_sequences=[words, parents, relations],

        )

Переходим к софтмакс h(-1). Убедитесь, что это именно то, что нужно. Если хотите, можете также попробовать отладить этот код и посмотреть форму h. Это будет хорошим упражнением для понимания работы функции scan.

        py_x = T.nnet.softmax(h[-1].dot(self.Wo) + self.bo)

        prediction = T.argmax(py_x, axis=1)

        rcost = reg*T.mean([(p*p).sum() for p in self.params])

        if train_inner_nodes:

            cost = -T.mean(T.log(py_x[T.arange(labels.shape[0]), labels])) + rcost

        else:

            cost = -T.mean(T.log(py_x[-1, labels[-1]])) + rcost

Переходим к градиенту.

        grads = T.grad(cost, self.params)

        dparams = [theano.shared(p.get_value()*0) for p in self.params]

Теперь обновления. Всё как обычно.

        updates = [

            (p, p + mu*dp – learning_rate*g) for p, dp, g in zip(self.params, dparams, grads)

        ] + [

            (dp, mu*dp – learning_rate*g) for dp, g in zip(dparams, grads)

        ]

        self.cost_predict_op = theano.function(

            inputs=[words, parents, relations, labels],

            outputs=[cost, prediction],

            allow_input_downcast=True,

        )

        self.train_op = theano.function(

            inputs=[words, parents, relations, labels],

            outputs=[h, cost, prediction],

            updates=updates

        )

И наконец переходим к главному обучающему циклу.

        costs = []

        sequence_indexes = range(N)

        if train_inner_nodes:

            n_total = sum(len(words) for words, _, _, _ in trees)

        else:

            n_total = N

        for i in xrange(epochs):

            t0 = datetime.now()

            sequence_indexes = shuffle(sequence_indexes)

            n_correct = 0

            cost = 0

            it = 0

            for j in sequence_indexes:

                words, par, rel, lab = trees[j]

                c, p = self.train_op(words, par, rel, lab)

                cost += c

                if train_inner_nodes:

                    n_correct += np.sum(p == lab)

                else:

                    n_correct += (p[-1] == lab[-1])

                it += 1

                if it % 1 == 0:

                    sys.stdout.write(“j/N: %d/%d correct rate so far: %f, cost so far: %f\r” % (it, N, float(n_correct)/n_total, cost))

                    sys.stdout.flush()

            print “i:”, i, “cost:”, cost, “correct rate:”, (float(n_correct)/n_total), “time for epoch:”, (datetime.now() – t0)

            costs.append(cost)

        plt.plot(costs)

        plt.show()

Теперь мы готовы написать функцию score.

    def score(self, trees):

        n_total = len(trees)

        n_correct = 0

        for words, par, rel, lab in trees:

            _, p = self.cost_predict_op(words, par, rel, lab)

            n_correct += (p[-1] == lab[-1])

        return float(n_correct) / n_total

Теперь нам нужно написать несколько функций для обработки данных. Каждому дереву нужен индекс, и функция add_idx_to_tree устанавливает соответствие между деревом и соответствующим индексом в списке. Это рекурсия. Не забывайте, что мы пользуемся прохождением в обратном порядке – сначала tree.left, потом tree.right, потом current_idx

def add_idx_to_tree(tree, current_idx):

    if tree is None:

        return current_idx

    current_idx = add_idx_to_tree(tree.left, current_idx)

    current_idx = add_idx_to_tree(tree.right, current_idx)

    tree.idx = current_idx

    current_idx += 1

    return current_idx

Далее идёт функция tree2list. Она возвращает четыре списка.

def tree2list(tree, parent_idx, is_binary=False, is_left=False, is_right=False):

    if tree is None:

        return [], [], [], []

    w = tree.word if tree.word is not None else -1

    if is_left:

        r = 0

    elif is_right:

        r = 1

    else:

        r = -1

    words_left, parents_left, relations_left, labels_left = tree2list(tree.left, tree.idx, is_binary, is_left=True)

    words_right, parents_right, relations_right, labels_right = tree2list(tree.right, tree.idx, is_binary, is_right=True)

    words = words_left + words_right + [w]

    parents = parents_left + parents_right + [parent_idx]

    relations = relations_left + relations_right + [r]

    if is_binary:

        if tree.label > 2:

            label = 1

        elif tree.label < 2:

            label = 0

        else:

            label = -1

    else:

        label = tree.label

    labels = labels_left + labels_right + [label]

    return words, parents, relations, labels

И переходим к функции main.

def main(is_binary=True):

    train, test, word2idx = get_ptb_data()

    for t in train:

        add_idx_to_tree(t, 0)

    train = [tree2list(t, -1, is_binary) for t in train]

    if is_binary:

        train = [t for t in train if t[3][-1] >= 0] # for filtering binary labels

    for t in test:

        add_idx_to_tree(t, 0)

    test = [tree2list(t, -1, is_binary) for t in test]

    if is_binary:

        test = [t for t in test if t[3][-1] >= 0]

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

    train = shuffle(train)

    train = train[:2000]

    test = shuffle(test)

    test = test[:100]

    V = len(word2idx)

    print “vocab size:”, V

    D = 10

    K = 2 if is_binary else 5

    model = RecursiveNN(V, D, K)

    model.fit(train, learning_rate=10e-3, reg=10e-3, mu=0, epochs=30, activation=T.tanh, train_inner_nodes=False)

    print “train accuracy:”, model.score(train)

    print “test accuracy:”, model.score(test)

if __name__ == ‘__main__’:

main()

Можем запускать.

Рекурсивные тензорные нейронные сети

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

Итак, вы уже знаете, как строится рекурсивная нейронная сеть. Теперь же давайте рассмотрим альтернативный взгляд на нейронные сети деревьев, поскольку именно так они были представлены в исходной работе, в которой впервые была опубликована эта модель.

Чтобы получить значение внутреннего узла, мы объединяем значения от левого и правого потомков. Обозначим это через x, тогда значение в этом узле равно

h = f(W^T x + b).

Обратите внимание, что для этого W должно быть размерности 2DxD в связи с тем, что все внутренние значения узлов имеют размер D, а после объединения x – размер 2D. При этом количество параметров остаётся прежним, поскольку и перед этим у нас было W_левое и W_правое размерности DxD каждое.

Итак, каков же естественный способ расширения этой модели? Мы можем добавить квадратичный член Aj:

Тут показан лишь один компонент h. Написано так, будто каждый член является скалярной величиной, поскольку сложно представить тензорное умножение в терминах матриц. Чтобы умножение на квадратичный член было корректным, Aj должно быть матрицей размерности 2Dx2D, поскольку X имеет размерность 2D. А поскольку существует D компонент h, (ведь j пробегает значения от 1 до D), то это значит, что A имеет полную размерность Dx2Dx2D.

Функциональная форма должна напоминать вам кое-что из уже пройденного. В частности, в курсе «Предпосылки к глубокому обучению: логистическая регрессия» мы рассматривали решение для логистической регрессии в аналитическом виде, которое удавалось найти, используя теорему Байеса, если оба класса имели гауссово распределение. Напомню, что тогда оба гауссовых распределения имели одинаковую ковариацию, а разделяющая гиперповерхность была линейной. Это ещё называется линейным дискриминантным анализом. Если бы у них была разная ковариация, то гиперповерхность была бы квадратичной, поскольку в этом случае члены ковариации не сокращаются. Это называется квадратичным дискриминантным анализом.

Есть и другой способ рассмотрения. Разделим X на отдельные составляющие. Предположим, D = 2. Тогда у нас будут х_левое_1, х_левое_2, х_правое _1, х_правое _2. В результате мы получаем члены парам*х_левое_1*х_правое_1 и парам*х_левое_1*х_левое_2. С таким мы встречались в моём курсе по линейной регрессии – это называется членами взаимодействия.

Таким образом, это весьма простое дополнение модели. Как же его реализовать? Вместо того чтобы объединить значения левого и правого потомков, мы оставим их раздельно и используем эквивалентную формулировку. Надеюсь, это поможет вам представить суть происходящего.

Итак, вернёмся к обычной рекурсивной нейронной сети:

Тут показан лишь один компонент h. Написано так, будто каждый член является скалярной величиной, поскольку сложно представить тензорное умножение в терминах матриц. Чтобы умножение на квадратичный член было корректным, Aj должно быть матрицей размерности 2Dx2D, поскольку X имеет размерность 2D. А поскольку существует D компонент h, (ведь j пробегает значения от 1 до D), то это значит, что A имеет полную размерность Dx2Dx2D.

Функциональная форма должна напоминать вам кое-что из уже пройденного. В частности, в курсе «Предпосылки к глубокому обучению: логистическая регрессия» мы рассматривали решение для логистической регрессии в аналитическом виде, которое удавалось найти, используя теорему Байеса, если оба класса имели гауссово распределение. Напомню, что тогда оба гауссовых распределения имели одинаковую ковариацию, а разделяющая гиперповерхность была линейной. Это ещё называется линейным дискриминантным анализом. Если бы у них была разная ковариация, то гиперповерхность была бы квадратичной, поскольку в этом случае члены ковариации не сокращаются. Это называется квадратичным дискриминантным анализом.

Есть и другой способ рассмотрения. Разделим X на отдельные составляющие. Предположим, D = 2. Тогда у нас будут х_левое_1, х_левое_2, х_правое _1, х_правое _2. В результате мы получаем члены парам*х_левое_1*х_правое_1 и парам*х_левое_1*х_левое_2. С таким мы встречались в моём курсе по линейной регрессии – это называется членами взаимодействия.

Таким образом, это весьма простое дополнение модели. Как же его реализовать? Вместо того чтобы объединить значения левого и правого потомков, мы оставим их раздельно и используем эквивалентную формулировку. Надеюсь, это поможет вам представить суть происходящего.

Итак, вернёмся к обычной рекурсивной нейронной сети:

Мы можем расширить её, добавив три квадратичных члена:

Разумеется, каждое А должно быть размерности DxDxD.

Сколько всего получается весовых коэффициентов? DxDxD*3 = 3D3. Заметьте, что это отличается от первоначальной формулировке, когда их получалось 4D3. У вас может возникнуть вопрос: а не пропустили ли мы тогда каких-то ещё членов взаимодействия? Я рекомендую попробовать оба случая и сравнить результаты.

Теперь поговорим о том, как всё это сделать в Theano. Понадобятся лишь небольшие изменения в простой рекурсивной нейронной сети, так что нам нет необходимости опять писать весь код. Единственное место, где есть отличия, – это где мы представляем деревья как набор списков. В частности, мы убираем понятия отношений и родителей. Вместо этого у нас будут три списка: слова (words), левые потомки (left_children) и правые потомки (right_children). Как следует из названия, список слов будет содержать слова-индексы, если это слова, и, как обычно, -1 в противном случае. Список левых потомков будет содержать индекс левого потомка, если таковой имеется у узла; то же самое касается списка правых потомков. Обратите внимание, что узлы, имеющие левых потомков, будут иметь и правых, поскольку каждый узел либо не имеет потомков, либо их двое.

Зачем это нужно? Напомню, что в нашей предыдущей формулировке, когда мы вычисляли значение текущего узла, мы передавали его значение родительскому. Но теперь нам нужно не только умножить текущий узел на весовой коэффициент, но и умножить на «братский» узел, к которому у нас пока нет доступа. Использование списков левых и правых потомков делает эту задачу легче.

Пройдёмся по основным изменениям в коде. Разумеется, нам придётся инициировать больше весовых коэффициентов, в частности W11, W12 и W22:

        We = init_weight(V, D)

        W11 = np.random.randn(D, D, D) / np.sqrt(3*D)

        W22 = np.random.randn(D, D, D) / np.sqrt(3*D)

        W12 = np.random.randn(D, D, D) / np.sqrt(3*D)

        W1 = init_weight(D, D)

        W2 = init_weight(D, D)

        bh = np.zeros(D)

        Wo = init_weight(D, K)

        bo = np.zeros(K)

Перед тем, как приступить к описанию входных данных в Theano мы инициируем списки для слов, левых и правых потомков, а также меток:

        words = T.ivector(‘words’)

        left_children = T.ivector(‘left_children’)

        right_children = T.ivector(‘right_children’)

        labels = T.ivector(‘labels’)

Функция recurrence теперь определяется в терминах описанной мной выше формулировки: если это слово – возвращается слово-вектор. Если нет – то вычисляется значение узла по их детям.

        def recurrence(n, hiddens, words, left, right):

            w = words[n]

            # any non-word will have index -1

            hiddens = T.switch(

                T.ge(w, 0),

                T.set_subtensor(hiddens[n], self.We[w]),

                T.set_subtensor(hiddens[n],

                    self.f(

                        hiddens[left[n]].dot(self.W11).dot(hiddens[left[n]]) +

                        hiddens[right[n]].dot(self.W22).dot(hiddens[right[n]]) +

                        hiddens[left[n]].dot(self.W12).dot(hiddens[right[n]]) +

                        hiddens[left[n]].dot(self.W1) +

                        hiddens[right[n]].dot(self.W2) +

                        self.bh

                    )

                )

            )

            return hiddens

Полный список изменений вы можете найти в репозитарии в файле rntn_theano.py.

А теперь посмотрим на результаты.

Рекурсивная нейронная сеть в TensorFlow с рекурсией

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

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

Итак, первое, что мы видим, – функцию get_labels. Как обычно, тут используется прохождение в обратном порядке.

def get_labels(tree):

    # must be returned in the same order as tree logits are returned

    # post-order traversal

    if tree is None:

        return []

    return get_labels(tree.left) + get_labels(tree.right) + [tree.label]

Далее идёт класс RNTN для рекурсивной тензорной нейронной сети. Вначале мы инициируем весовые коэффициенты и преобразуем их в переменные TensorFlow.

Далее идёт функция fit. Тут действительно появляются отличия. У нас есть списки train_ops, costs, predictions, all_labels. Почему так?Потому что, напомню, всё идёт отдельно для каждого дерева. Поэтому мы проходим по всем из них и заполняем эти списки.

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

        self.predictions = predictions

        self.all_labels = all_labels

        self.saver = tf.train.Saver()

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

После этого мы сохраняем модель и выводим значение функции затрат.

Далее у нас идёт функция get_cost. Напомню, библиотека TensorFlow не вычисляет напрямую функцию затрат на выходе софтмакса; она работает с помощью логитов, идущих перед софтмакс. Это связано с некоторыми приёмами оптимизации, которых нет в прямой формулировке. Напомню, что в «Глубоком обучении, часть 2» мы использовали матрицу индикаторов целевых переменных для функции затрат. С тех пор разработчики библиотеки TensorFlow внедрили новую функцию sparse_softmax_cross_entropy_with_logits, которая напрямую может воздействовать на метки. Это, кстати, показывает, как быстро всё меняется в данной сфере знаний.

Далее – функции get_output и get_output_recursive. Они возвращают логиты для каждого узла в дереве; их нужно поместить в список, причём поместить в правильном порядке. Это значит, что мы просто должны использовать прохождение в обратном порядке. Обратите внимание, что именно тут имеет место рекурсия.

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

Это практически всё, что касается данного файла. Если вы хотите по-настоящему увидеть разницу, запустите его и файл с рекуррентной версией с одинаковым количеством примеров и сравните, сколько времени понадобится для каждого из них.

Спасибо, что потратили свое время на изучение этого курса! До встречи на следующих темах, которые будут не менее интересные!

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

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