Автоматическое определение эмоций в текстовых беседах с использованием нейронных сетей

0
168


Одна из основных задач диалоговых систем состоит не только в предоставлении нужной пользователю информации, но и в генерации как можно более человеческих ответов. А распознание эмоций собеседника — уже не просто крутая фича, это жизненная необходимость. В этой статье мы рассмотрим архитектуру рекуррентной нейросети для определения эмоций в текстовых беседах, которая принимала участие в SemEval-2019 Task 3 “EmoContext”, ежегодном соревновании по компьютерной лингвистике. Задача состояла в классификации эмоций (“happy”, “sad”, “angry” и “others”) в беседе из трех реплик, в которой участвовали чат-бот и человек, пишет m.habr.com
В первой части статьи мы рассмотрим поставленную в 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 i feel you . i am breaking into million pieces
tired and I missed you too :‑( tired and i missed you too
you should liiiiiiisten to this: www.youtube.com/watch?v=99myH1orbs4 you should listen to this:
My apartment takes care of it. My rent is around $650. my apartment takes care of it. my rent is around .

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 = {
‘(^・^)’: ‘‘, ‘:‑c’: ‘‘, ‘=‑d’: ‘‘, “:’‑)”: ‘‘, ‘:‑d’: ‘‘,
‘:‑(‘: ‘‘, ‘;‑)’: ‘‘, ‘:‑)’: ‘‘, ‘:\\/’: ‘‘, ‘d=<': '‘,
‘:‑/’: ‘‘, ‘;‑]’: ‘‘, ‘(^�^)’: ‘‘, ‘angru’: ‘angry’, “d‑’:”:
‘, “:’‑(“: ‘‘, “:‑[“: ‘‘, ‘(�?�)’: ‘‘, ‘x‑d’: ‘‘,
}

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”,
}

Dnepr.com

ЛИШІТЬ ВІДПОВІДЬ

Please enter your comment!
Please enter your name here