Uma das principais tarefas dos sistemas de diálogo não é apenas fornecer as informações de que o usuário precisa, mas também gerar o maior número possível de respostas humanas. E o reconhecimento das emoções do interlocutor não é mais apenas um recurso interessante, é uma necessidade vital. Neste artigo, examinaremos a
arquitetura de uma rede neural recorrente para determinar emoções em conversas de texto , que participou da
Tarefa 3 do
SemEval-2019 "EmoContext" , a competição anual em linguística de computação. A tarefa era classificar emoções (“feliz”, “triste”, “zangada” e “outras pessoas”) em uma conversa de três observações, na qual participaram um bot de bate-papo e uma pessoa.
Na primeira parte do artigo, consideraremos a tarefa definida no EmoContext e os dados fornecidos pelos organizadores. Na segunda e terceira partes, analisamos o processamento preliminar do texto e os modos de representação vetorial das palavras. Na quarta parte, descrevemos a arquitetura LSTM que usamos na competição. O código é escrito em Python usando a biblioteca Keras.
1. Dados de treinamento
A faixa “EmoContext” no SemEval-2019 foi dedicada à definição de emoções nas conversas em texto, levando em consideração o contexto da correspondência. O contexto, neste caso, são várias observações consecutivas dos participantes do diálogo. Há dois participantes na conversa: um usuário anônimo (ele possui a primeira e a terceira réplica) e um bot de bate-papo
Ruuh (ele possui a segunda réplica). Com base em três réplicas, é necessário determinar qual emoção o usuário experimentou ao escrever uma resposta para o chatbot (Tabela 1). No total, a marcação do conjunto de dados continha quatro emoções: “feliz”, “triste”, “zangada” ou “outras” (Tabela 1). Uma descrição detalhada é apresentada aqui: (
Chatterjee et al., 2019 ).
Tabela 1. Exemplos do conjunto de dados EmoContext ( Chatterjee et al., 2019 )Durante a competição, os organizadores forneceram vários conjuntos de dados. O conjunto de dados de treinamento (Trem) consistiu em 30.160 textos marcados manualmente. Nesses textos, havia aproximadamente 5.000 objetos pertencentes às classes “feliz”, “triste” e “zangado”, além de 15.000 textos da classe “outros” (Tabela 2).
Os organizadores também forneceram conjuntos de dados para desenvolvimento (Dev) e testes (Teste), nos quais, diferentemente do conjunto de dados de treinamento, a distribuição por classe de emoções correspondia à vida real: cerca de 4% para cada uma das classes “feliz”, “triste” e “ bravo ", e o resto é da classe" outros ". Dados fornecidos pela Microsoft, você pode baixá-lo no
grupo oficial no LinkedIn .
Tabela 2. Distribuição dos rótulos das classes de emoções no conjunto de dados ( Chatterjee et al., 2019 ).Além desses dados, coletamos 900 mil mensagens em inglês do Twitter para criar um conjunto de dados Distant (300 mil tweets para cada emoção). Ao criá-lo, seguimos a estratégia de Go et al. (2009), no âmbito do qual as mensagens foram simplesmente associadas à presença de palavras relacionadas a emoções, como #angry, #annoyed, #happy, #sad, #surprised e assim por diante. A lista de termos é baseada nos termos do SemEval-2018 AIT DISC (
Duppada et al., 2018 ).
A principal métrica de qualidade da competição EmoContext é a medida F1 média para as três classes de emoções, ou seja, para as classes "feliz", "triste" e "zangada".
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. Pré-processamento de texto
Antes do treinamento, processamos previamente os textos usando a ferramenta Ekphrasis (Baziotis et al., 2017). Ajuda a corrigir a ortografia, normalizar palavras, segmento e também determinar quais tokens devem ser descartados, normalizados ou anotados usando tags especiais. Na fase de pré-processamento, fizemos o seguinte:
- URLs e correio, data e hora, apelidos, porcentagens, moedas e números foram substituídos pelas tags correspondentes.
- Termos em maiúsculas repetidos, censurados e alongados, acompanhados por rótulos apropriados.
- Palavras alongadas foram corrigidas automaticamente.
Além disso, o Ênfase contém um tokenizador que pode identificar a maioria dos emojis, emoticons e expressões complexas, além de datas, horas, moedas e acrônimos.
Tabela 3. Exemplos de pré-processamento de texto. 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(
3. Representação vetorial de palavras
A representação vetorial tornou-se parte integrante da maioria das abordagens para a criação de sistemas de PNL usando aprendizado profundo. Para determinar os modelos de mapeamento vetorial mais adequados, experimentamos o Word2Vec (
Mikolov et al., 2013 ), GloVe (
Pennington et al., 2014 ) e FastText (
Joulin et al., 2017 ), bem como os vetores DataStories pré-treinados (
Baziotis et al. 2017 ). O Word2Vec localiza relacionamentos entre palavras, assumindo que palavras semanticamente relacionadas são encontradas em contextos semelhantes. O Word2Vec tenta prever a palavra de destino (arquitetura CBOW) ou o contexto (arquitetura Skip-Gram), ou seja, minimizar a função de perda e o GloVe calcula vetores de palavras, reduzindo a dimensão da matriz de adjacência. A lógica do FastText é semelhante à lógica do Word2Vec, exceto que ele usa n-gramas simbólicos para criar vetores de palavras e, como resultado, pode resolver o problema de palavras desconhecidas.
Para todos os modelos mencionados, usamos os parâmetros de treinamento padrão fornecidos pelos autores. Nós treinamos um modelo LSTM simples (dim = 64) com base em cada uma dessas representações vetoriais e comparamos a eficiência da classificação usando validação cruzada. O melhor resultado nas medidas F1 foi mostrado por vetores pré-treinados do DataStories.
Para enriquecer o mapeamento vetorial selecionado com a coloração emocional das palavras, decidimos ajustar os vetores usando o
conjunto de dados Distant automaticamente identificado (
Deriu et al., 2017 ). Usamos o conjunto de dados Distant para treinar uma rede LSTM simples para classificar mensagens "más", "tristes" e "felizes". A camada de incorporação foi congelada durante a primeira iteração do treinamento, a fim de evitar fortes mudanças nos pesos dos vetores e, nas cinco iterações seguintes, a camada foi descongelada. Após o treinamento, os vetores "atrasados" foram salvos para uso posterior na rede neural e também
compartilhados .
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. Arquitetura de rede neural
Redes Neurais Recorrentes (RNNs) são uma família de redes neurais especializadas no processamento de uma série de eventos. Diferentemente das redes neurais tradicionais, as RNNs são projetadas para trabalhar com sequências usando balanços internos. Para isso, o gráfico computacional RNN contém ciclos que refletem a influência de informações anteriores da sequência de eventos no atual. Redes neurais LSTM (Long Short-Term Memory) foram introduzidas como uma extensão da RNN em 1997 (
Hochreiter e Schmidhuber, 1997 ). As células de recorrência LSTM são conectadas para evitar problemas de explosão e desbotamento. Os LSTMs tradicionais preservam apenas as informações passadas enquanto processam a sequência em uma direção. Os LSTMs bidirecionais que operam em ambas as direções combinam a saída de duas camadas ocultas de LSTM que transmitem informações em direções opostas - uma no decorrer do tempo e a outra contra - recebendo simultaneamente dados de estados passados e futuros (
Schuster e Paliwal, 1997 ).
Figura 1: Versão reduzida da arquitetura. O módulo LSTM usa os mesmos pesos para o primeiro e o terceiro estágios.Uma representação simplificada da abordagem descrita é apresentada na Figura 1. A arquitetura da rede neural consiste em uma camada de incorporação e dois módulos LTSM bidirecionais (dim = 64). O primeiro módulo LTSM analisa as palavras do primeiro usuário (isto é, a primeira e a terceira réplica da conversa) e o segundo módulo analisa as palavras do segundo usuário (segunda réplica). No primeiro estágio, as palavras de cada usuário usando representações vetoriais pré-treinadas são inseridas no módulo bidirecional LTSM correspondente. Em seguida, os três mapas de recursos resultantes são combinados em um vetor de recurso plano e depois transferidos para uma camada oculta totalmente conectada (dim = 30), que analisa as interações entre os recursos extraídos. Finalmente, essas características são processadas na camada de saída usando a função de ativação softmax para determinar o rótulo da classe final. Para reduzir o sobreajuste, após as camadas da representação vetorial, foram adicionadas camadas de regularização com ruído gaussiano e camadas de abandono a cada módulo LTSM (p = 0,2) e uma camada totalmente conectada oculta (p = 0,1) (
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. Resultados
Na busca pela arquitetura ideal, experimentamos não apenas o número de neurônios nas camadas, funções de ativação e parâmetros de regularização, mas também a arquitetura da própria rede neural. Isso é descrito com mais detalhes no
trabalho original .
A arquitetura descrita na seção anterior mostrou os melhores resultados ao treinar no conjunto de dados Train e validar no conjunto de dados Dev, por isso foi usado na fase final da competição. No último conjunto de dados de teste, o modelo mostrou uma medida F1 micro-média de 72,59%, e o resultado máximo alcançado entre todos os participantes foi de 79,59%. No entanto, nosso resultado foi muito superior ao valor basal de 58,68% estabelecido pelos organizadores.
O código fonte da representação de modelo e vetor de palavras está disponível no GitHub.
A versão completa do artigo e o
trabalho com a descrição da tarefa estão no site da ACL Anthology.
O conjunto de dados de treinamento pode ser baixado do grupo oficial do LinkedIn.
Citação:
@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", }