Одна из основных задач диалоговых систем состоит не только в предоставлении нужной пользователю информации, но и в генерации как можно более человеческих ответов. А распознание эмоций собеседника — уже не просто крутая фича, это жизненная необходимость. В этой статье мы рассмотрим архитектуру рекуррентной нейросети для определения эмоций в текстовых беседах, которая принимала участие в 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",
}

Комментарии (8)


  1. Warrangie
    12.08.2019 16:25

    Интересно, как эта система сможет определять пассивно-агрессивное поведение?


    1. pavelkolodin
      12.08.2019 22:47

      А также позитивно-токсичное протралливание.


  1. tuxi
    12.08.2019 18:42

    С SAP HANA были попытки сравнить? Я делал анализ эмоций твитов на сап хане. Очень понравилось. И никаких нейронных сетей не понадобилось. Я больше времени убил на реализацию перехвата твитов в реал тайме и чтение архивов, чем на часть анализа эмоций.


    1. sismetanin Автор
      16.08.2019 11:06

      Нет, с SAP HANA не работал. Не уверен, что коммерческие решения подобного рода можно использовать в SemEval.


  1. dobrman
    12.08.2019 19:41

    Невольно вспомнился мем в виде смс переписки, что-то типа:
    -Зачем ты пошла к Свете?
    -Не кричи на меня!
    -О_О Я просто спросил, да и как можно кричать через смс?
    -Я прочла твое сообщение с интонацией и в ней ты на меня КРИЧАЛ!!!


    1. tuxi
      12.08.2019 19:57

      — Разберитесь пожалуйста! Ваш сотрудник на меня наорал!
      — Позвольте, да я молчал все время!
      — А вы молча на меня наорали!


  1. Gryphon88
    13.08.2019 11:57

    Надеюсь, когда-нибудь можно будет ответить на вечный вопрос интернет-общения «Trolling or just stupid?»


  1. EvilBeaver
    13.08.2019 16:08

    А сарказм оно распознает?

    — <какая-то плохая новость>
    — Ну зашибись, теперь заживем!