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:
Antidepressant or Tolkien character?
— Mar-a-Lago Robbie (checarina@bsky.social) (@checarina) March 24, 2018
🔹Azafen
🔹Bergil
🔹Celebrían
🔹Círdan
🔹Clédial
🔹Desyrel
🔹Edronax
🔹Elendil
🔹Elronon
🔹Erestor
🔹Eskalith
🔹Finarfin
🔹Haldir
🔹Istinil
🔹Minalcar
🔹Nardil
🔹Narmacil
🔹Narvi
🔹Norval
🔹Orophin
🔹Prothiaden
🔹Sintamil
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
.
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()
Name | Gender | Details | Total | |
---|---|---|---|---|
0 | Adalbert | m | 1 character | 1 |
1 | Adaldrida | f | 1 character | 1 |
2 | Adalgar | m | 1 character | 1 |
3 | Adalgrim | m | 1 character | 1 |
4 | Adamanta | f | 1 character | 1 |
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 Name | Active Ingredient | Form;Route | Appl. No. | Company | Date | Link | |
---|---|---|---|---|---|---|---|
0 | Abilify | Aripiprazole | TABLET, ORALLY DISINTEGRATING;ORAL | 21729 | OTSUKA | 02/05/2020 | https://www.accessdata.fda.gov/drugsatfda_docs... |
1 | Abilify | Aripiprazole | TABLET;ORAL | 21436 | OTSUKA | 02/05/2020 | https://www.accessdata.fda.gov/drugsatfda_docs... |
2 | Abilify | Aripiprazole | SOLUTION;ORAL | 21713 | OTSUKA | 02/05/2020 | https://www.accessdata.fda.gov/drugsatfda_docs... |
3 | Abilify | Aripiprazole | SOLUTION;ORAL | 21713 | OTSUKA | 02/05/2020 | https://www.accessdata.fda.gov/drugsatfda_docs... |
4 | Abilify | Aripiprazole | INJECTABLE;INTRAMUSCULAR | 21866 | OTSUKA | 02/05/2020 | https://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:
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.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}")
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)
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!
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'])
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()
true | prediction | name | |
---|---|---|---|
0 | 0 | 0 | supprelin |
1 | 1 | 1 | bingo |
2 | 0 | 0 | ponstel |
3 | 0 | 1 | elidel |
4 | 0 | 0 | aubagio |
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?
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.