Workshop NLP: classificação de textos utilizando tf-idf e regressão logística

Setup

Importando bibliotecas necessárias:

In [0]:
import random
import numpy as np
import pandas as pd
import pickle
import copy 
import re

import json
import time

import seaborn as sns
import matplotlib.pyplot as plt

import nltk
import nltk, re, pprint
from nltk import word_tokenize
from nltk.stem import *
from nltk.corpus import stopwords

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics import confusion_matrix
from sklearn.linear_model import LogisticRegression
from sklearn.feature_selection import SelectFromModel
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import MaxAbsScaler

import keras
from keras.layers import Input, Dense
from keras.models import Model
from keras.models import load_model
from keras import optimizers
from keras.regularizers import l1
from keras.regularizers import l2

nltk.download('stopwords')
nltk.download('punkt')
nltk.download('rslp')
[nltk_data] Downloading package stopwords to /root/nltk_data...
[nltk_data]   Package stopwords is already up-to-date!
[nltk_data] Downloading package punkt to /root/nltk_data...
[nltk_data]   Package punkt is already up-to-date!
[nltk_data] Downloading package rslp to /root/nltk_data...
[nltk_data]   Package rslp is already up-to-date!
Out[0]:
True

Conectando ao Google Drive:

In [ ]:
from google.colab import drive
drive.mount('/content/drive')

Definindo caminho dos arquivos (base de dados etc):

In [0]:
path = "/content/drive/My Drive/Workshop2/"

Começo dos trabalhos

Lendo base de dados classificação de notícias (a base de dados pode ser baixada em https://medium.com/@robert.salgado/multiclass-text-classification-from-start-to-finish-f616a8642538):

In [0]:
#Carregando JSON 

data = []

for line in open(path+'News Classification DataSet.json', 'r'):
    data.append(json.loads(line))

content, label = [], []
for each in data:
    content.append(each['content'])
    label.append(each['annotation']['label'][0])
    
df = pd.DataFrame([content, label]).T
df.columns= ['content', 'label']
df.head()
Out[0]:
content label
0 Unions representing workers at Turner Newall... Business
1 SPACE.com - TORONTO, Canada -- A second\team o... SciTech
2 AP - A company founded by a chemistry research... SciTech
3 AP - It's barely dawn when Mike Fitzpatrick st... SciTech
4 AP - Southern California's smog-fighting agenc... SciTech

Vamos ver o shape da base:

In [0]:
np.shape(df)
Out[0]:
(7600, 2)

Checando frequências relativas de labels:

In [0]:
pd.crosstab(index=df['label'], columns="freq_rel", normalize=True)
Out[0]:
col_0 freq_rel
label
Business 0.25
SciTech 0.25
Sports 0.25
World 0.25

Vamos transformar os 4 labels em 4 variáveis binárias:

In [0]:
df=pd.concat([df,pd.get_dummies(df['label'])], axis=1)
df.head()
Out[0]:
content label Business SciTech Sports World
0 Unions representing workers at Turner Newall... Business 1 0 0 0
1 SPACE.com - TORONTO, Canada -- A second\team o... SciTech 0 1 0 0
2 AP - A company founded by a chemistry research... SciTech 0 1 0 0
3 AP - It's barely dawn when Mike Fitzpatrick st... SciTech 0 1 0 0
4 AP - Southern California's smog-fighting agenc... SciTech 0 1 0 0

Vamos transformar nossa base de dados em duas listas (uma para os textos e outra para a variável de interesse):

In [0]:
X=df['content'].to_list()
y=np.array(df[['Business', 'SciTech', 'Sports', 'World']])

Checando um texto da lista:

In [0]:
X[0]
Out[0]:
"Unions representing workers at Turner   Newall say they are 'disappointed' after talks with stricken parent firm Federal Mogul."

Checando sua classe:

In [0]:
y[0]
Out[0]:
array([1, 0, 0, 0], dtype=uint8)

Definindo uma função para a limpeza dos textos:

In [0]:
def clean(resulta):   
    result = copy.deepcopy(resulta)
    
    result=result.lower()
    result=result.replace(",", "")
    
    result=re.sub(r"\@\w+", ' citation### ', result) #subtituindo @* por 'citation###'
    result=re.sub(r"http\S+", ' weblink### ', result) #subtituindo urls por 'weblink###'
    result=re.sub('\d', ' ### ', result) #subtituindo números por '###'
    
    result=re.sub('([.,!?()])', r' \1 ', result)  #colocando espaço entre pontuações de palavras
    result=re.sub('\s{2,}', ' ', result)
    
    result=result.replace("<br />","")
    result=result.replace("\n", " ")
    result=result.replace("/", "")
    result=result.replace("|", "")
    result=result.replace("+", "")
    
    #result=result.replace(".", "") vamos MANTER 
    #result=result.replace(":", "") vamos MANTER 
    #result=result.replace(";", "") vamos MANTER 
    #result=result.replace("!", "") vamos MANTER 
    #result=result.replace("?", "") vamos MANTER 
    
    result=result.replace(">", "")
    result=result.replace("=", "")
    result=result.replace("§", "")
    result=result.replace(" - ", " ")
    result=result.replace(" _ ", " ")
    result=result.replace("&", "")
    result=result.replace("*", "")
    #result=result.replace("(", "") vamos MANTER 
    #result=result.replace(")", "") vamos MANTER 
    result=result.replace("ª", "")
    result=result.replace("º", "")
    result=result.replace("%", "")
    result=result.replace("[", "")
    result=result.replace("]", "")
    result=result.replace("{", "")
    result=result.replace("}", "")
    result=result.replace("'", "")
    result=result.replace('"', "")
    result=result.replace("“", "")
    result=result.replace("”", "")
    result=re.sub(' +', ' ', result)
    
    return(result)

Limpando cada um dos textos:

In [0]:
for i in range(len(X)):
    X[i]=clean(X[i])

Checando o mesmo texto de forma limpa:

In [0]:
X[0]
Out[0]:
'unions representing workers at turner newall say they are disappointed after talks with stricken parent firm federal mogul . '

Abrindo conjunto de Stopwords do pacote NLTK:

In [0]:
stop_words = set(stopwords.words('english')) 

Definindo função para a tokenização dos textos. A função já faz o processo de stemming e utiliza a base de stopwords para fazer a filtragem:

In [0]:
def tokenize(text):
    stemmer = PorterStemmer() # para o port. >>> RSLPStemmer() 
    tokens = nltk.word_tokenize(text)
    stems = []
    for item in tokens:
        if item not in stop_words: 
            stems.append(stemmer.stem(item))
    return stems

Vamos dividir nossa base em bases de treino teste e validação:

In [0]:
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.20, random_state=42)
X_test, X_val, y_test, y_val = train_test_split(X_test, y_test, test_size=0.5, random_state=42)
In [0]:
np.shape(X_train)
Out[0]:
(6080,)

Treinaremos o modelo TFIDF (https://en.wikipedia.org/wiki/Tf%E2%80%93idf) para a extração de atributos dos textos. Na implementação abaixo, você pode ver que escolhemos trabalhar com unigramas e bigramas e isso quer dizer que se um texto da nossa base de dados fosse "Eu como bolo" trabalharíamos com os seguintes termos: "Eu", "como", "bolo", "Eu como", "como bolo". A partir de uma análise da nossa base de treino, o algoritmo dá um escore chamado IDF (Inverse Document Frequency) a cada um dos termos, sendo que termos que aparecem em menos textos têm maior escore - esses escores dados aos termos são ponderadores de quão importantes os termos são. No processo de construção de atributos de cada um dos textos, para cada um dos termos presentes nos textos calculamos a frequência relativa dos termos dentro de cada um dos textos (Term Frequency) e multiplicamos pelo escores 'IDF' de cada um dos termos (aquele calculado anteriormente). Dessa maneira, para cada um dos termos dentro de um texto, teremos potenciais variáveis com valores diferentes de zero (se a palavra não aparece naquele texto, então é automaticamente nula). Como pode-se perceber, teremos um número gigantesco de variáveis e para controlá-lo vamos impor um teto (max_features) de k variáveis. O pacote automaticamente seleciona os k termos com maior frequência em todo o corpus da base de treino. Vamos treinar o modelo TFIDF na base de treino:

In [0]:
#Treinando bag of N-grams (TF-IDF) para a extração
n_feat=10000

tfidf = TfidfVectorizer(tokenizer=tokenize,ngram_range=(1,2), max_features=n_feat) 
tfidf.fit(X_train)

#Salvando modelo
#pickle.dump(tfidf, open(path+"tfidf.sav", 'wb'))

Vamos transformar nossas bases de treino, validação e test, que estão em formato de textos, em dados estruturados:

In [0]:
#Aplicando a transformação aprendida
X_train = tfidf.transform(X_train)
X_val = tfidf.transform(X_val)
X_test = tfidf.transform(X_test)

Afim de aumentar o poder preditivo dos nossos modelos, vamos normalizar (para tudo ficar entre 0 e 1) nossos atributos em nossas bases de dados:

In [0]:
scaler = MaxAbsScaler()
scaler.fit(X_train)

X_train = scaler.transform(X_train)
X_val = scaler.transform(X_val)
X_test = scaler.transform(X_test)

Checando formato de cada um dos arrays:

In [0]:
np.shape(X_train), np.shape(X_test), np.shape(X_val)
Out[0]:
((6080, 10000), (760, 10000), (760, 10000))
In [0]:
np.shape(y_train), np.shape(y_test), np.shape(y_val)
Out[0]:
((6080, 4), (760, 4), (760, 4))

Treinando e testando modelos preditivos

Regressão Logística

Para utilizar o modelo de regressão logística com o pacote Scikit-Learn, temos que transformar as nossas variáveis respostas em uma só da seguinte maneira:

In [0]:
y_train2=np.argmax(y_train,axis=1)
y_val2=np.argmax(y_val,axis=1)
y_test2=np.argmax(y_test,axis=1)

Em 'y_train2', 'y_test2' e 'y_val2' temos o seguinte encoding 'Business' -->0, 'SciTech' -->1, 'Sports' -->2 e 'World' -->3. Abaixo treinaremos um modelo de regressão logística multinomial, avaliando sua acurácia na base de validação:

In [0]:
model = LogisticRegression(multi_class='multinomial', solver='lbfgs', max_iter=100).fit(X_train, y_train2)
logregScore = model.score(X_val, y_val2)
print("Acurácia na base de validação=",logregScore)
Acurácia na base de validação= 0.8671052631578947

Como temos um grande número de atributos, utilizaremos a penalidade do tipo 'L1' para fazer a seleção dos atributos mais importantes para a predição. Tendo que encontrar um bom parâmetro 'C' (hiperparameter tuning), que é o inverso do parâmetro de regularização, vamos aplicar o método de validação com grid-search, comparando diferentes valores de 'C', o número de atributos selecionados e a acurácia do modelo com os atributos selecionados na etapa anterior (ver https://towardsdatascience.com/l1-and-l2-regularization-methods-ce25e7fc831c):

In [0]:
start_time = time.time()
#
cs=[.01,.1,1,10,100]
#
summary=[]

for c in cs:
    
    #seleção de atributos
    logreg = LogisticRegression(multi_class='multinomial', solver='saga', penalty='l1',C=c, max_iter=100).fit(X_train, y_train2)
    select_features = SelectFromModel(logreg, prefit=True)
    
    X_train_sel=select_features.transform(X_train)
    X_test_sel=select_features.transform(X_test)
    X_val_sel=select_features.transform(X_val)

    #fittando o modelo
    model = LogisticRegression(multi_class='multinomial', solver='lbfgs', max_iter=100).fit(X_train_sel, y_train2)
    
    #avaliando acurácia
    logregScore = model.score(X_val_sel, y_val2)
    
    #resumo da validação
    summary.append((c,np.shape(X_train_sel)[1],logregScore))
    
    print(round((time.time() - start_time)/60,2),"minutos \n")
0.01 minutos 

/usr/local/lib/python3.6/dist-packages/sklearn/linear_model/sag.py:337: ConvergenceWarning: The max_iter was reached which means the coef_ did not converge
  "the coef_ did not converge", ConvergenceWarning)
/usr/local/lib/python3.6/dist-packages/sklearn/linear_model/logistic.py:947: ConvergenceWarning: lbfgs failed to converge. Increase the number of iterations.
  "of iterations.", ConvergenceWarning)
0.04 minutos 

/usr/local/lib/python3.6/dist-packages/sklearn/linear_model/sag.py:337: ConvergenceWarning: The max_iter was reached which means the coef_ did not converge
  "the coef_ did not converge", ConvergenceWarning)
/usr/local/lib/python3.6/dist-packages/sklearn/linear_model/logistic.py:947: ConvergenceWarning: lbfgs failed to converge. Increase the number of iterations.
  "of iterations.", ConvergenceWarning)
0.17 minutos 

/usr/local/lib/python3.6/dist-packages/sklearn/linear_model/sag.py:337: ConvergenceWarning: The max_iter was reached which means the coef_ did not converge
  "the coef_ did not converge", ConvergenceWarning)
/usr/local/lib/python3.6/dist-packages/sklearn/linear_model/logistic.py:947: ConvergenceWarning: lbfgs failed to converge. Increase the number of iterations.
  "of iterations.", ConvergenceWarning)
0.85 minutos 

/usr/local/lib/python3.6/dist-packages/sklearn/linear_model/sag.py:337: ConvergenceWarning: The max_iter was reached which means the coef_ did not converge
  "the coef_ did not converge", ConvergenceWarning)
2.72 minutos 

/usr/local/lib/python3.6/dist-packages/sklearn/linear_model/logistic.py:947: ConvergenceWarning: lbfgs failed to converge. Increase the number of iterations.
  "of iterations.", ConvergenceWarning)

Mesmo tendo alguns problemas de convergência, os resultados foram interessantes:

In [0]:
for i in summary:
  print("C=%8.2f --- # Features=%5d --- Acurácia=%3.2f" % i)
C=    0.01 --- # Features=    1 --- Acurácia=0.33
C=    0.10 --- # Features=  159 --- Acurácia=0.77
C=    1.00 --- # Features= 1516 --- Acurácia=0.84
C=   10.00 --- # Features= 6411 --- Acurácia=0.87
C=  100.00 --- # Features= 9752 --- Acurácia=0.87

Vamos escolher C=10 para fazer a seleção de nossos atributos daqui para frente. Fazendo a seleção e treinando modelo:

In [0]:
#Seleção
logreg = LogisticRegression(multi_class='multinomial', solver='saga', penalty='l1',C=10).fit(X_train, y_train2)
select_features = SelectFromModel(logreg, prefit=True)

X_train_sel=select_features.transform(X_train)
X_test_sel=select_features.transform(X_test)
X_val_sel=select_features.transform(X_val)

#Treinando
model = LogisticRegression(multi_class='multinomial', solver='lbfgs').fit(X_train_sel, y_train2)
/usr/local/lib/python3.6/dist-packages/sklearn/linear_model/sag.py:337: ConvergenceWarning: The max_iter was reached which means the coef_ did not converge
  "the coef_ did not converge", ConvergenceWarning)

Avaliando modelo na base de teste. Na matriz de confusão, nas linhas temos as classes previstas (0,1,2,3) enquanto nas colunas temos as verdadeiras classes (0,1,2,3) :

In [0]:
print("Acurácia na base de teste=%3.2f \n" % model.score(X_test_sel, y_test2))

#

y_pred = model.predict(X_test_sel)
print(confusion_matrix(y_pred,y_test2))
Acurácia na base de teste=0.87 

[[149  24   3   9]
 [ 19 161   2   6]
 [  4   2 192   7]
 [  7  11   6 158]]