Одна из основных задач диалоговых систем состоит не только в предоставлении нужной пользователю информации, но и в генерации как можно более человеческих ответов. А распознание эмоций собеседника — уже не просто крутая фича, это жизненная необходимость. В этой статье мы рассмотрим архитектуру рекуррентной нейросети для определения эмоций в текстовых беседах, которая принимала участие в SemEval-2019 Task 3 “EmoContext”, ежегодном соревновании по компьютерной лингвистике. Задача состояла в классификации эмоций (“happy”, “sad”, “angry” и “others”) в беседе из трех реплик, в которой участвовали чат-бот и человек.
В первой части статьи мы рассмотрим поставленную в EmoContext задачу и предоставленные организаторами данные. Во второй и третьей частях разберём предварительную обработку текста и способы векторного представления слов. В четвёртой части мы опишем архитектуру LSTM, которую мы использовали в соревновании. Код написан на языке Python с использованием библиотеки Keras.
1. Обучающие данные
Трек “EmoContext” на SemEval-2019 был посвящен определению эмоций в текстовых беседах с учетом контекста переписки. Контекст в данном случае — это несколько последовательных реплик участников диалога. В беседе два участника: анонимный пользователь (ему принадлежит первая и третья реплика) и чат-бот Ruuh (ему принадлежит вторая реплика). На основе трех реплик необходимо определить, какую эмоцию испытывал пользователь при написании ответа чат-боту (Таблица 1). Всего разметка датасета содержала четыре эмоции: «happy», «sad», «angry» или «others» (Таблица 1). Подробное описание представлено здесь: (Chatterjee et al., 2019).
Таблица 1. Примеры из датасета EmoContext (Chatterjee et al., 2019)
Пользователь (Этап-1) | Диалоговый робот (Этап-1) | Пользователь (Этап-2) | True Class |
---|---|---|---|
I just qualified for the Nabard internship | WOOT! That’s great news. Congratulations! | I started crying | Счастье |
How dare you to slap my child | If you spoil my car, I will do that to you too | Just try to do that once | Злость |
I was hurt by u more | You didn’t mean it. | say u love me | Грусть |
I will do night. | Alright. Keep me in loop. | Not giving WhatsApp no. | Другое |
В ходе состязания организаторы предоставили несколько наборов данных. Обучающий датасет (Train) состоял из 30 160 размеченных вручную текстов. В этих текстах было примерно по 5000 объектов, относящихся к классам «happy», «sad» и «angry», а также 15000 текстов из класса «others» (Таблица 2).
Также организаторы предоставили наборы данных для разработки (Dev) и тестирования (Test), в которых, в отличие от обучающего датасета, распределение по классам эмоций соответствовало реальной жизни: примерно по 4 % для каждого из классов «happy», «sad» и «angry», а остальное — класс «others». Данные предоставлены Microsoft, скачать их можно в официальной группе в LinkedIn.
Таблица 2. Распределение меток классов эмоций в датасете (Chatterjee et al., 2019).
Датасет | Счастье | Грусть | Злость | Другое | Итого |
---|---|---|---|---|---|
Учебный |
14,07 % |
18,11 % |
18,26 % |
49,56 % |
30 160 |
Для разработки |
5,15 % |
4,54 % |
5,45 % |
84,86 % |
2755 |
Тестовый |
5,16 % |
4,54 % |
5,41 % |
84,90 % |
5509 |
Дистанцированный |
33,33 % |
33,33 % |
33,33 % |
0 % |
900 тыс. |
В дополнение к этим данным мы собрали 900 тыс. англоязычных сообщений из Twitter, чтобы создать Distant-датасет (300 тыс. твитов на каждую эмоцию). При его создании мы придерживались стратегии Go et al. (2009), в рамках которой просто ассоциировали сообщения с наличием относящихся к эмоциям слов, таких как #angry, #annoyed, #happy, #sad, #surprised и так далее. Список терминов основан на терминах из SemEval-2018 AIT DISC (Duppada et al., 2018).
Главной метрикой качества в соревновании EmoContext является усредненная F1-мера для трёх классов эмоций, то есть для классов «happy», «sad» и «angry».
def preprocessData(dataFilePath, mode):
conversations = []
labels = []
with io.open(dataFilePath, encoding="utf8") as finput:
finput.readline()
for line in finput:
line = line.strip().split('\t')
for i in range(1, 4):
line[i] = tokenize(line[i])
if mode == "train":
labels.append(emotion2label[line[4]])
conv = line[1:4]
conversations.append(conv)
if mode == "train":
return np.array(conversations), np.array(labels)
else:
return np.array(conversations)
texts_train, labels_train = preprocessData('./starterkitdata/train.txt', mode="train")
texts_dev, labels_dev = preprocessData('./starterkitdata/dev.txt', mode="train")
texts_test, labels_test = preprocessData('./starterkitdata/test.txt', mode="train")
2. Предварительная обработка текста
Перед обучением мы предварительно обработали тексты с помощью инструмента Ekphrasis (Baziotis et al., 2017). Он помогает исправить орфографию, нормализовать слова, сегментировать, а также определить, какие токены следует отбросить, нормализовать или аннотировать с помощью специальных тегов. На этапе предварительной обработки мы сделали следующее:
- Адреса URL и почту, дату и время, ники, проценты, валюты и числа заменили соответствующими тегами.
- Повторяющиеся, цензурированные, удлинённые написанные прописными буквами термины мы сопроводили соответствующими метками.
- Удлинённые слова были автоматически скорректированы.
Кроме того, Emphasis содержит токенизатор, который может идентифицировать большинство эмодзи, эмотиконов и сложных выражений, а также даты, время, валюты и акронимы.
Таблица 3. Примеры предварительной обработки текста.
Исходный текст | Предварительно обработанный текст |
---|---|
I FEEL YOU… I'm breaking into million pieces | <allcaps> i feel you </allcaps>. <repeated> i am breaking into million pieces |
tired and I missed you too :‑( | tired and i missed you too <sad> |
you should liiiiiiisten to this: www.youtube.com/watch?v=99myH1orbs4 | you should listen <elongated> to this: <url> |
My apartment takes care of it. My rent is around $650. | my apartment takes care of it. my rent is around <money> . |
from ekphrasis.classes.preprocessor import TextPreProcessor
from ekphrasis.classes.tokenizer import SocialTokenizer
from ekphrasis.dicts.emoticons import emoticons
import numpy as np
import re
import io
label2emotion = {0: "others", 1: "happy", 2: "sad", 3: "angry"}
emotion2label = {"others": 0, "happy": 1, "sad": 2, "angry": 3}
emoticons_additional = {
'(^・^)': '<happy>', ':‑c': '<sad>', '=‑d': '<happy>', ":'‑)": '<happy>', ':‑d': '<laugh>',
':‑(': '<sad>', ';‑)': '<happy>', ':‑)': '<happy>', ':\\/': '<sad>', 'd=<': '<annoyed>',
':‑/': '<annoyed>', ';‑]': '<happy>', '(^�^)': '<happy>', 'angru': 'angry', "d‑':":
'<annoyed>', ":'‑(": '<sad>', ":‑[": '<annoyed>', '(�?�)': '<happy>', 'x‑d': '<laugh>',
}
text_processor = TextPreProcessor(
# terms that will be normalized
normalize=['url', 'email', 'percent', 'money', 'phone', 'user',
'time', 'url', 'date', 'number'],
# terms that will be annotated
annotate={"hashtag", "allcaps", "elongated", "repeated",
'emphasis', 'censored'},
fix_html=True, # fix HTML tokens
# corpus from which the word statistics are going to be used
# for word segmentation
segmenter="twitter",
# corpus from which the word statistics are going to be used
# for spell correction
corrector="twitter",
unpack_hashtags=True, # perform word segmentation on hashtags
unpack_contractions=True, # Unpack contractions (can't -> can not)
spell_correct_elong=True, # spell correction for elongated words
# select a tokenizer. You can use SocialTokenizer, or pass your own
# the tokenizer, should take as input a string and return a list of tokens
tokenizer=SocialTokenizer(lowercase=True).tokenize,
# list of dictionaries, for replacing tokens extracted from the text,
# with other expressions. You can pass more than one dictionaries.
dicts=[emoticons, emoticons_additional]
)
def tokenize(text):
text = " ".join(text_processor.pre_process_doc(text))
return text
3. Векторное представление слов
Векторное представление стало неотъемлемой частью большинства подходов к созданию NLP-систем с применением глубокого обучения. Чтобы определить наиболее подходящие модели векторного отображения, мы попробовали Word2Vec (Mikolov et al., 2013), GloVe (Pennington et al., 2014) и FastText (Joulin et al., 2017), а также предварительно обученные векторы DataStories (Baziotis et al., 2017). Word2Vec находит взаимосвязи между словами согласно предположению, что в похожих контекстах встречаются семантически близкие слова. Word2Vec пытается прогнозировать целевое слово (архитектура CBOW) или контекст (архитектура Skip-Gram), то есть минимизировать функцию потерь, а GloVe рассчитывает вектора слов, уменьшая размерность матрицы смежности. Логика работы FastText похожа на логику Word2Vec, за исключением того, что для построения векторов слов она использует символьные n-граммы, и как следствие, может решать проблему неизвестных слов.
Для всех упомянутых моделей мы используем параметры обучения по умолчанию, предоставленные авторами. Мы обучили простую LSTM-модель (dim=64) на основе каждого из этих векторных представлений и сравнили эффективность классификации с помощью кросс-валидации. Наилучший результат в F1-меры показали предварительно обученные вектора DataStories.
Для обогащения выбранного векторного отображения эмоциональной окраской слов мы решили произвести тонкую настройку векторов с помощью автоматически размеченного Distant-датасета (Deriu et al., 2017). Мы использовали Distant-датасет для обучения простой LSTM-сети, чтобы классифицировать «злые», «грустные» и «счастливые» сообщения. Эмбеддинг слой был заморожен в течение первой итерации обучения, чтобы избежать сильных изменений у весов векторов, а для последующих пяти итераций слой был разморожен. После обучения «оттюненные» векторы были сохранены для последующего использования в нейронной сети, а также выложены в общий доступ.
def getEmbeddings(file):
embeddingsIndex = {}
dim = 0
with io.open(file, encoding="utf8") as f:
for line in f:
values = line.split()
word = values[0]
embeddingVector = np.asarray(values[1:], dtype='float32')
embeddingsIndex[word] = embeddingVector
dim = len(embeddingVector)
return embeddingsIndex, dim
def getEmbeddingMatrix(wordIndex, embeddings, dim):
embeddingMatrix = np.zeros((len(wordIndex) + 1, dim))
for word, i in wordIndex.items():
embeddingMatrix[i] = embeddings.get(word)
return embeddingMatrix
from keras.preprocessing.text import Tokenizer
embeddings, dim = getEmbeddings('emosense.300d.txt')
tokenizer = Tokenizer(filters='')
tokenizer.fit_on_texts([' '.join(list(embeddings.keys()))])
wordIndex = tokenizer.word_index
print("Found %s unique tokens." % len(wordIndex))
embeddings_matrix = getEmbeddingMatrix(wordIndex, embeddings, dim)
4. Архитектура нейросети
Рекуррентные нейросети (RNN) — это семейство нейросетей, специализирующихся на обработке серии событий. В отличие от традиционных нейросетей, RNN предназначены для работы с последовательностями путем использования внутренних весов. Для этого вычислительный граф RNN содержит циклы, отражающие влияние предыдущей информации из последовательности событий на текущую. LSTM-нейросети (Long Short-Term Memory) были представлены в качестве расширения RNN в 1997-м (Hochreiter and Schmidhuber, 1997). Рекуррентные ячейки LSTM соединены так, чтобы избегать проблем с взрывом и затуханием градиентов. Традиционные LSTM лишь сохраняют прошлую информацию, поскольку обрабатывают последовательность в одном направлении. Двунаправленные LSTM, работающие в обоих направлениях, комбинируют выходные данные двух скрытых LSTM-слоёв, передающих информацию в противоположных направлениях — один по ходу времени, другой против, — тем самым одновременно получая данные из прошлого и будущего состояний (Schuster and Paliwal, 1997).
Рисунок 1: Уменьшенная версия архитектуры. LSTM-модуль использует одни и те же веса для первого и третьего этапов.
Упрощённое представление описанного подхода представлено на рисунке 1. Архитектура нейросети состоит из эмбеддинг-слоя и двух двунаправленных LTSM-модулей (dim = 64). Первый LTSM-модуль анализирует слова первого пользователя (то есть первую и третью реплику беседы), а второй модуль анализирует слова второго пользователя (вторую реплику). На первом этапе слова каждого пользователя с помощью заранее обученных векторных представлений подаются в соответствующий двунаправленный LTSM-модуль. Затем получившиеся три карты признаков объединяются в плоский вектор признаков, а затем передаются в полносвязный скрытый слой (dim=30), который анализирует взаимодействия между извлечёнными признаками. Наконец, эти признаки обрабатываются в выходном слое с помощью функции softmax-активации, чтобы определить финальную метку класса. Для уменьшения переобучения после слоёв векторного представления были добавлены слои регуляризации с гауссовским шумом, а также в каждый LTSM-модуль (p = 0.2) и перед скрытым полностью связным слоем (p = 0.1) были добавлены dropout-слои (Srivastava et al., 2014).
from keras.layers import Input, Dense, Embedding, Concatenate, Activation, \
Dropout, LSTM, Bidirectional, GlobalMaxPooling1D, GaussianNoise
from keras.models import Model
def buildModel(embeddings_matrix, sequence_length, lstm_dim, hidden_layer_dim, num_classes,
noise=0.1, dropout_lstm=0.2, dropout=0.2):
turn1_input = Input(shape=(sequence_length,), dtype='int32')
turn2_input = Input(shape=(sequence_length,), dtype='int32')
turn3_input = Input(shape=(sequence_length,), dtype='int32')
embedding_dim = embeddings_matrix.shape[1]
embeddingLayer = Embedding(embeddings_matrix.shape[0],
embedding_dim,
weights=[embeddings_matrix],
input_length=sequence_length,
trainable=False)
turn1_branch = embeddingLayer(turn1_input)
turn2_branch = embeddingLayer(turn2_input)
turn3_branch = embeddingLayer(turn3_input)
turn1_branch = GaussianNoise(noise, input_shape=(None, sequence_length, embedding_dim))(turn1_branch)
turn2_branch = GaussianNoise(noise, input_shape=(None, sequence_length, embedding_dim))(turn2_branch)
turn3_branch = GaussianNoise(noise, input_shape=(None, sequence_length, embedding_dim))(turn3_branch)
lstm1 = Bidirectional(LSTM(lstm_dim, dropout=dropout_lstm))
lstm2 = Bidirectional(LSTM(lstm_dim, dropout=dropout_lstm))
turn1_branch = lstm1(turn1_branch)
turn2_branch = lstm2(turn2_branch)
turn3_branch = lstm1(turn3_branch)
x = Concatenate(axis=-1)([turn1_branch, turn2_branch, turn3_branch])
x = Dropout(dropout)(x)
x = Dense(hidden_layer_dim, activation='relu')(x)
output = Dense(num_classes, activation='softmax')(x)
model = Model(inputs=[turn1_input, turn2_input, turn3_input], outputs=output)
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['acc'])
return model
model = buildModel(embeddings_matrix, MAX_SEQUENCE_LENGTH, lstm_dim=64, hidden_layer_dim=30, num_classes=4)
5. Результаты
В ходе поиска оптимальной архитектуры мы экспериментировали не только с количеством нейронов в слоях, функциями активации и параметрами регуляризации, но и с самой архитектурой нейросети. Подробнее об этом говорится в исходной работе.
Описанная в предыдущем разделе архитектура продемонстрировала наилучшие результаты при обучении на датасете Train и валидации на датасете Dev, поэтому она использовалась на финальной стадии состязания. На последнем тестовом датасете модель показала микро-усредненную F1-меру 72,59 %, а максимально достигнутый результат среди всех участников составил 79,59 %. Тем не менее, наш результат оказался гораздо выше базового значения в 58,68 %, заданного организаторами.
Исходный код модели и векторного представления слов доступен на GitHub.
Полная версия статьи и работа с описанием задачи лежат на сайте ACL Anthology.
Учебный датасет можно скачать в официальной группе на LinkedIn.
Цитирование:
@inproceedings{smetanin-2019-emosense,
title = "{E}mo{S}ense at {S}em{E}val-2019 Task 3: Bidirectional {LSTM} Network for Contextual Emotion Detection in Textual Conversations",
author = "Smetanin, Sergey",
booktitle = "Proceedings of the 13th International Workshop on Semantic Evaluation",
year = "2019",
address = "Minneapolis, Minnesota, USA",
publisher = "Association for Computational Linguistics",
url = "https://www.aclweb.org/anthology/S19-2034",
pages = "210--214",
}