um produto orientado a dados governamentais: parte 4
O projeto ressurge apoiado em redes neurais.
Esse é o quinto post de uma série de como construir um produto data-driven de ponta a ponta, caso você ainda não tenha acompanhado os demais, abaixo segue uma síntese com os respectivos links 😀.
- Em metadados de normas jurídicas federais coletamos dados do sistema LexML.
- Em um produto orientado a dados governamentais: parte 1 realizamos uma análise exploratória dos dados e definimos um recorte e um escopo para os dados do projeto.
- Em um produto orientado a dados governamentais: parte 2 realizamos a definição dos dos datasets de treino, validação e teste
- Em um produto orientado a dados governamentais: parte 3 detalhamos tudo que não deu certo no treinamento de modelos de machine learning.
No último post fizemos um registro de algumas tentativas de usar modelos de machine learning mais tradicionais para criar o nosso classificador de normas jurídicas. Depois de algumas tentativas frustadas, decidimos partir para uma abordagem de processamento de linguagem natural com deep learning
. Para realizar o treinamento das redes neurais iremos usar a biblioteca fastai. Como sempre, iniciamos o projeto carregando as bibliotecas que faremos uso.
import pandas as pd
import numpy as np
from pathlib import Path
from fastai.text import *
from fastai import *
import fastai
from sklearn.preprocessing import MultiLabelBinarizer
from sklearn.metrics import classification_report, f1_score
import torch
import warnings
Utilizamos a plataforma colab por conta da sua disponibilidade de GPU/TPU
para treinamento do nosso modelo. Dessa forma, será necessário sobrescrever alguns paths da biblioteca fastai
para persistir alguns dados e modelos gerados dentro do nosso google drive
.
Config.DEFAULT_CONFIG = {
'data_path': './drive/My Drive/Colab Notebooks/datagovproduct/fastai/data',
'model_path': './drive/My Drive/Colab Notebooks/datagovproduct/fastai/models'
}
Config.create('./drive/My Drive/Colab Notebooks/datagovproduct/myconfig.yml')
Config.DEFAULT_CONFIG_PATH = './drive/My Drive/Colab Notebooks/datagovproduct/myconfig.yml'
path = Path("./drive/My Drive/Colab Notebooks/datagovproduct/")
data_path = Config.data_path()
O processo de escolha de uso da fast.ai
teve duas grandes influências para esse projeto. Primeiramente, tenho uma admiração pessoal pelo Jeremy Howard que é o criador e mantenedor do projeto, que busca democratizar o uso de deep learning
. Além disso, ele é uma das mentes por trás do ULMFit que é uma das aplicações pioneiras de uso de transfer learning
para processamento de linguagem natural (NLP). Outra personalidade que contribuiu diretamente para essa escolha foi o Pierre Guillou que é uma referência em deep learning
, além de ser um dos membros mais ativos da comunidade de Deep Learning Brasília.
Com o grupo de sábado, completamos nossa primeira sessão do curso @fastdotai (parte 1) do @jeremyphoward e @math_rachel sobre o #DeepLearning :-) Este é o fim da aventura em #Brasilia ? Não. Tudo começa agora com os sobreviventes dessa primeira sessão :-) Parabéns ! #Brasil #AI pic.twitter.com/lbo2Icz5MM
— Pierre Guillou (@pierre_guillou) May 19, 2018
O Pierre Guillou já realizou diversas contribuções quanto a treinamento de modelos de linguagem natural pra a língua portuguesa. Especificamente fizemos uso de um dos seus trabalhos que ele treinou um language model
com o MultiFiT a partir dos dados da Wikipedia em português. Além disso, ele também demonstrou como fazer uso do language model
treinado para a tarefa de classificadores de textos.
Apresentado os créditos necesários aos trabalhos que tornaram o nosso projeto viável, vamos iniciar carregando os dados necessários.
xTrain = pd.read_csv('drive/My Drive/Colab Notebooks/data/xTrain.csv', sep=';')
yTrain = pd.read_csv('drive/My Drive/Colab Notebooks/data/yTrain.csv', sep=';')
xValidate = pd.read_csv('drive/My Drive/Colab Notebooks/data/xValidate.csv', sep=';')
yValidate = pd.read_csv('drive/My Drive/Colab Notebooks/data/yValidate.csv', sep=';')
xTest = pd.read_csv('drive/My Drive/Colab Notebooks/data/xTest.csv', sep=';')
yTest = pd.read_csv('drive/My Drive/Colab Notebooks/data/yTest.csv', sep=';')
O language model
que iremos utilizar foi treinado como bidirecional, isto é, uma rede neural é treinada predizer o token n+1
, isto é, a palavra seguinte. Enquanto segundo modelo, é treinado para predizer a palavra antecessora, isto é, o token n-1
. Para maiores detalhes do treinamento do language model
sugiro consultar o post do Pierre Guillou.
De posse do modelo treinado com os dados da Wikipedia, podemos realizar um ajuste fino com os nossos dados. Para essa etapa precisamos expor o modelo a maior quantidade de textos do nosso domínio específico. Assim, iremos consolidar os três datasets (treino
, validação
e teste
) que segmentamos para treinamento. É válido ressaltar, que não iremos expor ao language model
a nossa variável alvo, isto é, as tags. Portanto, não há vazamento de informação para o nosso problema de classificação, que é uma etapa posterior do nosso problema.
def transf_str_into_list(texto: str) -> list:
texto = str(texto)
texto = texto.split('[')[1].split(']')[0].split(',')
texto = [w.replace('[','').replace(']','').replace("'","").replace('(','').replace(')','').strip() for w in texto]
return texto
ementas = pd.concat([xTrain, xValidate, xTest])
ementas.drop(ementas[ementas.description.isnull()].index, inplace=True)
ementas = ementas.merge(pd.concat([yTrain, yValidate, yTest]), on='legislationIdentifier', how='left')
ementas.drop(ementas[ementas.description.isnull()].index, inplace=True)
ementas['keywords'] = ementas['keywords'].apply(transf_str_into_list)
Apesar que nesse estágio não iremos treinar o nosso classificador, já podemos preparar os dados para como a biblioteca da fastai os consome. Assim realizamos uma transformação por one-hot-encoding
em nossa variável alvo
one_hot = MultiLabelBinarizer()
mlabel_features = one_hot.fit_transform(ementas['keywords'])
mlabel_features_to_df = pd.DataFrame(mlabel_features, columns=one_hot.classes_)
mlabel_features_to_df.index = ementas.legislationIdentifier
ementas = ementas.set_index('legislationIdentifier')\
.drop(['legislationType', 'datePublished', 'keywords'], axis='columns')\
.join(mlabel_features_to_df)\
.rename(columns={'description' : 'ementa'})
ementas.head(3)
Em seguida criamos alguns diretórios e salvamos os nomes dos arquivos que armazenam os pesos dos modelos bem como seus respectivos vocabulários.
lang = 'pt'
name = f'{lang}wiki'
path = data_path/name
path.mkdir(exist_ok=True, parents=True)
data_path_wiki = data_path / 'ptwiki'
lm_fns3 = [f'{lang}_wt_sp15_multifit', f'{lang}_wt_vocab_sp15_multifit']
#pesos do forward language model e vocabulário
lm_fns3_bwd = [f'{lang}_wt_sp15_multifit_bwd', f'{lang}_wt_vocab_sp15_multifit_bwd']
Em meados do ano passado, foi incluído na fast.ai
uma implementação de um tokenizador faz uso de subwords units chamado SentencePiece.
We now have a sentencepiece tokenizer in fastai, so you can train NLP models in agglutinative languages like Turkish and non space delimited languages like Chinese.
— Jeremy Howard (@jeremyphoward) June 11, 2019
I just tried it on Turkish sentiment analysis with ULMFiT and easily beat the SoTA! :)https://t.co/6agBdk2tjW pic.twitter.com/PURrIsn7dD
Assim, precisamos garantir que os nossos dados sejam tokenizados no mesmo padrão utilizado no language model
que iremos realizar o fine-tunning.
%%time
bs = 64 # batch size works for Kaggle Kernels/colab
data_lm = (TextList.from_df(ementas, data_path, cols='ementa', processor=SPProcessor.load(data_path_wiki))
.split_by_rand_pct(0.1, seed=42)
.label_for_lm()
.databunch(bs=bs))
Iremos realizar alguns ajustes nos parâmetros da ASGD Weight-Dropped LSTM
que é arquitetura usada no treinamento do language model
.
config = awd_lstm_lm_config.copy()
config['qrnn'] = True
config['n_hid'] = 1550 # default 1152
config['n_layers'] = 4 # default 3
Por conseguinte, instanciamos o Learner
que irá armazenar o modelo que iremos importar e realizar o fine-tunning
.
%%time
perplexity = Perplexity()
learn_lm = language_model_learner(data_lm, AWD_LSTM, config=config, pretrained_fnames=lm_fns3, drop_mult=1.,
metrics=[error_rate, accuracy, perplexity]).to_fp16()
O modelo que usaremos possui 46 milhões de parâmetros, mas colocando em contexto com o GPT-3 que possui os seus 175 Bilhões 🦾, não parece tanto, né? Mas o nosso propósito que é treinar um classificador de textos, é mais que suficiente 😄!
sum([p.numel() for p in learn_lm.model.parameters()])
Avaliamos o gráfico abaixo para definir um learning rate para o nossa etapa de aprendizado.
learn_lm.recorder.plot(suggestion=True)
lr = 2e-2
lr *= bs/48
wd = 0.1
Em problemas de otimização, há diversas abordagens, mas uma em particular que tem atraído atenção nos tempos mais recentes, é a chamada de super convergência. Para maiores detalhes do seu funcionamento recomendo a leitura do artigo do Sylvain Gugger.
And here is @fastdotai's implemenation of Super-convergance: https://t.co/HOOav7ujtS #fit_one_cycle #SGD #DeepLearning #DataScience
— Jash Data Sciences (@DataJash) March 30, 2020
learn_lm.fit_one_cycle(2, lr*10, wd=wd, moms=(0.8,0.7))
learn_lm.unfreeze()
learn_lm.fit_one_cycle(18, lr, wd=wd, moms=(0.8,0.7), callbacks=[ShowGraph(learn_lm)])
Preparamos nossa estrutura de dados orientadas ao language model
invertido.
%%time
data_lm = (TextList.from_df(ementas, data_path, cols='ementa', processor=SPProcessor.load(data_path_wiki))
.split_by_rand_pct(0.1, seed=42)
.label_for_lm()
.databunch(bs=bs, backwards=True))
De forma análoga, instanciamos o Learner
que irá armazenar o backward model
que iremos importar e realizar o fine-tunning
.
%%time
perplexity = Perplexity()
learn_lm = language_model_learner(data_lm, AWD_LSTM, config=config, pretrained_fnames=lm_fns3_bwd, drop_mult=1.,
metrics=[error_rate, accuracy, perplexity]).to_fp16()
learn_lm.recorder.plot(suggestion=True)
Novamente, com auxílio do gráfico de Loss
x Learning Rate
definimos o lr
que usaremos e também definimos um weight decay
.
lr = 2e-2
lr *= bs/48
wd = 0.1
learn_lm.fit_one_cycle(2, lr*10, wd=wd, moms=(0.8,0.7))
learn_lm.unfreeze()
learn_lm.fit_one_cycle(18, lr, wd=wd, moms=(0.8,0.7), callbacks=[ShowGraph(learn_lm)])
Uma vez concluído as etapas de ajuste dos [forward
& backward
] language model
. Iremos proceder com o treinamento dos classificadores multilabel
.
Iniciamos essa etapa carregando a TextList
utilizada no fine-tuning do forward language model
. Essa etapa será necessária para acessar o vocabulário existente nessa estrutura de dados.
%%time
data_lm = load_data(data_path, f'{lang}_databunch_lm_pylexml_metadados_sp15_multifit_v2', bs=bs)
Podemos inspecionar essa estrutura de dados 👀.
data_lm.show_batch()
A partir de agora como estamos trabalhando na parte de classificação, devemos novamente segregar os nossos dados que originalmente estavam dividos em conjunto de: treinamento
, validação
e teste
. A classe TextList
possui um método (split_by_rand_pct
) que faz um recorte aleatório nos dados - baseado num percentual determinado pelo usuário - para fins de validação
. Portanto, iremos consolidar os dados de treinamento e validação e essa definição de distribuição será delegada a estrutura do TextList
.
Vamos selecionar as urns
- que são os identificadores únicos das normas - dos conjunto de treinamento
e validação
originais.
xTrain = xTrain[~xTrain['legislationIdentifier'].isna()].copy()
train_id = list(xTrain['legislationIdentifier'])
train_id = set(ementas.index).intersection(set(train_id))
valid_id = list(xValidate['legislationIdentifier'])
valid_id = set(ementas.index).intersection(set(valid_id))
Em seguida vamos criar um set resultante da união dos dois conjuntos de urns
.
new_train_id = train_id.union(valid_id)
Podemos conferir que temos 26900 normativos que serão utilizados para fins de treinamento
e validação
.
len(new_train_id)
Por fim, iremos consolidar os dataframes de treinamento/validação
e de teste
.
train_ementas_df = ementas.loc[new_train_id]
test_ementas_df = ementas.drop(new_train_id)
Resultamos com dois dataframes
com respectivamente 26900 e 6788 registros.
train_ementas_df.shape, test_ementas_df.shape
Realizamos uma última inspeção para garantirmos que não há urns
que estejam presentes em ambos conjuntos de dados.
set(train_ementas_df.index).intersection(test_ementas_df.index)
Um último ajuste necessário é a segregação do test_ementas_df
em dois dataframes distintos, um contendo apenas a variável preditora
(ementa) e um segundo com as variáveis respostas
.
test_ementas_df = test_ementas_df.reset_index()
y_test_ementas_df = test_ementas_df.drop(['ementa'], axis='columns')
y_test_ementas_df = y_test_ementas_df.set_index('legislationIdentifier')
test_ementas_df = test_ementas_df[['legislationIdentifier','ementa']]
test_ementas_df = test_ementas_df.set_index('legislationIdentifier')
Podemos fazer uma inspeção da estrutura do dataframe
com os dados de teste.
test_ementas_df.head(2)
Em seguida podemos criar uma estrutura TextList
para os dados destinados para avaliação final. Além disso, armazenamos em uma lista todas as tags
do conjunto de dados.
test_datalist = TextList.from_df(test_ementas_df, cols='ementa')
labels_cols = list(one_hot.classes_)
Agora podemos criar um novo objeto TextList
a partir dos dataframe recém criado, informando o vocabulário que deverá ser utilizado (data_lm.vocab
), além do tipo de tokenizador (SentencePiece
), além disso determinar que 20% dos dados devem ser destinados para validação
, bem como se trata de um problema de classificação multilabel e que os dados estão organizados na forma de one-hot-encoding
.
%%time
data_cls = (TextList.from_df(train_ementas_df, cols='ementa', vocab=data_lm.vocab, path=data_path, processor=SPProcessor.load(data_path_wiki))
.split_by_rand_pct(valid_pct=0.2, seed=42)
.label_from_df(cols=labels_cols, label_cls=MultiCategoryList, one_hot=True)
.databunch(bs=bs, num_workers=1))
O próximo passo é instanciar um Learner (text_classifier_learner
) com um classificador de textos, informando entre outras coisas qual arquitetura será utilizada. Em seguida devemos carregar o encoder
do forward language model.
learn_c = text_classifier_learner(data_cls, AWD_LSTM, config=config, pretrained=False, drop_mult=0.3,
metrics=[fbeta]).to_fp16()
learn_c.load_encoder(f'{lang}fine_tuned2_enc_pylexml_metadados_sp15_multifit_v2');
Podemos inspecionar qual a função de loss setada pelo Learner e, aparentemente, parece ser a mais indicada para problemas do tipo multilabel, como relatado aqui.
learn_c.loss_func
Novamente, fazendo a chamada no método lr_find
do Learner podemos determinar o learning rate a ser utilizado.
learn_c.recorder.plot(suggestion=True)
Definindo o learning rate
e o weight decay
podemos iniciar o processo de aprendizagem.
lr = 1e-1
lr *= bs/48
wd = 0.1
Vamos iniciar o nosso processo de aprendizado do classificador.
learn_c.fit_one_cycle(2, lr, wd=wd, moms=(0.8,0.7))
learn_c.fit_one_cycle(2, lr, wd=wd, moms=(0.8,0.7))
learn_c.freeze_to(-2)
learn_c.fit_one_cycle(2, slice(lr/(2.6**4),lr), wd=wd, moms=(0.8,0.7))
learn_c.freeze_to(-3)
learn_c.fit_one_cycle(2, slice(lr/2/(2.6**4),lr/2), wd=wd, moms=(0.8,0.7))
learn_c.unfreeze()
learn_c.fit_one_cycle(20, slice(lr/10/(2.6**4),lr/10), wd=wd, moms=(0.8,0.7))
learn_c.fit_one_cycle(5, slice(lr/100/(2.6**4),lr/100), wd=wd, moms=(0.8,0.7))
learn_c.fit_one_cycle(2, slice(lr/1000/(2.6**4),lr/1000), wd=wd, moms=(0.8,0.7))
Resultamos com um f1-score de 94.7% para a avaliação no conjunto de validação. Abaixo podemos em maiores detalhes a predição do modelo para alguns exemplos.
learn_c.show_results()
test_ementas_df.head(2)
Vamos escrever uma função para consolidar as predições do modelo em relação ao conjunto de testes.
def consolida_predicao_test_dataset(dataframe: pd.DataFrame, learner: fastai.text.learner.RNNLearner, multilabel: MultiLabelBinarizer):
container_of_preds = []
for row in dataframe.itertuples():
ementa = row.ementa
pred = learner.predict(ementa)[0].data.numpy()
container_of_preds.append(pred)
df = pd.DataFrame(container_of_preds, columns=multilabel.classes_, index=dataframe.index)
return df
Por fim, podemos armazenar o resultado das predições em uma variável e avaliar o f1-score
com os dados de teste.
df_test_predict = consolida_predicao_test_dataset(test_ementas_df, learn_c, one_hot)
Galera, marcamos 97.4% !!! 🥳🥳🥳🥳
f1_score(y_test_ementas_df.to_numpy(), df_test_predict.to_numpy(), average='micro')
Confesso que estou aliviado 😌, sabia? Depois de várias abordagens fracassadas que foram relatadas aqui conseguimos um excelente desempenho! A seguir segue o relatório detalhado das métricas por tag
.
print(classification_report(y_test_ementas_df.to_numpy(), df_test_predict.to_numpy(), target_names=one_hot.classes_))
De forma análoga ao que foi realizado com o forward classifier iremos proceder com o backward.
data_lm = load_data(data_path, file=f'{lang}_databunch_lm_pylexml_metadados_sp15_multifit_bwd_v2', bs=bs, backwards=True)
data_cls_bwd = (TextList.from_df(train_ementas_df, cols='ementa', vocab=data_lm.vocab, path=data_path, processor=SPProcessor.load(data_path_wiki))
.split_by_rand_pct(valid_pct=0.2, seed=42)
.label_from_df(cols=labels_cols, label_cls=MultiCategoryList, one_hot=True)
.add_test(test_datalist)
.databunch(bs=bs, num_workers=1, backwards=True))
Vamos instanciar um Learner e carregar o encoder
do backward language model que fizemos o fine-tunning.
learn_clf_bwd = text_classifier_learner(data_cls_bwd, AWD_LSTM, config=config, drop_mult=0.3, pretrained=False, metrics=[accuracy,fbeta]).to_fp16()
learn_clf_bwd.load_encoder(f'{lang}fine_tuned2_enc_pylexml_metadados_sp15_multifit_bwd_v2');
Como já procedido anteriormente, vamos determinar o learning rate
a ser utilizado.
learn_clf_bwd.recorder.plot(suggestion=True)
Não diferente do que já feito, setamos o learning rate
e o weight decay
.
lr = 2e-1
lr *= bs/48
wd = 0.1
Assim, podemos proceder com treinamento do classificador com o backward language model
.
learn_clf_bwd.fit_one_cycle(2, lr, wd=wd, moms=(0.8,0.7))
learn_clf_bwd.fit_one_cycle(2, lr, wd=wd, moms=(0.8,0.7))
learn_clf_bwd.freeze_to(-2)
learn_clf_bwd.fit_one_cycle(2, slice(lr/(2.6**4),lr), wd=wd, moms=(0.8,0.7))
learn_clf_bwd.freeze_to(-3)
learn_clf_bwd.fit_one_cycle(2, slice(lr/2/(2.6**4),lr/2), wd=wd, moms=(0.8,0.7))
learn_clf_bwd.unfreeze()
learn_clf_bwd.fit_one_cycle(20, slice(lr/10/(2.6**4),lr/10), wd=wd, moms=(0.8,0.7))
learn_clf_bwd.fit_one_cycle(5, slice(lr/100/(2.6**4),lr/100), wd=wd, moms=(0.8,0.7))
learn_clf_bwd.unfreeze()
learn_clf_bwd.fit_one_cycle(10, slice(lr/1000/(2.6**4),lr/1000), wd=wd, moms=(0.8,0.7))
Terminamos o última época com um f1-score
de 92.1% 🤗 para o conjunto de validação
, um resultado um pouco menor que o obtido com forward classifier
, mas igualmente satisfatório.
Podemos aproveitar a função que escrevemos e consolidar as predições do estimador em um dataframe.
%%time
df_test_bwd_predict = consolida_predicao_test_dataset(test_ementas_df, learn_clf_bwd, one_hot)
E o modelo também obteve um resultado excelente (97.2%)!! Apenas 0.02% abaixo do resultado do forward classifier
.
f1_score(y_test_ementas_df.to_numpy(), df_test_bwd_predict.to_numpy(), average='micro')
Abaixo o relatório detalhado dos resultados.
print(classification_report(y_test_ementas_df.to_numpy(), df_test_bwd_predict.to_numpy(), target_names=one_hot.classes_))
Com muita satisfação terminamos a nossa prototipação de modelo de classificação de nomas jurídicas com a entrega de dois modelos baseados em MultiFiT que tiveram performance no conjunto de testes de 97.4% e 97.2%. Esse desempenho os confere potencial real de serem embarcados em sistemas em produção. Por fim, espero que aos que tiveram o interesse de acompanhar o desenvolvimento desse projeto até aqui estejam satisfeito com a solução, da mesma forma que eu. Por fim, no próximo post iremos apresentar o deploy 🖥️ da solução! Até mais!!