Рекурсивные нейронные сети. Нейронные сети деревьев

Описание данных для рекурсивной нейронной сети

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

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

До сих пор мы были знакомы с двумя способами представления предложений: «мешок слов» и последовательности. Но есть третий и самый мощный способ – рассматривать предложения как деревья.

Предложения в своей структуре имеют зависимости. Например, рассмотрим очень простое предложение, в котором всего одно существительное и глагол – «Джонни идёт». «Джонни» – существительное, «идёт» – глагол. Мы можем составить и более длинное предложение с аналогичной структурой, которая имеет именное словосочетание и глагольное словосочетание, например «Большой Джон быстро идёт». Тут «Большой Джон» – именное словосочетание, а «быстро идёт» – глагольное. Затем сочетание «большой Джон» также можно разделить на составные части, как и «быстро идёт». Теперь у нас есть дерево.

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

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

Когда мы занимались рекуррентными нейронными сетями, мы решали задачу прогнозирования следующего слова. Мы смогли построить языковую модель максимизации p(x(t)|x(t-1),…,x(0)). Теперь у нас деревья. Существует ли здесь понятие «следующего»? Нет, поскольку деревья – это не последовательности. Они имеют иерархическую структуру. Вместо этого мы сделаем анализ тональности текста. Напомню, что мы его делали в вводном курсе по обработке естественных языков, используя «мешок слов». Напомню также, что недостатком такого подхода является то обстоятельство, что он не может справиться с отрицанием: имея слово, записанное методом прямого кодирования, и добавляя к входному вектору «не» или «ни», мы получим вектор, очень близкий с исходному. Поэтому любой модели машинного обучения, использующей «мешок слов», очень трудно обрабатывать отрицания.

Интересно, что рекурсивные нейронные сети наконец-то решили эту проблему. Исследования по анализу тональности текста давали в лучшем случае около 80% точности, пока не появились рекурсивные сети. Теперь мы можем достичь около 85-90% точности.

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

Что из этого следует? Из этого следует, что нам нужно иметь метку для каждого узла дерева. Поэтому вернёмся к дереву предложения, которое обсуждалось чуть ранее. На данный момент мы знаем, что это двоичное дерево и каждый узел имеет либо 0, либо 2 потомков. Добавим ещё к каждому узлу метку, чтобы уметь «понимать» отрицание. И ещё один факт, который вы должны знать и который вы легко поймёте, просто взглянув на данные, – только «листья» дерева представляют слова; любой же внутренний узел представляет фразу, составленную из его слов-потомков.

Итак, теперь вы знаете всё о структуре деревьев. А каков вид наших данных? Прежде всего я покажу, где их взять. Вы должны перейти по адресу http://nlp.stanford.edu/sentiment и загрузить файл trainDevTestTrees_PTB.zip. Это просто ряд обзоров фильмов. У вас может возникнуть вопрос, как исследователи умудрились получить все эти размеченные данные. Ну, они используют сервис Amazon Mechanical Turk, где люди выполняют случайные задания за мелкое вознаграждение. Им дали очень короткие фразы вплоть до отдельного слова и попросили оценить их по шкале от 1 до 5, где 5 было лучшей оценкой, 1 – худшей, а 3 обозначало нейтральный характер данных. В наших данных это будут, конечно же, метки от 0 до 4.

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

(5(5 Отличный)(3 фильм))

Это и будет двоичное дерево единичной глубины, представляющее предложение «Отличный фильм».

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

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

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

Что такое рекурсивные нейронные сети. Нейронные сети деревьев

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

Иногда рекурсивные нейронные сети сокращённо называют РНН, но это весьма неудачное название, ведь в таком случае их очень легко спутать с рекуррентными нейронными сетями. Однако позже вы увидите, что такое сокращение может оказаться и подходящим. Я же буду периодически называть рекурсивные сети нейронными сетями деревьев, потому что они ими и являются. К тому же это даёт нам отдельную аббревиатуру для них – TNN (tree neural network).

Самый простой способ разобраться в них – это рассмотреть дерево разбора предложения. Поскольку для наших данных мы используем двоичные деревья, то их первыми и обсудим.

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

Вот как рассчитать значение скрытого узла h1 (см. слайд):

Теперь вычислим значение корня. Оно зависит от h1 слева и w3 справа:

Возникает следующий вопрос: какова размерность этих весовых коэффициентов? Не забывайте, что структура сети рекурсивна, так что потомками любого узла могут быть слова или другие узлы, но весовые коэффициенты должны соответствовать им обоим. Следовательно, все весовые коэффициенты должны иметь одинаковую размерность, а размерность входа должна соответствовать размерности выхода. Другими словами, если наше представление имеет размерность D, то все наши w должны иметь размерность DxD, а все свободные члены – размерность D.

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

Что дальше? При решении задачи анализа тональности текста у нас есть 5 классов (или 2 – в зависимости от того, как задача сформулирована). Как получить выход размерности 5 или 2? Не забывайте, что у каждого узла есть метка, а значит, есть возможность вычислить p(y|h) для любого h, будь то внутренний узел, корневой узел или отдельное слово. Для этого нам нужно умножить значение узла на исходящий весовой коэффициент, добавить исходящий свободный член и взять функцию софтмакс – всё как обычно:

Применяя софтмакс, у нас может быть любое количество классов, будь то 5 или 2 – обрабатываться всё будет одинаково.

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

Теперь, когда мы определили вид двоичной рекурсивной нейронной сети, что с рекурсивной сетью с любым количеством потомков? Вы можете видеть, что суммируя значения по каждому потому, мы естественным образом расширяем определение двоичных отношений. Может возникнуть вопрос, как мы сперва получим дерево, не являющееся двоичным? Инструментарий для обработки естественного языка Стэнфордского университета даёт возможность генерировать собственные деревья разбора для любого текста. Это выходит за рамки данного курса, но если желаете, можете его испробовать. Недостатком тут является то обстоятельство, что нет размеченного набора данных. Что любопытно, там нет инструментов, легко позволяющих использовать двоичные деревья. Вот почему очень удачно для нас, что они выполнили работу по сбору данных. Они, правда, написали код на Java для создания двоичных деревьев, но для его использования требуется знание языка Java. И там всё равно нет меток.

Как бы то ни было, количество возможных отношений «родитель-потомок» явно ограничено. Обозначим этот предел через R. Тогда будет R возможных различных отношений зависимости. В связи с этим мы можем создать большую матрицу весовых коэффициентов W, которая будет включать все R различных матриц DxD, умножить вход на соответствующий зависимости весовой коэффициент и получить исходящее значение:

где rel(h, i) возвращает число от 1 до R, указывающий на тип зависимости.

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

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

Построение нейронной сети деревьев с помощью рекурсии

Прежде чем приступить к реализации рекурсивной нейронной сети, важно обсудить, как именно её создавать, а не просто следовать решению. В действительности вы можете попробовать создать её самостоятельно, а не смотреть, как это буду делать я. Иначе у вас не случится «момента озарения», когда увидите решение. На самом деле вы не должны смотреть даже это видео, пока не попробуете сами создать рекурсивную нейронную сеть. Так что если вы ещё не попробовали – немедленно закройте это видео. Когда вернётесь – я по-прежнему буду здесь.

Итак, надеюсь, вы попробовали самостоятельно построить рекурсивную нейронную сеть, используя рекурсивные свойства деревьев для чистоты реализации. Позже я покажу, как построить рекуррентную нейронную сеть с применением рекурсии. Это будет в TensorFlow, поскольку это была моя исходная библиотека при её написании. Это не критично для данного курса и совершенно необязательно к просмотру, но я покажу недостатки реализации TNN очевидным способом. Дополнительный бонус написания в TensorFlow – невозможность обмана. Я собираюсь научить вас писать рекурсивную сеть в Theano, поэтому часть кода вам всё равно придётся писать с нуля.

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

def fit(self, X, Y, …):

    py_x = softmax( f(X.dot(W1) + b1).dot(W2) + b2 )

    cost = Y*log(py_x)

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

Начнём с простейших вещей. Обычно у нас есть функция forward, которая принимает Theano-вход и выдаёт Theano-выход:

def forward(self, X):

    Z = X.dot(W1) + b1

    return softmax(Z.dot(W2) + b2)

То, что получается на выходе, на самом деле не является массивом Numpy, а лишь узлом графа Theano, который должен ещё получить значение путём вызова функции Theano. Это даёт нам отправную точку. Предположим, в нашей рекурсивной нейронной сети есть функция forward, но в качестве аргумента принимающая дерево. Назовём её forward_hidden, поскольку она нужна только для просчёта значений узлов. Нас интересует только p(y|x) для корневого узла, что мы можем сделать и вне этой функции. Поскольку это рекурсия, функция forward_hidden должна вызывать саму себя. В частности, она должна вызывать себя для левого потомка, если дерево такового имеет, и вызывать себя для правого потомка, если он есть. В противном случае она должна давать векторное представление для слова, которое представляет данный узел.

def forward_hidden(self, tree):

    if tree.word:

        return We[tree.word]

    left = 0, right = 0

    if tree.left:

        left = self.forward_hidden(tree.left)

    if tree.right:

        right = self.forward_hidden(tree.right)

    return f(left.dot(Wleft) + right.dot(Wright) + b)

Теперь у нас есть всё необходимое для получения значения корневого узла. Мы просто вызываем функцию forward_hidden, она делает рекурсивные вызовы, и мы получаем значение корневого узла, вызывая forward_hidden в корневом узле. Затем мы пропускаем это через софтмакс и получаем p(y|x). Далее мы как обычно вычисляем функцию затрат, градиент, обновления и так далее.

root_node_value = self. forward_hidden(root)

py_x = softmax(root_node_value.dot(Wo) + bo)

cost = log(py_x[root.label])

grad = T.grad(cost, params)

updates = [(p, p – lr*g) for p, g in …]

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

Мораль этой истории в том, что хотя решение кажется очевидным, оно совсем не идеально.

От деревьев к последовательностям

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

Основная идея состоит в том, чтобы преобразовать нашу рекурсивную нейронную сеть в рекуррентную. Теперь, полагаю, становится понятно, почему этот курс называется «Глубокое обучение, часть 6» и идёт после «Глубокого обучения, часть 5», посвящённого рекуррентным нейронным сетям.

Подумаем, зачем нужны нейронные сети деревьев и зачем нужны рекуррентные нейронные сети. Нейронные сети деревьев – для деревьев, а рекуррентные нейронные сети – для последовательностей. Следовательно, чтобы решить задачу преобразования из нейронной сети деревьев в рекуррентные нейронные сети, нужно преобразовать наши деревья разбора в последовательности. Как же это сделать?

Вновь-таки, это связано с использованием алгоритмов. Если вы никогда не изучали курсов по алгоритмам, я настоятельно рекомендую взяться за них, поскольку это значительно улучшит ваши навыки в программировании. Один из простейших способов преобразования дерева в массив – это упорядочить элементы массива так, чтобы элементы-потомки всегда появлялись слева от родителей, а значение каждого элемента могло быть их родительским индексом. Это называется родительским массивом. При этом мы используем значение -1 для узлов, у которых нет родителей, а родителей нет только у корневого узла.

Итак, каков же алгоритм, позволяющий строить деревья таким способом? Мы знаем, что родитель должен идти после своих потомков. В терминах алгоритмов это называется прохождением в обратном порядке (post-order traversal). Это значит, что сначала мы посещаем левый узел, затем правый, после этого текущий – и так далее по рекурсии:

def post_order(tree):

    if tree is None: return

    post_order(tree.left)

    post_order(tree.right)

    print tree.value # или что-то ещё, что пожелаете, включая добавление в список

Что касается конкретно нашего набора данных, то нам нужно знать, как потомок связан со своим родителем. Это может быть отношение, определяемое деревом разбора, или простым «левое-правое», как в случае двоичного дерева. Как можно видеть, такая структура будет работать как для двоичных деревьев, так и для деревьев с любым количеством потомков. Обратите внимание, что мы вновь используем значение -1, если нет никаких отношений.

И наконец нам нужно знать, какое слово содержится в узле (если содержится). Следовательно, нам нужен ещё один список, того же размера, что и предыдущие. Этот список содержит слово-индекс, если это узел-лист, или -1, если нет.

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

Теперь, когда мы знаем, как преобразовать дерево в ряд списков, обсудим как это всё можно запустить через рекуррентную нейронную сеть. Это несколько более сложно, чем то, чем мы занимались в «Глубоком обучении, часть 5», поскольку там у нас был лишь один массив – массив слов-индексов. Теперь же у нас их три. Что с ними делать?

Суть в том, чтобы сохранять текущий список всех значений узлов дерева, а затем обновлять эти значения по мере прохождения по массивам. Мы можем последовательно проходить по каждому элементу каждого массива. Например, мы одновременно смотрим на родителей[0], отношения[0], слова [0]. Затем мы одновременно смотрим на родителей[1], отношения[1], слова[1]. Почему это работает? Потому что всё, что нужно для определения значения узла, зависит только от его потомков. А поскольку это потомки, они всегда находятся слева от родителей в массиве, а значит, мы можем быть уверены, что первыми рассчитали именно эти значения.

С точки зрения функции scan библиотеки Theano, мы просто запускаем цикл в диапазоне n, где n – количество узлов, а затем используем текущее значение последовательности для получения всей необходимой информации от списка родителей, списка отношений и списка слов. Эти списки мы можем определить как non_sequences (не-последовательности):

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

h, _ = theano.scan(

    fn=recurrence,

    sequence=range(n),

    outputs_info=[hiddens],

    non_sequences=[parents, relations, words].

)

А что нам делать с функцией recurrence? Две вещи. Первое – вычислить значение в текущем узле n. Если это слово – то это просто слово-вектор, соответствующий этому слову. Если же это не слово, то тогда это f от его текущего значения.

if words[n] >= 0:

    hiddens[n] = We[words[n]]

else:

    hiddens = T.set_subtensor(hiddens[n] + bh))

Обратите внимание на использование set_subtensor, который мы изучили, когда рассматривали word2vec. Скоро вы поймёте, почему hiddens[n] уже содержит весь нужный материал.

Это потому, что вторая часть – передать текущее значение своему родителю. Будь текущий узел словом или внутренним узлом – он передаёт свое значение родителю одним и тем же образом, а именно: собственное значение, умножение на Wh для соответствующего типа отношений и добавленное к текущему значению родителя. Если же узел является корневым, то родителя у него нет и делать ничего больше не надо.

p = parents[n]

r = relations[n]

if p >= 0:

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

Далее нам нужно немного усовершенствовать код. Вы можете спросить, поддерживает ли Theano условный оператор if. Нет, не поддерживает, поскольку для Theano требуется создать граф, который не может состоять из какого угодно кода Python. Но аналогично тому, как вместо оператора цикла мы используем функцию scan, так и вместо условного оператора if мы воспользуемся функций switch. Первый её аргумент – условие, второй – значение, которое возвращается, если условие истинно, а третий аргумент – значение, которое возвращается, если условие ложно.

new_value = T.switch(<условие>, <значение, если истинно>, <значение, есло ложно>)

hiddens = T.switch(

    T.ge(words[n], 0),

    We[words[n]],

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

)

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

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

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