Usando Machine Learning Para Limpeza de Dados

Recentemente eu tenho estudado Processamento de Linguagem Natural (NLP) mais do que outras áreas de ciência de dados, e um dos desafios que aparecem frequentemente é a parte de limpeza dos dados de texto. A criação de um modelo de NLP abrange muitas etapas de pré-processamento, e se os dados não estiverem apropriadamente tratados, podemos prejudicar a precisão do modelo final, o que é justamente o que tentamos evitar.

Nesse artigo, vamos focar em documentos PDF. O objetivo é abrir um arquivo PDF, converter para texto (em formato string), entender a necessidade da limpeza de dados e construir um modelo de machine learning para esse propósito.

Neste artigo vamos:

  • Abrir um arquivo PDF e converter para string
  • Dividir esse texto em frases separadas
  • Classificar esses dados usando interação com o usuário
  • Criar um modelo para remover as frases indesejadas

Algumas bibliotecas que usaremos:

  • pdfminer → ler arquivos PDF
  • textblob → processamento de texto
  • pandas → análise de dados

Leitor de PDF

Durante o artigo, tentarei explicar o código utilizado no próprio texto, então fique a vontade para pular as células em Python. Vamos começar importando alguns módulos:

from collections import Counter
from IPython.display import clear_output
from pdfminer.converter import TextConverter
from pdfminer.layout import LAParams
from pdfminer.pdfinterp import PDFResourceManager
from pdfminer.pdfinterp import PDFPageInterpreter
from pdfminer.pdfpage import PDFPage
from textblob import TextBlob
import io
import math
import numpy as np
import pandas as pd
import string

Usaremos a biblioteca pdfminer para fazer o leitor de PDF:

def read_pdf(path):
rsrcmgr = PDFResourceManager()
retstr = io.StringIO()
codec = 'utf-8'
laparams = LAParams()
device = TextConverter(rsrcmgr, retstr, codec=codec, laparams=laparams)
fp = open(path, 'rb')
interpreter = PDFPageInterpreter(rsrcmgr, device)
password = ""
maxpages = 0
caching = True
pagenos=set()
for page in PDFPage.get_pages(fp, pagenos, maxpages=maxpages, password=password, caching=caching, check_extractable=True):
interpreter.process_page(page)
text = retstr.getvalue()
text = " ".join(text.replace(u"\xa0", " ").strip().split())
fp.close()
device.close()
retstr.close()
return text

Apesar dessa função parecer longa, ela apenas abre um arquivo PDF e retorna seu texto em formato string. Vamos usá-la em um artigo chamado “A Hands-on Guide to Google Data”:

Só de olhar para a primeira página já vemos que um documento assim contém muito mais que apenas frases, incluindo elementos como datas, contagem de linhas, números de páginas, títulos e subtítulos, separadores, equações, etc. Vamos ver como essas propriedades se comportam quando convertemos o artigo para texto simples (o nome do artigo no meu computador é primer.pdf):

read_pdf('primer.pdf')

Fica claro aqui que perdemos praticamente toda a estrutura do texto. Os números de linhas e páginas estão jogados nas frases como se fossem parte delas, enquanto os títulos e referências mal podem ser distinguidos do corpo do texto. Com certeza existem várias formas de conservar a estrutura de um texto ao fazer esse tipo de conversão, mas vamos manter o texto desorganizado para fins da explicação (porque é assim que muitas vezes encontramos os dados em problemas reais).

Limpeza de Texto

O processo de limpeza de texto é composto por várias etapas, e, para se tornar mais familiar com elas, eu recomendo ler alguns tutoriais na área de NLP (esse e esse são um bom ponto de partida). Em linhas gerais, este processo inclui:

  • Tokenização
  • Normalização
  • Extração de Entidades
  • Correção Ortográfica e Gramatical
  • Remoção de Pontuação
  • Remoção de Caracteres Especiais
  • Stemização e Lematização

Aqui, nosso objetivo não é substituir nenhuma dessas etapas, mas sim criar uma ferramenta mais generalizada para excluir o que é indesejado, como uma ferramenta complementar para auxiliar no meio do processo.

Vamos supor que queremos remover qualquer frase que não se pareça com uma frase escrita por um ser humano. A idéia é classificar essas frases como indesejadas ou estranhas e considerar as outras frases como normais. Por exemplo:

32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 related.

Ou

51 52 53 54 55 # read data from correlate and make it a zoo time series dat <- read.csv(“Data/econ-HSN1FNSA.csv”) y <- zoo(dat[,2],as.

Essas frases estão claramente modificadas devido à transformação do texto, e no caso de fazermos, por exemplo, um aplicativo de resumo automático de textos, elas deveriam ser excluídas.

Para removê-las, uma alternativa seria analisá-las manualmente, encontrar padrões e aplicar Expressões Regulares (Regex) que definiriam o que são frases normais e estranhas. Porém, em alguns casos, pode ser melhor treinar um modelo que encontre esses padrões automaticamente. Isso é o que faremos aqui. Vamos criar uma classificador para reconhecer frases “estranhas” para que possamos facilmente removê-las do corpo do texto.

Construindo o Data Set

Para começar, faremos uma função que abre o arquivo PDF, divide o texto em frases e as salva em um data frame com as colunas label e sentence:

def pdf_to_df(path):
content = read_pdf(path)
blob = TextBlob(content)
sentences = blob.sentences
df = pd.DataFrame({'sentence': sentences, 'label': np.nan})
df['sentence'] = df.sentence.apply(''.join)
return df
df = pdf_to_df('primer.pdf')
df.head()

Já que não temos os dados previamente classificados (em “estranho” ou “normal”), preencheremos nossa coluna label manualmente. Esse data set será atualizável para que possamos anexar novos documentos a ele e classificar suas frases.

Vamos salvar o dados (ainda não classificados) em um arquivo .pickle:

df.to_pickle('weird_sentences.pickle')

Agora criamos uma interação com usuário para classificar manualmente os pontos de dados. Para cada frase no data set, vamos apresentar uma caixa de texto para o usuário escrever ‘1’ ou nada. Se o usuário escrever ‘1’, a frase será classificada como “estranha”.

Estou usando um Jupyter Notebook, então chamei a função clear_output()do módulo IPython.display para melhorar a interação.

def manually_label(pickle_file):
print('Is this sentence weird? Type 1 if yes. \n')
df = pd.read_pickle(pickle_file)
for index, row in df.iterrows():
if pd.isnull(row.label):
print(row.sentence)
label = input()
if label == '1':
df.loc[index, 'label'] = 1
if label == '':
df.loc[index, 'label'] = 0
clear_output()
df.to_pickle('weird_sentences.pickle')

print('No more labels to classify!')
manually_label('weird_sentences.pickle')

Esse é o output para cada frase:

Já que essa frase parece bem normal, eu não digito ‘1’, mas apenas aperto enter e passar para a próxima frase. Esse processo se repetirá para todas as frases no data set, ou até o usuário interromper. Todo input está sendo salvo no arquivo .pickle para que os dados sejam atualizados a cada frase. Esta interação com o usuário fez com que classificar os dados ficasse relativamente fácil. Eu precisei de 20 minutos para ter cerca de 500 frases classificadas.

Duas outras funções foram escritas para manter as coisas simples. Uma para anexar outro arquivo PDF ao data set, e outra para limpar as classificações (define a coluna label igual a np.nan).

def append_pdf(pdf_path, df_pickle):
new_data = pdf_to_df(pdf_path)
df = pd.read_pickle(df_pickle)
df = df.append(new_data)
df = df.reset_index(drop=True)
df.to_pickle(df_pickle)
def reset_labels(df_pickle):
df = pd.read_pickle(df_pickle)
df['label'] = np.nan
df.to_pickle(df_pickle)

Como nós ficamos com mais frases “normais” do que “estranhas”, eu fiz uma função para balancear os dados (undersampling), do contrário, alguns algoritmos de machine learning não apresentariam bons resultados:

def undersample(df, target_col, r=1):
falses = df[target_col].value_counts()[0]
trues = df[target_col].value_counts()[1]
relation = float(trues)/float(falses)
if trues >= r*falses:
df_drop = df[df[target_col] == True]
drop_size = int(math.fabs(int((relation - r) * (falses))))
else:
df_drop = df[df[target_col] == False]
drop_size = int(math.fabs(int((r-relation) * (falses))))
df_drop = df_drop.sample(drop_size)
df = df.drop(labels=df_drop.index, axis=0)
return df
df = pd.read_pickle('weird_sentences.pickle').dropna()
df = undersample(df, 'label')
df.label.value_counts()

645 frases classificadas. Não é o suficiente para um modelo decente, mas usaremos elas como exemplo.

Transformação de Texto

A seguir, o que precisamos fazer é transformar o texto para uma forma que os algoritmos possam compreender. Uma maneira de fazer isso é contar a ocorrência dos caracteres dentro de cada frase. Seria como uma abordagem de bag-of-words, mas no nível de caracteres.

def bag_of_chars(df, text_col):
chars = []
df['char_list'] = df[text_col].apply(list)
df['char_counts'] = df.char_list.apply(Counter)
for index, row in df.iterrows():
for c in row.char_counts:
df.loc[index, c] = row.char_counts[c]
chars = list(set(chars))
df = df.fillna(0).drop(['sentence', 'char_list', 'char_counts'], 1)
return df
data = bag_of_chars(df, 'sentence')
data.head()

Modelo de Machine Learning

Ótimo! Agora temos nada mais que um problema supervisionado de machine learning. Muitas variáveis X e uma dependente y a ser determinada por um algoritmo de classificação. Vamos dividir o data set em train e test sets:

data = data.sample(len(data)).reset_index(drop=True)
train_data = data.iloc[:400]
test_data = data.iloc[400:]
x_train = train_data.drop('label', 1)
y_train = train_data['label']
x_test = test_data.drop('label', 1)
y_test = test_data['label']

Tudo pronto para escolher um algoritmo e avaliar sua performance. Aqui decidi utilizar uma Regressão Logística para ver onde chegamos:

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
lr = LogisticRegression()
lr.fit(x_train, y_train)
accuracy_score(y_test, lr.predict(x_test))

86 % de acurácia. Muito bom para um data set mínimo, um modelo superficial e abordagem de bag-of-chars. O único problema é que, mesmo dividindo os dados em train e test sets, estamos avaliando o modelo no mesmo documento que utilizamos para treinar. A forma mais apropriada de avaliar este modelo seria usando um novo documento PDF para servir como test set.

Vamos criar uma função que permita prever a classe de qualquer frase:

def predict_sentence(sentence):
sample_test = pd.DataFrame({'label': np.nan, 'sentence': sentence}, [0])
for col in x_train.columns:
sample_test[str(col)] = 0
sample_test = bag_of_chars(sample_test, 'sentence')
sample_test = sample_test.drop('label', 1)
pred = lr.predict(sample_test)[0]
if pred == 1:
return 'WEIRD'
else:
return 'NORMAL'
weird_sentence = 'jdaij oadao //// fiajoaa32 32 5555'

Frase normal:

We just built a cool Machine Learning model
normal_sentence = 'We just built a cool machine learning model'
predict_sentence(normal_sentence)

Frase estranha:

jdaij oadao //// fiajoaa32 32 5555
weird_sentence = 'jdaij oadao //// fiajoaa32 32 5555'
predict_sentence(weird_sentence)

E o modelo acertou! Infelizmente, quando experimentando com outras frases, percebe-se má performance ao classificar várias delas. A abordagem de bag-of-chars não é a melhor opção aqui, o algoritmo em si poderia ser bem mais sofisticado e teríamos que preencher manualmente um volume muito maior de dados para ter confiabilidade. O ponto é que podemos usar esta mesma técnica para diferentes tarefas, por exemplo no reconhecimento de elementos específicos (como links, datas, nomes, tópicos e referências, entre tantos outros). Utilizada da forma correta, a classificação de texto pode ser uma ferramenta poderosa para ajudar no processo de limpeza de dados, e não deve ser subestimada. Boa limpeza!


Esse foi um artigo sobre classificação de texto para resolver problemas de limpeza de dados. Siga meu perfil para mais informações sobre ciência de dados, e se gostou, não se esqueça de aplaudir!