Nome de personagem do Tolkien ou de medicamento?

Classificação utilizando redes neurais de Memória de Curto e Longo Prazo (LSTM) em nível de caracteres

Lista de Conteúdos

Depois de tentar ler O Silmariliion de J.R.R. Tolkien pela milionésima vez, eu me lembrei de um tweet engraçado que já existe há algum tempo:


Mesmo sendo um fã casual de O Senhor dos Anéis e já tendo cursado duas disciplinas de farmacologia na faculdade, eu não tinha ideia de quem ou o que era um Narmacil. Devemos temê-lo(a) por suas habilidades com a espada ou por seus perigosos efeitos colaterais?

Essa pequena trivialidade me levou a perguntar se uma rede neural artificial (ANN) poderia ter sucesso onde eu e muitos outros falhamos. Aqui, eu mostro como construir um tipo especial de ANN chamado Long Short-Term Memory (LSTM) para classificar nomes entre personagens do Tolkien ou medicamentos utilizando o framework Keras.

begins

Conjunto de dados

O primeiro passo foi construir do zero um conjunto de dados com nomes de personagens do Tolkien e nomes de medicamentos (um monte deles, não apenas antidepressivos).

Personagens do Tolkien

Para nossa sorte, o site Behind the Name tem um banco de dados dos primeiros nomes dos personagens de Tolkien que podemos ler diretamente do HTML da página usando pandas.

import pandas as pd

raw_tolkien_chars = pd.read_html('https://www.behindthename.com/namesakes/list/tolkien/name')
raw_tolkien_chars[2].head()

NameGenderDetailsTotal
0Adalbertm1 character1
1Adaldridaf1 character1
2Adalgarm1 character1
3Adalgrimm1 character1
4Adamantaf1 character1
tolkien_names = raw_tolkien_chars[2]['Name']
tolkien_names.iloc[350:355]
350           Gethron
351    Ghân-buri-Ghân
352            Gildis
353            Gildor
354         Gil-galad
Name: Name, dtype: object

Podemos ver que alguns nomes são hifenizados e têm letras acentuadas. Para simplificar a análise, transformei caracteres Unicode em ASCII, removi os sinais de pontuação, transformei-os em letras minúsculas e removi quaisquer duplicatas possíveis.

import unidecode

processed_tolkien_names = tolkien_names.apply(unidecode.unidecode).str.lower().str.replace('-', ' ')
processed_tolkien_names = [name[0] for name in processed_tolkien_names.str.split()]
processed_tolkien_names = pd.DataFrame(processed_tolkien_names, columns=['name']).sort_values('name').drop_duplicates()
processed_tolkien_names['tolkien'] = 1
processed_tolkien_names['name'].iloc[350:355]
473    gethron
439       ghan
109        gil
341     gildis
324     gildor
Name: name, dtype: object
processed_tolkien_names.shape
(746,2)

Feito! Agora temos 746 nomes de personagens diferentes.

Medicamentos

Para obter uma lista abrangente de nomes de medicamentos, baixei o guia de medicamentos da agência norte-americana FDA (U.S. Food and Drug Administration).

raw_medication_guide = pd.read_csv('data/raw/medication_guides.csv')
raw_medication_guide.head()

Drug NameActive IngredientForm;RouteAppl. No.CompanyDateLink
0AbilifyAripiprazoleTABLET, ORALLY DISINTEGRATING;ORAL21729OTSUKA02/05/2020https://www.accessdata.fda.gov/drugsatfda_docs...
1AbilifyAripiprazoleTABLET;ORAL21436OTSUKA02/05/2020https://www.accessdata.fda.gov/drugsatfda_docs...
2AbilifyAripiprazoleSOLUTION;ORAL21713OTSUKA02/05/2020https://www.accessdata.fda.gov/drugsatfda_docs...
3AbilifyAripiprazoleSOLUTION;ORAL21713OTSUKA02/05/2020https://www.accessdata.fda.gov/drugsatfda_docs...
4AbilifyAripiprazoleINJECTABLE;INTRAMUSCULAR21866OTSUKA02/05/2020https://www.accessdata.fda.gov/drugsatfda_docs...
drug_names = raw_medication_guide['Drug Name']
drug_names.iloc[160:165]
160                                             Chantix
161         Children's Cetirizine Hydrochloride Allergy
162    Chlordiazepoxide and Amitriptyline Hydrochloride
163                                              Cimzia
164                                              Cimzia
Name: Drug Name, dtype: object

Uma etapa de pré-processamento semelhante também foi repetida para este conjunto de dados:

processed_drug_names = drug_names.str.lower().str.replace('.', '').str.replace( '-', ' ').str.replace('/', ' ').str.replace("'", ' ').str.replace(",", ' ')
processed_drug_names = [name[0] for name in processed_drug_names.str.split()]
processed_drug_names = pd.DataFrame(processed_drug_names, columns=['name']).sort_values('name').drop_duplicates()
processed_drug_names['tolkien'] = 0
processed_drug_names['name'].iloc[84:89]
373             chantix
448            children
395    chlordiazepoxide
185              cimzia
292               cipro
Name: name, dtype: object
processed_drug_names.shape
(611,2)

Pronto, 611 nomes de medicamentos diferentes!

Podemos finalmente combinar os dois conjuntos de dados e seguir em frente.

dataset = pd.concat([processed_tolkien_names, processed_drug_names], ignore_index=True)

Transformação de dados

Agora temos vários nomes, mas os modelos de aprendizado de máquina não funcionam com caracteres brutos. Precisamos convertê-los em um formato numérico que possa ser processado por nosso modelo a ser construído em breve.

Usando a classe Tokenizer de Keras, definimos char_level = True para processar cada palavra em nível de caractere. O método fit_on_texts() atualizará o vocabulário interno do tokenizer com base nos nomes do conjunto de dados e então text_to_sequences() transformará cada nome em uma sequência de inteiros.

from keras.preprocessing.text import Tokenizer
tokenizer = Tokenizer(char_level=True)
tokenizer.fit_on_texts(dataset['name'])
char_index = tokenizer.texts_to_sequences(dataset['name'])

Olhe como ficou o nosso querido Bilbo:

print(dataset['name'][134])
print(char_index[134])
bilbo
[16, 3, 6, 16, 5]

No entanto, essa representação não é exatamente ideal. Ter inteiros para representar letras pode levar a ANN a supor que os caracteres têm uma escala ordinal de importância. Para resolver este problema, temos que:

  1. Definir todos os nomes com o comprimento do nome mais longo (17 caracteres aqui). Usamos pad_sequences para adicionar 0’s ao final dos nomes com menos de 17 letras.

  2. Converter cada inteiro em sua representação de one-hot encoding. O vetor consiste em 0s em todas as posições, exceto um único 1 em uma posição para identificar a letra.

from keras.preprocessing.sequence import pad_sequences
from keras.utils import to_categorical
import numpy as np

char_index = pad_sequences(char_index, maxlen=dataset['name'].apply(len).max(), padding="post")
x = to_categorical(char_index)  # onehot encoding
y = np.array(dataset['tolkien'])
x.shape
(1357, 17, 27)

Temos 1357 nomes. Cada nome é um vetor de 17 letras e cada letra é um vetor de tamanho 27 (26 letras do alfabeto latino + caractere de preenchimento).

Divisão de dados

Eu dividi os dados em conjuntos de treinamento, validação e teste com uma proporção de 60/20/20 usando uma função personalizada, uma vez que sklearn train_test_split produz apenas dois conjuntos.

from sklearn.model_selection import train_test_split

def data_split(data, labels, train_ratio=0.5, rand_seed=42):

    x_train, x_temp, y_train, y_temp = train_test_split(data,
                                                        labels,
                                                        train_size=train_ratio,
                                                        random_state=rand_seed)

    x_val, x_test, y_val, y_test = train_test_split(x_temp,
                                                    y_temp,
                                                    train_size=0.5,
                                                    random_state=rand_seed)

    return x_train, x_val, x_test, y_train, y_val, y_test

x_train, x_val, x_test, y_train, y_val, y_test = data_split(x, y, train_ratio=0.6)

Vamos dar uma olhada nas divisões:

from collections import Counter
import matplotlib.pyplot as plt

dataset_count = pd.DataFrame([Counter(y_train), Counter(y_val), Counter(y_test)],
                                index=["train", "val", "test"])
dataset_count.plot(kind='bar')
plt.xticks(rotation=0)
plt.show()

print(f"Total number of samples: \n{dataset_count.sum(axis=0).sum()}")
print(f"Class/Samples: \n{dataset_count.sum(axis=0)}")
print(f"Split/Class/Samples: \n{dataset_count}")

png

Total number of samples: 
1357
Class/Samples: 
1    746
0    611
dtype: int64
Split/Class/Samples: 
        1    0
train  451  363
val    149  122
test   146  126

Existem mais personagens de Tolkien do que nomes de medicamentos, mas parece um equilíbrio adequado.

Modelo LSTM

Long Short-Term Memory é um tipo de Rede Neural Recorrente proposta por Hochreiter S. & Schmidhuber J. (1997) para armazenar informações em intervalos de tempo estendidos. Nomes são apenas sequências de caracteres em que a ordem é importante, portanto, as redes LSTM são uma ótima escolha para nossa tarefa de previsão de nomes. Você pode ler mais sobre LSTMs neste guia ilustrado incrível escrito por Michael Phi.

Treinamento

O framework Keras foi usado para construir este modelo LSTM simples após alguns testes e ajustes de hiperparâmetros. A rede tem uma camada oculta com 8 blocos LSTM, uma camada de dropout para evitar sobreajustamento e um neurônio de saída com uma função de ativação sigmoide para fazer uma classificação binária.

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense, Activation, Dropout, LSTM
from tensorflow.random import set_seed
set_seed(23)

model = Sequential()
model.add(LSTM(8, return_sequences=False,
               input_shape=(x.shape[1], x.shape[2])))
model.add(Dropout(0.3))
model.add(Dense(units=1))
model.add(Activation('sigmoid'))
model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
lstm (LSTM)                  (None, 8)                 1152      
_________________________________________________________________
dropout (Dropout)            (None, 8)                 0         
_________________________________________________________________
dense (Dense)                (None, 1)                 9         
_________________________________________________________________
activation (Activation)      (None, 1)                 0         

=================================================================
Total params: 1,161
Trainable params: 1,161
Non-trainable params: 0
_________________________________________________________________

Adam é um bom otimizador padrão e produz ótimos resultados em aplicações de aprendizado profundo. A entropia cruzada binária é a função de custo padrão para problemas de classificação binária e é compatível com nossa arquitetura de saída de neurônio único.

from tensorflow.keras.optimizers import Adam

model.compile(loss="binary_crossentropy",
              optimizer=Adam(learning_rate=1e-3), metrics=['accuracy'])

Duas callbacks foram implementadas. EarlyStopping para interromper o processo de treinamento após 20 épocas sem reduzir o valor da função de custo de validação e ModelCheckpoint para sempre salvar o modelo quando o valor de custo de validação diminuir.

from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint

es = EarlyStopping(monitor='val_loss', verbose=1, patience=20)
mc = ModelCheckpoint("best_model.h5", monitor='val_loss',
                     verbose=1, save_best_only=True)

history = model.fit(x_train, y_train, batch_size=32, epochs=100,
                    validation_data=(x_val, y_val), callbacks=[es, mc])
Epoch 00071: val_loss did not improve from 0.34949
Epoch 72/100
26/26 [==============================] - 1s 24ms/step - loss: 0.3085 - accuracy: 0.8836 - val_loss: 0.3861 - val_accuracy: 0.8487

Epoch 00072: val_loss did not improve from 0.34949
Epoch 00072: early stopping
val_loss_per_epoch = history.history['val_loss']
best_epoch = val_loss_per_epoch.index(min(val_loss_per_epoch)) + 1
print(f"Best epoch: {best_epoch}")
Best epoch: 52

Vamos plotar os valores de acurácia e custo por época para ver a progressão dessas métricas.

def plot_metrics(history):
    
    plt.figure(figsize=(12,6))
    
    plt.subplot(1,2,1)
    plt.plot(history.history['accuracy'], label='Training')
    plt.plot(history.history['val_accuracy'], label='Validation')
    plt.xlabel('Epoch')
    plt.ylabel('Accuracy')
    plt.legend(loc='lower right')
    plt.grid('on')

    plt.subplot(1,2,2)
    plt.plot(history.history['loss'], label='Training')
    plt.plot(history.history['val_loss'], label='Validation')
    plt.xlabel('Epoch')
    plt.ylabel('Loss')
    plt.legend(loc='upper right')
    plt.grid('on')

plot_metrics(history)

png

Podemos ver que a acurácia atinge rapidamente um bom patamar em torno de 80%. Visualmente, o modelo parece começar a sobreajustar após a época 50. Não deve ser um problema usar a versão salva por ModelCheckpoint na época 52.

Avaliação do desempenho

Finalmente, vamos ver como nosso modelo se sai com o conjunto de dados de teste.

from tensorflow.keras.models import load_model

model = load_model("best_model.h5")
metrics = model.evaluate(x=x_test, y=y_test)
9/9 [==============================] - 1s 7ms/step - loss: 0.4595 - accuracy: 0.8125
print("Accuracy: {0:.2f} %".format(metrics[1]*100))
Accuracy: 81.25 %

81.25 %

Nada mal!

hobits

Podemos explorar os resultados um pouco mais com a matriz de confusão e o relatório de classificação:

from sklearn.metrics import confusion_matrix
from seaborn import heatmap

def plot_confusion_matrix(y_true, y_pred, labels):
    
    cm = confusion_matrix(y_true, y_pred)
    heatmap(cm, annot=True, fmt="d", cmap="rocket_r", xticklabels=labels, yticklabels=labels)
    plt.ylabel('True label')
    plt.xlabel('Predicted label')
    plt.show()

predictions = model.predict(x_test)
threshold = 0.5
y_pred = predictions > threshold
plot_confusion_matrix(y_test, y_pred, labels=['Drug','Tolkien'])

png

from sklearn.metrics import classification_report

print(classification_report(y_test, y_pred, target_names=['Drug', 'Tolkien']))
              precision    recall  f1-score   support

        Drug       0.80      0.80      0.80       126
     Tolkien       0.83      0.82      0.82       146

    accuracy                           0.81       272
   macro avg       0.81      0.81      0.81       272
weighted avg       0.81      0.81      0.81       272

Além da boa acurácia, o modelo possui quase o mesmo número de falsos positivos e falsos negativos. Podemos ver isso refletindo em uma precisão e sensibilidade equilibradas.

Então, apenas por curiosidade, quais nomes de personagens criados por Tolkien poderiam ser confundidos com nomes de medicamentos?

def onehot_to_text(onehot_word):
    """Reverse one-hot encoded words to strings"""

    char_index = [[np.argmax(char) for char in onehot_word]]
    word = tokenizer.sequences_to_texts(char_index)
    return ''.join(word[0].split())
test_result = pd.DataFrame()
test_result['true'] = y_test
test_result['prediction'] = y_pred.astype(int)
test_result['name'] = [onehot_to_text(name) for name in x_test]
test_result.head()

truepredictionname
000supprelin
111bingo
200ponstel
301elidel
400aubagio
test_result['name'].loc[(test_result['true']==1) & (test_result['prediction']==0)]
13              ivy
17         camellia
44      celebrindor
47         meriadoc
63        vanimelde
64        finduilas
75        eglantine
84             ruby
87            poppy
89             otto
100           tanta
102          myrtle
108          prisca
132          cottar
151          stybba
171            este
175           daisy
189          tulkas
195        arciryas
205        odovacar
206          tarcil
207    hyarmendacil
229            jago
230            tata
240           ponto
271       landroval
Name: name, dtype: object

Conclusões

Aqui aprendemos como trabalhar com representações vetoriais de caracteres e construir um modelo LSTM simples capaz de distinguir entre nomes de personagens de Tolkien e nomes de medicamentos. O código completo, incluindo pré-requisitos, conjunto de dados, uma versão de código em Jupyter Notebook e uma versão em script, pode ser encontrado em meu repositório do GitHub.

Você também pode brincar com esse popular teste interativo encontrado na web: “Antidepressivo ou Tolkien?”. Eu acertei apenas 70,8%!. Será que você consegue adivinhar melhor do que a rede LSTM?

ends

Referências

Hu, Y., Hu, C., Tran, T., Kasturi, T., Joseph, E., & Gillingham, M. (2021). What’s in a Name?–Gender Classification of Names with Character Based Machine Learning Models. arXiv preprint arXiv:2102.03692.

Bhagvati, C. (2018). Word representations for gender classification using deep learning. Procedia computer science, 132, 614-622.

Liang, X. (2018). How to Preprocess Character Level Text with Keras.

Guilherme Bauer Negrini
Guilherme Bauer Negrini
Biomedical Data Scientist | Postdoctoral Associate

Informática e Ciência Biomédica.