СТАТЬЯ

Классификация предложений в НЛП

Из книги Масато Хагивары Обработка естественного языка в реальном мире

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

__________________________________________________________________

Получите скидку 37 % на Обработку естественного языка в реальном мире, введя fcchagiwara в поле кода скидки при оформлении заказа на сайте manning.com.
________________________________________________________________________________

Спам-фильтр — это приложение для классификации предложений, которое получает сообщение электронной почты и определяет, является ли оно спамом или нет. Если вы хотите классифицировать новостные статьи по разным темам (бизнес, политика, спорт и т. д.), это также задача классификации предложений. Классификация предложений — одна из самых простых задач НЛП, которая имеет широкий спектр приложений, включая классификацию документов, фильтрацию спама и анализ настроений. В частности, мы рассмотрим классификатор настроений и подробно обсудим его компоненты.

Рекуррентные нейронные сети (RNN)

Первым шагом в классификации предложений является представление предложений переменной длины с использованием нейронных сетей. В этом разделе я собираюсь представить концепцию рекуррентных нейронных сетей (RNN), одну из самых важных концепций глубокого НЛП. Многие современные модели НЛП так или иначе используют RNN. Я объясню, почему они важны, что они делают, и представлю их простейший вариант.

Обработка ввода переменной длины

Структура сети Skip-gram проста. Он берет вектор слов фиксированного размера, пропускает его через линейный слой и получает распределение баллов по всем контекстным словам. Структура и размер ввода, вывода и сети фиксируются на протяжении всего обучения.

Многие, если не большинство из того, с чем мы имеем дело в НЛП, представляют собой последовательности переменной длины. Например, слова, представляющие собой последовательности символов, могут быть короткими («а», «в») или длинными («интернационализация»). Предложения (последовательности слов) и документы (последовательности предложений) могут быть любой длины. Даже иероглифы, если смотреть на них как на последовательности штрихов, могут быть простыми («О» и «Л» в английском языке) или более сложными (например, «鬱» — китайский иероглиф, означающий «депрессия», который, к сожалению, имеет двадцать девять ударов).

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

Одна из идей состоит в том, чтобы сначала преобразовать входные данные (например, последовательность слов) во вложения, которые представляют собой последовательность векторов чисел с плавающей запятой, а затем усреднить их. Предположим, входное предложение — sentence = ["john", "loves", "mary", "."], и вы уже знаете вложения слов для каждого слова в предложении v("john"), v("loves") и т. д. Среднее значение можно получить следующим образом:

result = (v("john") + v("loves") + v("mary") + v(".")) / 4

Этот метод довольно прост и используется во многих приложениях НЛП, но у него есть одна важная проблема: он не может учитывать порядок слов. Поскольку порядок входных элементов не влияет на результат усреднения, вы получите один и тот же вектор как для «Мэри любит Джона», так и для «Джон любит Мэри». Хотя это соответствует поставленной задаче, трудно представить, чтобы многие приложения НЛП хотели такого поведения.

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

Можем ли мы разработать структуру нейронной сети, которая имитирует это постепенное считывание ввода? Ответ – твердое да. Эта структура называется рекуррентными нейронными сетями (RNN), которую я подробно объясню ниже.

Абстракция RNN

Если разбить упомянутый выше процесс чтения, то его ядром является повторение следующего ряда операций:

  1. Прочитать слово
  2. Основываясь на том, что было прочитано до сих пор (ваше «психическое состояние»), выясните, что означает это слово.
  3. Обновите психическое состояние
  4. Перейти к следующему слову

Давайте посмотрим, как это работает на конкретном примере. Если входное предложение sentence = ["john", "loves", "mary", "."] и каждое слово уже представлено как вектор встраивания слова. Кроме того, давайте обозначим ваше «психическое состояние» как state, которое инициализируется init_state(). Тогда процесс чтения представлен следующими инкрементными операциями:

state = init_state()
 state = update(state, v("john"))
 state = update(state, v("loves"))
 state = update(state, v("mary"))
 state = update(state, v("."))

Окончательное значение state становится представлением всего предложения из этого процесса. Обратите внимание, что если вы измените порядок, в котором обрабатываются эти слова (например, поменяв местами «Джон» и «Мэри»), конечное значение состояния также изменится, а это означает, что состояние также кодирует некоторую информацию о порядке слов.

Вы можете добиться чего-то подобного, если спроектируете сетевую подструктуру, которая применяется к каждому элементу ввода, поскольку он обновляет некоторые внутренние состояния. RNN — это структуры нейронных сетей, которые делают именно это. В двух словах, RNN — это нейронная сеть с петлей. По своей сути это операция, которая применяется к каждому элементу входных данных по мере их поступления. Если бы вы написали, что делают RNN на псевдо-Python, это было бы примерно так:

def rnn(words):
     state = init_state()
     for word in words:
         state = update(state, word)
     return state

Обратите внимание, что есть state, который инициализируется первым и передается во время итерации. Для каждого входа word state обновляется на основе предыдущего состояния и ввода с использованием функции update. Подструктура сети, соответствующая этому шагу (кодовый блок внутри цикла), называется ячейкой. Это останавливается, когда вход исчерпан, и окончательное значение state становится результатом этого RNN. См. иллюстрацию на рис. 2.

Теперь вы видите здесь параллелизм. Когда вы читаете предложение (последовательность слов), ваше внутреннее мысленное представление предложения, state, обновляется после прочтения каждого слова. Можно предположить, что конечное состояние кодирует представление всего предложения.

Осталось только разработать две функции — init_state() и update(). Состояние обычно инициализируется нулем (вектор, заполненный нулями), и вам обычно не нужно беспокоиться о том, как определить первое. Более важным вопросом является то, как вы проектируете update(), который определяет характеристики RNN.

Простой RNN и нелинейность

Здесь мы собираемся реализовать update(), функцию, которая принимает две входные переменные и создает одну выходную переменную? В конце концов, клетка — это нейронная сеть со своими входами и выходами, верно? Ответ положительный, и это будет выглядеть так:

def update_simple(state, word):
     return f(w1 * state + w2 * word + b)

Обратите внимание, что это поразительно похоже на функцию linear2() в разделе 3.4.3. На самом деле, если не обращать внимания на разницу в именах переменных, получается то же самое, за исключением функции f(). RNN, определяемая этим типом функции обновления, называется простой RNN или Elman RNN, которая, как следует из ее названия, является одной из самых простых структур RNN.

Тогда вам может быть интересно, что здесь делает эта функция f()? На что это похоже? Оно нам здесь вообще нужно? Функция, называемая функцией активации или нелинейностью, принимает один вход (или вектор) и преобразует его (или каждый элемент вектора) нелинейным образом. Многие виды нелинейностей играют незаменимую роль в том, чтобы сделать нейронные сети по-настоящему мощными. Для понимания того, что именно они делают и почему они важны, требуется некоторая математика, которая выходит за рамки этой статьи, но ниже я попытаюсь дать интуитивное объяснение на простом примере.

Представьте, что вы создаете RNN, которая распознает «грамматические» английские предложения. Отличие грамматических предложений от неграмматических — сложная задача НЛП, которая является хорошо зарекомендовавшим себя исследованием, но давайте упростим ее и рассмотрим только соответствие между подлежащим и глаголом. Давайте еще больше упростим его и предположим, что в этом «языке» всего четыре слова — «я», «ты», «есть» и «есть». Если предложение состоит из «я есть» или «ты есть», оно грамматическое. Две другие комбинации «я есть» и «ты есть» неверны. То, что вы хотите построить, — это RNN, которая выводит 1 для этих правильных предложений, поскольку она выдает 0 для этих неправильных. Как бы вы подошли к созданию такой нейронной сети?

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

Теперь давайте представим, что функции активации не было. Функция update_simple() выше упрощается до:

def update_simple_linear(state, word):
     return w1 * state + w2 * word + b

Мы предполагаем, что начальное значение состояния равно [0, 0], потому что конкретные начальные значения не имеют отношения к обсуждению здесь. RNN берет первое вложение слова, x1, обновляет состояние, берет второе вложение слова, x2, затем создает окончательный state, который является двумерным вектором. Наконец, два элемента этого вектора суммируются и преобразуются в result. Если result близко к 1, предложение грамматическое. В противном случае это не так. Если вы дважды примените функцию update_simple_linear() и немного упростите ее, вы получите следующую функцию, в конце концов, это все, что делает эта RNN:

w1 * w2 * x1 + w2 * x2 + w1 * b + b

Помните, что w1, w2 и b — это параметры модели (также известные как «магические константы»), которые необходимо обучать (настраивать). Здесь вместо того, чтобы настраивать эти параметры с помощью обучающего набора данных, давайте назначим некоторые произвольные значения и посмотрим, что произойдет. Например, когда w1 = [1, 0], w2 = [0, 1] и b = [0, 0] вход и выход этого RNN показаны на рисунке 4.

Если вы посмотрите на значения результата, эта РНС группирует неграмматические предложения (например, «я») с грамматическими (например, «вы»), что не является желаемым поведением. Как насчет того, чтобы попробовать другой набор значений параметров? Давайте воспользуемся w1 = [1, 0], w2 = [-1, 0] и b = [0, 0] и посмотрим, что получится (рис. 5).

Это намного лучше, потому что RNN успешно группирует неграмматические предложения, присваивая 0 как «я», так и «ты». Он также присваивает совершенно противоположные значения (2 и -2) грамматическим предложениям («я есть» и «ты есть»).

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

Давайте отступим и подумаем, почему это так. Если вы посмотрите на функцию обновления выше, все, что она делает, это умножает ввод на некоторое значение и складывает их. Говоря более конкретно, он только преобразует входные данные линейным образом. Результат этой нейронной сети всегда изменяется на некоторую постоянную величину, когда вы изменяете значение ввода на некоторую величину. Но это, очевидно, нежелательно — вы хотите, чтобы результат был равен 1, только когда входные переменные — это какие-то определенные значения. Вы не хотите, чтобы эта RNN была линейной, вы хотите, чтобы она была нелинейной.

Если использовать аналогию, это как если бы вы могли использовать только присваивание («=»), сложение («+») и умножение («*») в вашем языке программирования. Вы можете до некоторой степени изменить входные значения, чтобы получить результат, но вы не можете написать более сложную логику в таких ограниченных условиях.

Теперь давайте вернем функцию активации f() и посмотрим, что произойдет. Конкретная функция активации, которую мы будем использовать, называется функцией гиперболического тангенса или, чаще, tanh, которая является одной из наиболее часто используемых функций активации в нейронных сетях. Детали этой функции не важны в данном обсуждении, но в двух словах она ведет себя следующим образом: tanh мало что делает со входом, когда он близок к нулю, например, 0,3 или -0,2. Вход проходит через функцию почти без изменений. Когда вход далек от нуля, tanh пытается сжать его между -1 и 1. Например, когда вход большой (скажем, 10,0), выход становится близким к 1,0, хотя он мал (скажем, -10,0), выход становится почти -1.0. Это создает эффект, аналогичный логическому элементу ИЛИ (или элементу И, в зависимости от весов), если в функцию активации подаются две или более переменных. Выход вентиля становится включенным (~1) и выключенным (~-1) в зависимости от входа.

Когда используются w1 = [-1, 2], w2 = [-1, 2], b = [0, 1] и функция активации tanh, результат RNN становится намного ближе к желаемому (см. рис. 6). Если вы округлите их до ближайших целых чисел, RNN успешно сгруппирует предложения по их грамматике.

Используя ту же аналогию, использование функций активации в ваших нейронных сетях похоже на использование И, ИЛИ и ЕСЛИ в вашем языке программирования в дополнение к базовым математическим операциям, таким как сложения и умножения. Таким образом, вы можете писать сложную логику и моделировать сложные взаимодействия между входными переменными, как в этом примере в этом разделе.

ПРИМЕЧАНИЕ.Пример, используемый в этом разделе, представляет собой слегка измененную версию популярного примера «исключающее ИЛИ» (или «исключающее ИЛИ»), который обычно встречается в учебниках по глубокому обучению. Это самый простой и простой пример, который может быть решен нейронными сетями, но не другими линейными моделями.

Несколько заключительных замечаний по RNN — они обучаются, как и любые другие нейронные сети. Окончательный результат сравнивается с желаемым результатом с помощью функции потерь, затем разница между ними, потеря, используется для обновления «магических констант». В данном случае магическими константами являются w1, w2 и b в функции update_simple(). Обратите внимание, что функция обновления и ее магические константы идентичны на всех временных шагах в цикле. Это означает, что то, что изучают RNN, является общей формой обновлений, которые можно применять в любой ситуации.

Это все на данный момент.

Если вы хотите узнать больше о книге, ознакомьтесь с ней в нашей браузерной читалке liveBook здесь и посмотрите эту колоду слайдов.