company_banner

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


    Одна из основных задач диалоговых систем состоит не только в предоставлении нужной пользователю информации, но и в генерации как можно более человеческих ответов. А распознание эмоций собеседника — уже не просто крутая фича, это жизненная необходимость. В этой статье мы рассмотрим архитектуру рекуррентной нейросети для определения эмоций в текстовых беседах, которая принимала участие в 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",
    }
    • +36
    • 5,6k
    • 8
    Mail.ru Group
    1 077,30
    Строим Интернет
    Поделиться публикацией

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

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

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

        0
        С SAP HANA были попытки сравнить? Я делал анализ эмоций твитов на сап хане. Очень понравилось. И никаких нейронных сетей не понадобилось. Я больше времени убил на реализацию перехвата твитов в реал тайме и чтение архивов, чем на часть анализа эмоций.
          0
          Нет, с SAP HANA не работал. Не уверен, что коммерческие решения подобного рода можно использовать в SemEval.
          0
          Невольно вспомнился мем в виде смс переписки, что-то типа:
          -Зачем ты пошла к Свете?
          -Не кричи на меня!
          -О_О Я просто спросил, да и как можно кричать через смс?
          -Я прочла твое сообщение с интонацией и в ней ты на меня КРИЧАЛ!!!
            0
            — Разберитесь пожалуйста! Ваш сотрудник на меня наорал!
            — Позвольте, да я молчал все время!
            — А вы молча на меня наорали!
            0
            Надеюсь, когда-нибудь можно будет ответить на вечный вопрос интернет-общения «Trolling or just stupid?»
              0
              А сарказм оно распознает?

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

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

              Самое читаемое