Meu primeiro grande projeto de Ciência de Dados — Parte 1

Elisa Ribeiro
10 min readNov 27, 2021

--

Spoiler: envolve Processamento de Linguagem Natural (NLP)

Depois de 9 longos cursos e vários pequenos projetos, cheguei à ultima etapa da especialização de Data Science da John Hopkins. O projeto Capstone (ou projeto final) envolvia criação e o “deploy” de um modelo de predição textual, utilizando linguagem R.

O projeto foi extremamente desafiador e demorei um pouco mais de um mês para finalizá-lo, trabalhando por longas horas, quase diariamente.

Nesta publicação, vou mostrar os desafios que encontrei ao longo do caminho, as estratégias que escolhi, os meus aprendizados e como desenvolvi esse projeto. O código e o app estão disponíveis para acesso, caso você tenha interesse.

Pode ser que esse post seja útil para outros alunos da Especialização ou para iniciantes em NLP. Para aqueles que tem mais experiência na área, sintam-se livres para me corrigir, criticar meu projeto e pontuar qualquer informação que julguem importante.

Confesso que, mesmo orgulhosa com o resultado final, ainda gostaria de melhorar a performance do modelo e a estética do aplicativo. Por isso, qualquer opinião é bem vinda.

Mas feito é melhor do que perfeito, então, vamos ao post.

Visão Geral do Projeto

O projeto consistiu em criar um modelo preditivo de texto, semelhante ao que encontramos nos celulares, ao digitar uma mensagem. Inclusive, ao final do projeto, o modelo deveria ser adicionado à um aplicativo capaz de receber um input de texto e retornar uma palavra que, em teoria, seria a mais provável de ser utilizada em seguida.

Os dados para a criação desse modelo foram fornecidos pela empresa Swiftkey e foram extraídos do twitter, de blogs e notícias.

Durante a especialização, não tive nenhuma base em NLP, apenas em Ciência de Dados ‘genérica’, então o projeto foi realmente desafiador. De acordo com os instrutores, o objetivo era que os alunos demonstrassem capacidade para explorar um novo tipo de dado, implementar um modelo útil e criar um aplicativo de forma eficiente, num tempo razoável. Ao longo da especialização inteira eles comentam sobre ‘hacker mentality’, a habilidade de encontrar informações básicas para realizar uma tarefa em tempo mínimo.

Por conta disso, a estratégia recomendada e que de fato escolhi seguir foi: simplify, simplify, simplify (simplificar, simplificar, simplificar)

Um pouco sobre Processamento de Linguagem Natural (NLP)

Não vou trazer aqui os detalhes e a teoria completa de NLP porque, primeiro, fugiria do escopo desse post e, segundo, seria impossível trazer tudo sobre o assunto já que o conteúdo é BEM extenso. No momento, vou apenas dar uma introdução de conceitos que foram essenciais para que eu desenvolvesse esse projeto e para que você, leitora ou leitor, entendam do que estou falando, principalmente se for iniciante.

Disclaimer: AINDA me considero iniciante no assunto de NLP e ter feito o projeto final da Especialização definitivamente NÃO me tornou especialista no assunto. Muito provavelmente cometi erros durante a elaboração desse projeto e por isso adoraria o feedback daqueles que são mais versados nessa área.

Pelo fato de existirem basicamente infinitas formas de combinar palavras e formar sentenças, os dados gerados pela língua escrita e falada são extremamente extensos. Erros de sintaxe, escrita, coloquialismo, abreviação e ambiguidade são comuns no dia a dia das línguas, o que aumenta ainda mais a quantidade de dados gerados.

E pra piorar a situação, estes dados são gerados a todo momento (basicamente sempre que nos comunicamos) e guardam um potencial gigantesco em várias áreas. Usando técnicas de NLP, os computadores podem ‘compreender’ a linguagem humana e processar estes dados, permitindo interação entre humanos e máquinas.

Processamento de Linguagem Natural(NLP), então, é uma subárea de Ciência de Dados que envolve linguística. Com essa união, é possível criar análises de sentimento, aplicativos de tradução, chatbots (como o Eliza) e modelos de predição de texto (como os teclados de celulares e esse projeto que desenvolvi), por exemplo.

Eliza, o primeiro chatbot da história (esse nome é coincidência? Acho que não.)

Um projeto de NLP inicia, em geral, com a criação de um ‘corpus’ (corpora, no plural) que nada mais é que um conjunto de textos possível de ser analisado e ‘lido’ pela máquina. O passo seguinte é a criação e limpeza (que falarei mais para frente) de tokens que são partes do texto separados de acordo com algum parâmetro. Palavras únicas, caracteres ou parte de palavras podem ser consideradas tokens.

Na frase: “A vingança nunca é plena, mata a alma e envenena”
Os tokens seriam:

“A” , “vingança, “nunca”, “é”, “plena”, “,”, “mata”, “a”, “alma, “e”, “envenena.”

Para obter esses tokens, usei o código abaixo. Se quiser entender melhor como funciona, instale o pacote quanteda e copia este código o R:

> tokens(“A vingança nunca é plena, mata a alma e envenena”)
Tokens consisting of 1 document.
text1 :
[1] “A” “vingança” “nunca” “é” “plena” “,” “mata”
[8] “a” “alma” “e” “envenena”

A criação de tokens é essencial em um projeto de NLP, porém eles não necessariamente levam em consideração a ordem das palavras. Por isso, em casos em que a ordem das palavras importa, em seguida se criam os ngrams, o modelo mais básico em NLP. Os ngram são sequências de n tokens, em geral, n palavras. Para a frase anterior, alguns dos possíveis bigrams(2) e trigrams(3), seriam:

“A_vingança”, “vingança_nunca”, “nunca_é”, “é_plena”, “plena_,” […]

E o cógido para obter estes ngrams:

> tokens_ngrams(tokens(“A vingança nunca é plena, mata a alma e envenena”), n=2:3)
Tokens consisting of 1 document.
text1 :
[1] “A_vingança” “vingança_nunca” “nunca_é” “é_plena” “plena_,”
[6] “,_mata” “mata_a” “a_alma” “alma_e” “e_envenena”
[11] “A_vingança_nunca” “vingança_nunca_é”
[ … and 7 more ]

A base de um modelo que prediz a próxima palavra são os ngrams e as suas frequências. O modelo preditivo usa essa frequência e retorna o último termo do ngram como a próxima palavra a ser sugerida (vou explicar melhor mais para frente).

Então a ordem geral em projetos de NLP é:

corpus>tokens>ngrams>cálculo de frequência

Agora que introduzi um pouco sobre o assunto, vamos ao projeto de fato.

Decisões, decisões, decisões.

A primeira parte do projeto, obviamente, era carregar os dados para o R a fim de iniciar a análise. E claro, como todo bom projeto que envolve programação, mal comecei a criar o script e alguns desafios já apareceram, com algumas decisões a serem tomadas:

1. Que função usar para ler os dados .txt?

Ler dados .txt no R é ‘fácil’. Já existem várias funções para ler esse tipo de arquivo, cada uma com um diferencial. Mas para um projeto como esse, usar qualquer função poderia causar problemas. Eu tinha, no momento, algumas funções à disposição: readtext() do pacote readtext, readLines()do pacote base e read_lines()do pacote readr.

Depois de ler a documentação de cada função e testar uma a uma, decidi escolher a read_lines(). Isso porque: a função readLines()não lia o arquivo de twitter completo e a readtext() entregava uma tabela ao invés de um vetor de caracteres o que dificultava o uso do objeto nas funções seguintes de NLP (criação de corpus, tokenization e criação de ngrams)

Além disso, de todas elas, a read_lines() era a função mais rápida, com uma diferença ENORME:

> system.time(function_readtext <- readtext(“./en_US.blogs.txt”))usuário sistema decorrido 
2.75 0.28 3.05
> system.time(function_read_lines <-read_lines(“./en_US.blogs.txt”))usuário sistema decorrido
0.42 0.02 0.08
> system.time(function_readLines <- readLines(“./en_US.blogs.txt”))

usuário sistema decorrido
1.74 0.13 1.86

O tempo de execução da função, num projeto como este, era essencial. Pela quantidade absurda de dados, os passos de limpeza, criação de tokens, ngrams e cálculo de frequência demandariam muito tempo para serem realizados e qualquer economia de tempo (e memória, como vou mostrar mais para frente) era essencial.

2. Ler apenas parte dos dados ou todos eles? Juntar todos os dados em um único objeto ou mantê-los separados?

Escolhendo a função, resolvi ler todos os arquivos, do início ao fim, de uma única vez e uni-los em um único objeto, separando somente uma parte de cada arquivo para criar um dataset de teste.

O objeto final de treino estava, consequentemente, gigantesco (~ 4 milhões de linhas ao todo). Por conta disso, essa decisão não foi a das melhores, mas só fui perceber o problema nos passos seguintes.

3. Como limpar os dados? Aplicar Lemmatization, Stemming e remoção stopwords?

A próxima decisão era como limpar os dados. Em NLP, é possível retirar pontuação, símbolos, preposições (stopwords), palavrões, abreviações etc. É também possível retirar conjugação verbal ou extrair a ‘raiz’ de palavras (lemmatization e stemming). Palavras como ‘am’, ‘are’, ‘is’, nesses casos, seriam todas transformadas em ‘be’ . Palavras como ‘automatic’, ‘automate’ e ‘automation’ seriam transformadas em ‘automat’.

https://www.quora.com/What-is-difference-between-stemming-and-lemmatization

Para mim, stemming, lemmatization e retirada de palavrões e abreviações não fariam sentido num projeto como este. Se eu executasse estes passos, os ngrams resultantes seriam completamente diferentes dos originais, o que criaria falsas sequências de palavras.

Uma alternativa seria retirar completamente as sentenças que se encaixassem nessas condições, mas com isso, praticamente todo os dados seriam deletados, afinal, conjugação verbal está presente em todas as sentenças. Além disso, o dataset de twitter não sairia menos ileso dessa limpeza, por estar cheio de palavrões e abreviações.

Remover stopwords era minha maior dúvida. Por um lado, não removê-las poderia me gerar um algoritmo que retornaria somente stopwords (um problema encontrado por outros alunos do curso) já que elas são as mais frequentes. Por outro lado removê-las faria pouco sentido, já que normalmente usamos, e muito, stopwords em nossa escrita.

Então, depois de ir e voltar inúmeras vezes nas análises, fazendo estes passos de limpeza em diferentes combinações e pesquisando sobre NLP, decidi não retirar stopwords, abreviações e palavrões e não realizar lemmatization e stemming. De certa forma, não realizar estes passos me deixou com uma quantidade maior de dados, mas felizmente, consegui lidar com eles ocasionalmente.

“Cannot allocate vector of size X”

Como comentei antes, a primeira decisão que tomei foi ler todos os aquivos e uni-los em um único objeto para iniciar o processamento. A partir desse momento, encontrei uma das dificuldades mais comuns quando se lida com uma grande quantidade de dados: falta de memória para o processamento.

O erro que mais apareceu durante esse projeto inteiro foi “Cannot allocate vector of size [… ]”. A causa desse erro pode ser o tamanho do objeto (que excedeu o espaço concedido) ou incapacidade do sistema operacional de fornecer a memória necessária.

No primeiro caso, o uso da função memory.limit() pode ser uma solução, mas para mim, por conta do tamanho dos objetos que eu estava processando, não havia memória RAM suficiente, nem se eu quisesse. A partir daí, percebi que deveria recomeçar do início e mudar a estratégia.

Em projetos de NLP, um dos maiores obstáculos é lidar com o aumento quase exponencial do número de features. Esse problema é chamado de ‘maldição da dimensionalidade’ (em inglês, ‘’curse of dimentionality’).

Lembrando da frase “A vingança nunca é plena, mata a alma e envenena”, se compararmos a quantidade de dados gerados ao longo daquela pequena demonstração, passamos de 1 linha para 11 tokens e, por fim, para 19 ngrams.

Quando decidi ler todos os arquivos de uma vez e uni-los em um único objeto, a quantidade de dados gerada a cada passo era tão grande e foi aumentando de forma tão acentuada, que sobrecarregava a memória RAM do meu computador. Checando o Gerenciador de Tarefas (Ctrl+Alt+Del, se você quiser saber o que é isso), a coluna “Memory”, relativa á memoria RAM, chegava por volta dos 95%!

JUSTO o meu computador, que foi montado por mim, escolhendo uma boa placa de vídeo, um processador com 6 cores e memória RAM de 16Gb. Ainda assim, processando os dados do projeto, eu sobrecarregava a memória constantemente.

Por conta disso, tive que repensar a minha estratégia. Pesquisando um pouco sobre alternativas e principalmente olhando no fórum do curso, uma das recomendações era carregar cada arquivo de pouco em pouco no R.

Resolvi, então, continuar com a função que estava usando antes, mas colocá-la dentro de um loop. A ideia era, de fato, ler partes de cada arquivo por vez (1/5, para ser mais exata) e salvar cada parte em um objeto diferente.

Em seguida, cada parte seria processada e geraria sua própria tabela de frequência para que, no final, tudo fosse unido em uma única grande tabela. Com os dados muito mais ‘leves’, seria mais fácil uni-los para formar uma “base de dados” com frases e suas possíveis últimas palavras.

SaveRDS() > rm() > gc()

Quando comecei a colocar em prática essa estratégia, os três comandos que mais usei ao longo do projeto foram saveRDS(), rm() e gc(). Se você trabalha com R, estes 3 comandos podem ser muito úteis caso você encontre problemas de processamento e memória durante alguma análise. No meu caso, por mais que eu separasse cada grande arquivo em pequenos pedaços e processasse um a um, ainda era necessária uma grande quantidade de memória.

Por isso, em pontos estratégicos da análise, eu salvava os objetos gerados usando saveRDS(), deletava objetos desnecessários usando rm() e retornava a memória para o sistema operacional usando gc()(garbage collector). Depois de salvá-los, caso encontrasse o erro de memória novamente, reiniciava o R e retomava a análise do ponto anterior, carregando o objeto salvo usando readRDS().

E assim continuei, passo a passo, até chegar na tabela final, o coração do aplicativo e de todo o projeto Capstone. Mas vou falar sobre a construção da tabela e do modelo, somente no próximo post porque este já está extenso demais.

--

--