Texto no R
O pacote stringr
Webscrapping para capturar material para o tutorial
Nossa primeira tarefa será obter um conjunto de textos com o qual trabalharemos. Classicamente, tutoriais de R sobre strings e mineração de texto utilizam “corpus” (já veremos o que é isso) de literatura clássica.
Para tornar nosso exemplo mais interessante, vamos utilizar discursos na Câmara dos Deputados. Em particular, vamos raspar todos os discursos da Deputada Luiza Erundina no site da Câmara dos Deputados. Vamos começar carregando os pacotes rvest e stringr:
library(rvest)
library(stringr)
A seguir, vamos salvar em um objeto a página que contém uma tabela com os links para os discursos. Note que quando fazemos pesquisa de discurso na Câmara dos Deputados obtemos apenas 20 discursos por página. Alterando o argumento “Pagesize” no url consegui obter todos (480) os links em uma página única.
url_tabela_discursos <- "http://www.camara.leg.br/internet/sitaqweb/DiscursosDeputado.asp?txOrador=LUIZA+ERUNDINA&Campoordenacao=dtSessao&tipoordenacao=DESC&Pagesize=1000&txUF=SP"
Vamos capturar os links de cada discurso. Examine a url antes de prosseguir para aprender um pouco mais de webscrapping.
url_discursos <- url_tabela_discursos %>%
read_html() %>%
html_nodes(xpath = "//table[@class ='tabela-padrao-bootstrap table-bordered']//td/a") %>%
html_attr(name = "href")
O resultado é um vetor com o conteúdo dos atributos “href”. Precisamos adicionar o início da url para indicar que estamos navegando no servidor da Câmara e retirar os espaços vazios do url, que não são um problema para um browser com o Firefox, mas é um problema para o R.
Aproveitemos para ver duas funções novas, ambas do pacote stringr. Várias delas, como veremos, são semelhantes a funções de outros pacotes com as quais já trabalhamos. Há, porém, algumas vantagens ao utilizá-las: bugs e comportamentos inesperados corrigidos, uso do operador “pipe”, nomes intuitivos e sequência de argumentos intuitivos.
str_c (aka string concatenar) é uma função semelhante a paste0 e serve para concatenar dois pedaços de texto, inclusive quando a operação for entre um texto e um vetor e entre vetores.
str_replace_all, por sua vez, substitui no texto um padrão por outro, respectivamente na sequência de argumentos. Seu uso é semelhante à função gsub, mas os argumentos estão em ordem intuitiva. Por exemplo, estamos substituindo espaço por nada nos url:
url_discursos <- str_c("http://www.camara.leg.br/internet/sitaqweb/", url_discursos)
url_discursos <- str_replace_all(url_discursos, " ", "")
Vamos agora passar por todos os urls e obter os discursos. Examine a primeira url do vetor antes de prosseguir para aprender um pouco mais de webscrapping. Gravaremos os discursos em um objeto chamado “discursos”, e cada posição conterá um discurso.
discursos <- c()
for (url_discurso in url_discursos) {
discurso <- url_discurso %>%
read_html() %>%
html_nodes(xpath = "//div[@id = 'content']//p") %>%
html_text()
discursos <- c(discursos, discurso)
Sys.sleep(0.5)
}
Funcionalidades do stringr
Qual é o tamanho de cada discurso? Vamos aplicar str_length para descobrir. Seu uso é semelhante ao da função nchar:
len_discursos <- str_length(discursos)
len_discursos
Vamos agora observar quais são os discursos nos quais a deputada menciona “Constituição”. Para tanto, usamos str_detect
str_detect(discursos, "Constituição")
Poderíamos usar o vetor lógico resultante para gerar um subcojunto dos discursos, apenas com aqueles nos quais a palavra “Constituição” é mencionada. Mais simples, porém, é utilizara função str_subset, que funciona tal qual str_detect, mas resulta num subconjunto em lugar de um vetor lógico:
discursos_constituicao <- str_subset(discursos, "Constituição")
Se quisessemos apenas a posição no vetor dos discursos que contêm “Constituição”, str_which faria o trabalho:
str_which(discursos, "Constituição")
Voltando ao vetor completo, quantas vezes “Constituição” é mencionada em cada discursos? Qual é o máximo de menções a “Constituição” em um único discurso?
str_count(discursos, "Constituição")
max(str_count(discursos, "Constituição"))
Vamos fazer uma substituição nos discursos. No lugar de “Constituição” colocaremos a expressão “Constituição, aquele pedaço de papel que não vale nada,”. Podemos fazer a substituição com str_replace ou com str_replace_all. A diferença entre ambas é que str_replace substitui apenas a primeira ocorrênca encontrada, enquanto str_replace_all substitui todas as ocorrências.
str_replace(discursos_constituicao, "Constituição", "Constituição, aquele pedaço de papel que não vale nada,")
str_replace_all(discursos_constituicao, "Constituição", "Constituição, aquele pedaço de papel que não vale nada,")
Em vez de substituir, queremos conhecer a posição das ocorrências de “Constituição”. Com str_locate e str_locate_all, respectivamente para a primeira ocorrência e todas as ocorrências, obtemos a posição de começo e fim do padrão buscado:
str_locate(discursos_constituicao, "Constituição")
str_locate_all(discursos_constituicao, "Constituição")
Finalmente, notemos que os discursos começam sempre mais ou menos da mesma forma. Vamos retirar os 100 primeiros caracteres de cada discurso para observá-los. Usamos a função str_sub, semelhante à função substr, para extrair um padaço de uma string:
str_sub(discursos, 1, 100)
As posições para extração de exerto podem ser variáveis. Por exemplo, vamos usar “len_discursos” que criamos acima para extrair os 50 últimos caracteres de cada discurso:
str_sub(discursos, (len_discursos - 50), len_discursos)
Note que alguns discursos começam e terminam com espaços. Para nos livrarmos deles (apenas daqueles no começo e fim da string), utilizamos str_trim:
str_trim(discursos)
Infelizmente, não há tempo suficiente para entrarmos neste tutorial em um tema extremamante útil: expressões regulares. Expressões regulares, como podemos deduzir pelo nome, são expressões que nos permite localizar – e, portanto, substituir, extrair, parear, etc – sequências de caracteres com determinadas caraterísticas - por exemplo, “quaisquer caracteres entre parênteses”, ou “qualquer sequência entre espaços que comece com 3 letras e termine com 4 números” (placa de automóvel).
Você pode ler um pouco sobre expressões regulares no R aqui se tiver tempo em sala de aula. Com o uso de expressões regulares, outros dois pares de funções são bastante úteis str_extract, str_extract_all, str_match e str_match_all.
Nuvem de Palavras
Com a função wordcloud do pacote de mesmo nome, podemos rapidamente visualizar as palavras discursadas tendo o tamanho como função da frequência (vamos limitar a 50 palavras):
library(wordcloud)
wordcloud(discursos, max.words = 50)
Não muito bonita. Voltaremos a fazer nuvem de palavras depois de aprendermos outra maneiras de trabalharmos com texto como dado no R.
Corpus e o pacote tm
O pacote mais popular para trabalharmos com texto no R se chama tm (“Text Mining”). Vamos carregá-lo e passar por algumas funções do pacote para, então, trabalharmos com uma nova classe de objeto: Corpus.
Carregue o pacote.
library(tm)
Uma boa prática ao trabalharmos com texto é transformarmos todas as palavras em minúsculas (exceto, obviamente, quando a diferenciação importar). tolower, função da biblioteca básica do R, cumpre a tarefa e vamos criar um objeto “discursos2”, que será nossa versão modificada dos discursos.
discursos2 <- tolower(discursos)
discursos2[1]
Pontuação também costuma ser um problema ao trabalharmos com texto. A não ser que nos interesse recortar o texto usando os pontos como marcas, convém aplicarmos a função removePunctuation do pacote tm para retirar a pontuação:
discursos2 <- removePunctuation(discursos2)
discursos2[1]
O mesmo ocorre com números. Se não forem de interesse específico, melhor extraí-los. A função removeNumbers resolve o problema:
discursos2 <- removeNumbers(discursos2)
discursos2[1]
Vamos olhar novamente para a nuvem de palavras, usando agora o nosso objeto de texto transformado:
wordcloud(discursos2, max.words = 50)
Note que as palavras com mais frequência são aquelas de maior ocorrência na língua portuguese. Qual é a utilidade de incluí-las na análise se sabemos que são frequentes?
O pacote tm oferece a função stopwords. Essa função gera um vetor com as palavras mais frequentes da língua indicada:
stopwords("pt")
Com a função removeWords podemos excluir as “stopwords” da língua portuguesa de nosso conjunto de textos:
discursos2 <- removeWords(discursos2, stopwords("pt"))
discursos2[1]
Vamos aproveitar que já fizemos inúmeras remoções – pontuação, números e stopwords – e retirar os espaços excedentes que sobraram no texto:
discursos2 <- stripWhitespace(discursos2)
discursos2[1]
E vamos repetir nossa nuvem de palavras:
wordcloud(discursos2, max.words = 50)
Muito mais interessante, não?
Note, porém, o destaque a “presidente”. A deputada faz referências ao presidente da Câmara em praticamente todos os seus discursos e isso aumenta demais a frequência desta palavra. O mesmo ocorre com “luiza” e “erundina”, já que todas as vezes em que inicia uma fala, seu nome é transcrito.
Podemos, então, incrementar a lista de stopwords com padrões que conhecemos:
stopwords_pt <- c(stopwords("pt"), "presidente", "é", "sr", "sra", "luiza",
"erundina", "oradora", "revisão", "sp", "v.exa")
E gerar um novo objeto removendo as novas stopwords:
discursos3 <- removeWords(discursos2, stopwords_pt)
wordcloud(discursos3, max.words = 50)
Com uma imagem, podemos ter alguma ideia dos temas e termos recorrentes da deputada.
Uma funcionalidade do pacote tm não muito bem implementada em português é a “stemização de palavras”. “Word Stem”, em linguística, significa extrair de um conjunto de palavras apenas a raiz da palavra ou o denominador comum de várias palavras. Por exemplo, “discurso”, “discursivo”, “discursar” e “discussão”, “stemizadas”, deveriam se tornar “discus”, e poderíamos agrupá-las para fins analíticos. Vamos ver um exemplo em inglês:
stemDocument(c("politics", "political", "politically"), language = "english")
Vamos ver o resultado da função stemDocument no primeiro discurso:
discursos4 <- stemDocument(discursos2, language = "portuguese")
discursos4[1]
Hummmm… meio estranho, não? Mas você pegou o espírito. Vamos seguir em frente
Tokenização
Tokenização de um texto significa a separação em pequenos “tokens”, que podem ser palavras ou n-grams, que são pequenos conjuntos de palavras. Bigrams, por exemplo, são pares de palavras. Voltaremos a esse tópico adiante e com mais cuidado. Mas vamos aproveitar o objeto tal como está para apresentarmos uma função do pacote stringr que deixamos propositalmente para trás: str_split. Como as palavras estão separadas por espaço, no resultado final será uma lista contendo um vetor de tokens para cada discurso:
tokens <- str_split(discursos2, " ")
unlist transforma a lista em um vetor único:
unlist(tokens)
Corpus
Corpus, em linguística, é um conjunto de textos, normalmente grande e em formato digital. Um corpus é composto pelo conteúdo dos textos e pelos metadados de cada texto. Na linguagem R, Corpus é também uma classe de objetos do pacote tm e à qual podemos aplicar uma série de funções e transformações.
Vamos ver como criar um Corpus.
Em primeiro lugar, é preciso uma fonte. A fonte pode ser um vetor, um data frame ou um diretório. Vejamos os dois primeiros, começando com o vetor com o qual já estamos trabalhando:
discursos_source <- VectorSource(discursos)
“discursos_source” é um objeto que apenas indica uma fonte de textos para funções do pacote tm. Para criar um Corpus, utilizados a função VCorpus (volatile corpus, com o qual vamos trabalhar, e que armazena os dados na memória) ou PCorpus (permanent corpus, usada para quando os dados estão em uma base externa ao R).
discursos_corpus <- VCorpus(discursos_source)
Vamos observar o objeto “discursos_corpus” e sua classe:
discursos_corpus
class(discursos_corpus)
Veja que um VCorpus contém “Metadata” e “Content”. Neste caso, não temos nenhum metadata sobre os discursos, mas poderíamos criar. Vamos observar o que há na primeira posição de um VCorpus. (hey, note que um VCorpus é uma lista!)
str(discursos_corpus[[1]])
Em metadata temos diversas variáveis: author, description, id, language, etc. Veja que id está preenchido com a ordem dos discursos e a língua está em inglês, por default. Neste exercícios temos mais controle sobre os metadados, pois capturamos os textos de uma fonte específica, mas seria legal armazenar os metadados de um Corpus para compartilhá-lo ou trabalhar com Corpora (plural de Corpus) mais complexos.
Aliás, metadados são a única boa razão para trabalharmos com Corpus e não com vetores. Guardar informações sobre os textos é fundamental para selecionarmos subconjuntos e produzirmos análise.
Vamos reabrir os dados usando um data frame como fonte. Vamos criar um:
discursos_df <- data.frame(id_discurso = 1:length(discursos),
text = discursos,
stringsAsFactors = F)
str(discursos_df)
E repetir o processo, com a diferença que utilizamos DataframeSource para indicar a fonte dos dados:
discursos_df_source <- DataframeSource(discursos_df[,2])
discursos_df_corpus <- VCorpus(discursos_df_source)
Mesma coisa, não?
Ao trabalharmos com Corpus, não aplicamos diretamente as funções do pacote tm. Em vez disso, utilizamos a função tm_map, que aplica uma outra função a todos os elementos do Corpus. Esse uso lembra as funções do pacote purrr e da família apply, caso você tenha lido sobre elas no livro R for Data Science ou alhures. Observe a remoção de pontuação com removePunctuation:
discursos_corpus <- tm_map(discursos_corpus, removePunctuation)
A aplicação de qualquer função do pacote tm segue este procedimento. Quando a função não pertence ao pacote tm, porém, precisamos “passá-la” dentro da função content_transformer:
discursos_corpus <- tm_map(discursos_corpus, content_transformer(tolower))
Se você criar uma função para alteração de um texto, você deve utilizar content_transformer também.
Mais dois exemplos, com removeNumbers e removeWords:
discursos_corpus <- tm_map(discursos_corpus, removeNumbers)
discursos_corpus <- tm_map(discursos_corpus, removeWords, stopwords("pt"))
Porque tanto trabalho? Para trabalharmos com Corpus, que tem a vantagem de armazenar os metadados, em vez de um vetor.
Para poupar seu trabalho, você pode “envelopar” todas as transformações que quiser produzir em um Corpus em uma função:
limpa_corpus <- function(corpus){
corpus <- tm_map(corpus, removePunctuation)
corpus <- tm_map(corpus, content_transformer(tolower))
corpus <- tm_map(corpus, removeNumbers)
corpus <- tm_map(corpus, removeWords, stopwords("pt"))
corpus
}
E aplicar a função aos Corpora com os quais estiver trabalhando:
discursos_corpus <- limpa_corpus(discursos_corpus)
Matriz Documento-Termo
O principal uso do pacote tm é gerar uma matriz de documentos-termos (“dtm”). Basicamente, essa matriz tem cada documento na linha e cada termo na coluna. O conteúdo da célula é a frequência do termo em cada documento.
dtm_discursos <- DocumentTermMatrix(discursos_corpus)
Veja um fragmento da “dtm” que criamos (documentos 101 a 105 e termos 996 a 1000):
as.matrix(dtm_discursos[101:105, 996:1000])
Se quisermos rapidamente olhar os termos com frequência maior do que, digamos, 500:
findFreqTerms(dtm_discursos, 500)
Há uma série de usos para a classe Corpus do pacote tm para mineração de texto. Não vamos explorá-los e você pode buscar sozinh@. Vamos adotar agora uma abordagem que não envolve a criação de um Corpus.
Uma abordagem “tidy” para texto
Corpora são os objetos clássicos para processamento de linguagem natural. No R, porém, há uma tendência a deixar tudo “tidy”. Vamos ver uma abordagem “tidy”, ou seja, com data frames no padrão do tidyverse, para texto.
Vamos fazer uma rápida introdução, mas recomendo fortemente a leitura do livro Text Mininig with R, disponível o formato “bookdown”.
Comecemos carregando os seguintes pacotes:
library(tidytext)
library(dplyr)
library(ggplot2)
library(tidyr)
Vamos recriar o data frame com os discursos:
discursos_df <- data_frame(id_discurso = 1:length(discursos),
text = discursos)
glimpse(discursos_df)
Tokens
A primeira função interessante do pacote tidytext é justamente a tokenização de um texto:
discursos_token <- discursos_df %>%
unnest_tokens(word, text)
glimpse(discursos_token)
Note que a variável _id_discurso, criada por nós, é mantida. “text”, porém, se torna “words”, na exata sequência do texto. Veja que o formato de um “tidytext” é completamnte diferente de um Corpus.
Como excluir stopwords nessa abordagem? Precisamos de um data frame com stopwords. Vamos recriar um vetor stopwords_pt, que é a versão ampliada das stopwords disponíveis no R, e criar um data frame com tal vetor:
stopwords_pt <- c(stopwords("pt"), "presidente", "é", "sr", "sra", "luiza",
"erundina", "oradora", "revisão", "sp", "v.exa")
stopwords_pt_df <- data.frame(word = stopwords_pt)
Com anti_join (lembra dessa função?) mantemos em “discursos_token” apenas as palavras que não estao em “stopwords_pt_df”
discursos_token <- discursos_token %>%
anti_join(stopwords_pt_df, by = "word")
Para observarmos a frequência de palavras nos discursos, usamos count, do pacote dplyr:
discursos_token %>%
count(word, sort = TRUE)
Com ggplot, podemos construir um gráfico de barras dos temos mais frequêntes, por exemplo, com frequência maior do que 500. Neste ponto do curso, nada do que estamos fazendo abaixo deve ser novo a você:
discursos_token %>%
count(word, sort = TRUE) %>%
filter(n > 500) %>%
mutate(word = reorder(word, n)) %>%
ggplot(aes(word, n)) +
geom_col() +
xlab(NULL) +
coord_flip()
Incorporando a função wordcloud a nossa análise:
discursos_token %>%
count(word, sort = TRUE) %>%
with(wordcloud(word, n, max.words = 50))
A abordagem “tidy” para texto nos mantém no território confortável da manipulação de data frames e, particularmente, me parece mais atrativa do que a abordagem via Corpus para um conjunto grande de casos.
Bigrams
Já produzimos duas vezes a tokenização do texto, sem, no entanto, refletir sobre esse procedimento. Tokens são precisam ser formados por palavras únicas. Se o objetivo for, por exemplo, observar a ocorrência conjunta de termos, convém trabalharmos com bigrams (tokens de 2 palavras) ou ngrams (tokens de n palavras). Vejamos como:
discurso_bigrams <- discursos_df %>%
unnest_tokens(bigram, text, token = "ngrams", n = 2)
Note que, ao tokenizar o texto, automaticamente foram excluídas as as pontuações e as palavras foram alteradas para minúscula (use o argumento “to_lower = FALSE” caso não queira a conversão). Vamos contar os bigrams:
discurso_bigrams %>%
count(bigram, sort = TRUE)
Como, porém, excluir as stopwords quando elas ocorrem em bigrams? Em primeiro, temos que separar os bigrams e duas palavras, uma em cada coluna:
bigrams_separated <- discurso_bigrams %>%
separate(bigram, c("word1", "word2"), sep = " ")
E, a seguir, filter o data frame excluindo as stopwords (note que aproveitamos o vetor “stopwords_pt”):
bigrams_filtered <- bigrams_separated %>%
filter(!word1 %in% stopwords_pt) %>%
filter(!word2 %in% stopwords_pt)
ou, usando anti_join, como anteriormente:
bigrams_filtered <- bigrams_separated %>%
anti_join(stopwords_pt_df, by = c("word1" = "word")) %>%
anti_join(stopwords_pt_df, by = c("word2" = "word"))
Produzindo a frequência de bigrams:
bigram_counts <- bigrams_filtered %>%
count(word1, word2, sort = TRUE)
Reunindo as palavras do bigram que foram separadas para excluirmos as stopwords:
bigrams_united <- bigrams_filtered %>%
unite(bigram, word1, word2, sep = " ")
A abordagem “tidy” traz uma tremenda flexibilidade. Se, por exemplo, quisermos ver com quais palavras a palavra “poder” é antecedida:
bigrams_filtered %>%
filter(word2 == "poder") %>%
count(word1, sort = TRUE)
Ou precedida:
bigrams_filtered %>%
filter(word1 == "poder") %>%
count(word2, sort = TRUE)
Ou ambos:
bf1 <- bigrams_filtered %>%
filter(word2 == "poder") %>%
count(word1, sort = TRUE) %>%
rename(word = word1)
bf2 <- bigrams_filtered %>%
filter(word1 == "poder") %>%
count(word2, sort = TRUE) %>%
rename(word = word2)
bind_rows(bf1, bf2) %>%
arrange(-n)
Super simples e legal, não?
Ngrams
Repetindo o procedimento para “trigrams”:
discursos_df %>%
unnest_tokens(trigram, text, token = "ngrams", n = 3) %>%
separate(trigram, c("word1", "word2", "word3"), sep = " ") %>%
anti_join(stopwords_pt_df, by = c("word1" = "word")) %>%
anti_join(stopwords_pt_df, by = c("word2" = "word")) %>%
anti_join(stopwords_pt_df, by = c("word3" = "word")) %>%
count(word1, word2, word3, sort = TRUE)
“sociedade civil organizada” é o “trigram” mais frequente no discurso da deputada.
Redes de palavras
Para encerrar, vamos a um dos usos mais interessantes do ngrams: a construção de redes de palavras. Precisaremos de dois novos pacotes, igraph e ggraph. Instale-os se precisar:
library(igraph)
library(ggraph)
Em primeiro lugar, transformaremos nosso data frame em um objeto da classe igraph, do pacote de mesmo nome, usado para a presentação de redes no R:
bigram_graph <- bigram_counts %>%
filter(n > 20) %>%
graph_from_data_frame()
A seguir, com o pacote ggraph, faremos o grafo a partir dos bigrams dos discursos da deputada:
ggraph(bigram_graph, layout = "fr") +
geom_edge_link() +
geom_node_point() +
geom_node_text(aes(label = name), vjust = 1, hjust = 1)
Note que são formadas pequenas associações entre termos que, par a par, caminham juntos. Novamente, não vamos explorar aspectos analíticos da mineração de texto, mas estas associações são informações de grande interessa a depender dos objetivos da análise.
Para além do tutorial
No tutorial, vimos o básico da preparação de textos para mineração, como organizar um Corpus e criar tokens. Além disso, vimos várias utilidades do pacote stringr, que serve para além da mineração de texto e pode ser útil na organização de bases de dados que contém variáveis “character”.
Se houver tempo em sala de aula e você quiser se aprofundar no assunto, leia alguns dos capítulos de Text Mininig with R: