library(tabulizer)
library(dplyr)
library(tidyr, include.only = "pivot_longer")
library(janitor)
library(stringr)
library(countries, include.only = "country_name")
Importando dados em PDF
Trabalhar com dados e visualizar dados, muitas vezes, são objetivos que não se conciliam facilmente. Uma tabela, otimizada para leitura humana, dificilmente será a mais apropriada para análise de dados. De maneira geral, a maior parte dos dados que se encontra são sujos, num sentido amplo. Isto não significa que estes dados estejam errados, ou com algum tipo de imprecisão; quer dizer que os dados não estão num formato tabular, apropriado para a análise de dados.
Talvez um dos formatos mais frustrantes para se consumir dados seja o PDF. Arquivos PDF não são um típico formato de armazenamento de dados, como CSV, XLSX, SAV, etc. PDFs são relatórios que combinam texto, imagens, tabelas, fórmulas, etc. Ainda assim, não é incomum receber tabelas de dados salvas dentro de arquivos PDF.
Neste post vou mostrar uma solução para importar dados em formato PDF de maneira fácil e prática usando R
.
tabulizer
O pacote tabulizer
oferece funções simples para importar tabelas de dados que estão salvas em formato PDF. Como o formato PDF é muito flexível e diverso, não é possível garantir que o tabulizer
funcione sempre. Além disso, como veremos adiante, o processo de limpeza dos dados é bastante artesanal, variando caso a caso.
Composição religiosa por país
Como exemplo, vamos importar dados sobre a composição religiosa de cada país. A tabela provém do Pew Research Center e pode ser baixada no seguinte link. Mais recentemente, foi disponibilizada uma versão interativa destes dados, com uma opção mais simples de consumo. Mas, para seguir os objetivos deste post, vamos seguir com o arquivo PDF.
A imagem abaixo mostra o começo da tabela em PDF. A tabela é dividida em 6 páginas e tem dez colunas.
Importando
Abaixo listo os pacotes necessários para este tutorial.
O primeiro passo é baixar os dados e importar a tabela. Note que seria possível fazer isto de maneira mais automatizada usando download.file
, criando um arquivo temporário, etc. Contudo, como trata-se de um dado estático, que será consumido uma única vez, acaba sendo mais simples baixá-lo manualmente. Para ler as tabelas do PDF usa-se a função extract_tables()
. No código abaixo eu utilizo o pacote here
, que não é essencial, mas é recomendável. Para mais sobre o pacote veja ‘Escrevendo paths relativos com here’.
# path fake, use o seu path
= here::here("project/data/globalReligion-tables.pdf")
path = extract_tables(path) tables
O resultado é uma lista de seis elementos (um para cada página). Pode-se ver também que houve algum problema na hora de importar os dados. Como comentei, o padrão PDF não é apropriado para compatilhamento de dados; inevitavelmente, o processo de importação e limpeza será caso a caso.
str(tables)
List of 6
$ : chr [1:40, 1:10] "" "COUNTRY" "Afghanistan" "Albania" ...
$ : chr [1:40, 1:10] "ayman Islands" "entral African Republic" "had" "hannel Islands" ...
$ : chr [1:40, 1:10] "reece" "reenland" "renada" "uadeloupe" ...
$ : chr [1:40, 1:10] "uxembourg" "acau" "adagascar" "alawi" ...
$ : chr [1:40, 1:10] "apua New Guinea" "araguay" "eru" "hilippines" ...
$ : chr [1:41, 1:10] "weden" "witzerland" "yria" "aiwan" ...
Limpeza dos dados
Olhando as primeiras linhas do primeiro elemento, vemos que o cabeçalho foi importado em duas linhas distintas.
1]][1:5, ] tables[[
[,1] [,2] [,3] [,4] [,5] [,6]
[1,] "" "COUNTRY" "PERCENT" "PERCENT" "PERCENT" "PERCENT"
[2,] "COUNTRY" "POPULATION" "CHRISTIAN" "MUSLIM" "UNAFFIL." "HINDU"
[3,] "Afghanistan" "31,410,000" "0.1 %" "99.7 %" "< 0.1 %" "< 0.1 %"
[4,] "Albania" "3,200,000" "18.0" "80.3" "1.4" "< 0.1"
[5,] "Algeria" "35,470,000" "0.2" "97.9" "1.8" "< 0.1"
[,7] [,8] [,9] [,10]
[1,] "PERCENT" "FOLK" "OTHER" "PERCENT"
[2,] "BUDDHIST" "RELIGION" "RELIGION" "JEWISH"
[3,] "< 0.1 %" "< 0.1 %" "< 0.1 %" "< 0.1 %"
[4,] "< 0.1" "< 0.1" "0.2" "< 0.1"
[5,] "< 0.1" "< 0.1" "< 0.1" "< 0.1"
Vamos primeiro separar estas linhas e montar o cabeçalho (nome das colunas) da nossa tabela. Queremos (1) juntar as informações; e (2) renomear as colunas. Assim, queremos juntar "PERCENT"
com "CHRISTIAN"
e depois tornar o nome limpo, i.e., "percent_christian"
.
# Seleciona apenas as duas primeiras linhas do primeiro elemento
= tables[[1]][1:2, ]
header
# Junta as colunas e 'limpa' os nomes
= apply(header, 2, \(x) make_clean_names(str_c(x, collapse = "_")))
col_names
col_names
[1] "country" "country_population" "percent_christian"
[4] "percent_muslim" "percent_unaffil" "percent_hindu"
[7] "percent_buddhist" "folk_religion" "other_religion"
[10] "percent_jewish"
Agora vamos empilhar os dados e usar o cabeçalho. Além disso, vamos inspecionar as primeiras linhas.
= bind_rows(lapply(tables, as.data.frame))
table_data = table_data[3:nrow(table_data), ]
table_data names(table_data) = col_names
head(table_data)
country country_population percent_christian percent_muslim
3 Afghanistan 31,410,000 0.1 % 99.7 %
4 Albania 3,200,000 18.0 80.3
5 Algeria 35,470,000 0.2 97.9
6 American Samoa 70,000 98.3 < 0.1
7 Andorra 80,000 89.5 0.8
8 Angola 19,080,000 90.5 0.2
percent_unaffil percent_hindu percent_buddhist folk_religion other_religion
3 < 0.1 % < 0.1 % < 0.1 % < 0.1 % < 0.1 %
4 1.4 < 0.1 < 0.1 < 0.1 0.2
5 1.8 < 0.1 < 0.1 < 0.1 < 0.1
6 0.7 < 0.1 0.3 0.4 0.3
7 8.8 0.5 < 0.1 < 0.1 0.1
8 5.1 < 0.1 < 0.1 4.2 < 0.1
percent_jewish
3 < 0.1 %
4 < 0.1
5 < 0.1
6 < 0.1
7 0.3
8 < 0.1
A primeira coluna de nossa tabela é de strings, enquanto as demais são todas numéricas. Para facilitar a leitura humana, os números foram formatados, com separador de milhar, sinal de porcentagem, etc. O próximo passo é formatar os números: o código abaixo remove este símbolos e converte para numérico.
= table_data |>
table_data as_tibble() |>
mutate(across(2:last_col(), ~as.numeric(str_remove_all(.x, "[%<>,]"))))
Por fim, se olharmos para as últimas linhas da tabela, veremos que as linhas representam regiões ao invés de países. Isto é muito frequente em tabelas: é costumeiro acrescentar “totais” ou “agregados” nas últimas linhas de uma tabela para facilitar a interpretação dos dados.
Em termos de análise, contudo, é importante que cada linha representa a mesma unidade. Isto é, cada linha na tabela principal deve representar um país.
slice_tail(table_data, n = 7)
# A tibble: 7 × 10
country country_population percent_christian percent_muslim
<chr> <dbl> <dbl> <dbl>
1 sia-Pacific 4054990000 7.1 24.3
2 urope 742550000 75.2 5.9
3 atin America-Caribbean 590080000 90 0.1
4 iddle East-North Africa 341020000 3.7 93
5 orth America 344530000 77.4 1
6 ub-Saharan Africa 822720000 62.9 30.2
7 orld 6895890000 31.5 23.2
percent_unaffil percent_hindu percent_buddhist folk_religion other_religion
<dbl> <dbl> <dbl> <dbl> <dbl>
1 21.2 25.3 11.9 9 1.3
2 18.2 0.2 0.2 0.1 0.1
3 7.7 0.1 0.1 1.7 0.2
4 0.6 0.5 0.1 0.3 0.1
5 17.1 0.7 1.1 0.3 0.6
6 3.2 0.2 0.1 3.3 0.2
7 16.3 15 7.1 5.9 0.8
percent_jewish
<dbl>
1 0
2 0
3 0
4 1
5 1
6 0
7 0
Vamos guardar esta informação num objeto secundário chamado table_regions
.
<- slice_tail(table_data, n = 7)
table_regions <- slice(table_data, 1:(nrow(table_data) - 7)) table_data
Por fim, temos um problema específico. O nome de muitos países saiu cortado, faltando a primeira letra. Para resolver isto vamos usar o pacote countries
que oferece um fuzzy matching para o nome de países.
Para melhor compreender o uso do pacote vamos analisar um exemplo. Olhando a linha 51, vemos que o país importado foi “yprus”. O correto seria “Cyprus”.
= table_data[51, ]$country
test_name test_name
[1] "yprus"
A função country_name
consegue identificar o nome corretamente.
country_name(test_name, fuzzy_match = TRUE, to = "name_en")
[1] "Cyprus"
Pode-se aplicar esta função em todos os nomes dos países. Além disso, é importante verificar se houve erros. O código busca qualquer entrada com NA
em name
ou iso3c
.
= table_data |>
test mutate(
name = country_name(country, to = "name_en", fuzzy_match = TRUE),
iso3c = country_name(country, to = "ISO3", fuzzy_match = TRUE)
)
= test |>
errors filter(if_any(name:iso3c, ~is.na(.))) |>
pull(country)
errors
[1] "hannel Islands" "osovo" "etherlands Antilles"
[4] "orthern Mariana Is." "alestinian territories" "ruguay"
Como se vê, o algoritmo falha em identificar alguns países, que serão corrigidos manualmente. O foco do post não é de como usar o algortimo de fuzzy matching então não vou entrar em muitos detalhes. Vale notar que é possível acrescentar o argumento verbose = TRUE
na função country_name
para receber um retorno mais informativo.
Além dos valores ausentes, vou verificar também os matchings duplicados. O código abaixo faz uma correção manual destes casos. Vale notar que há um caso “impossível”: Gambia e Zambia, pois ambos os países estão com o nome “ambia” na tabela importada. Neste caso, uso a informação da população para discriminar os casos.
= test |>
errors get_dupes(name) |>
select(name, country)
= c(
correction "c", "k", "n", "n", "p", "u", "m", "m", "p", "", "m", "", "l", "e", "p", "f",
"i", "n", "z", "g", "u", "i", "o", "r", "r", "p", "s", "g", "u", "f", "u"
)
= errors |>
fix_table mutate(country_fixed = str_c(str_to_upper(correction), country)) |>
select(country, country_fixed)
= table_data |>
table_data left_join(fix_table, by = 'country', relationship = "many-to-many") |>
mutate(
temp_name = case_when(
== "ambia" & country_population == 1730000 ~ "Gambia",
country == "ambia" & country_population > 1730000 ~ "Zambia",
country is.na(country_fixed) ~ country,
TRUE ~ country_fixed
),# faz o matching do nomes dos países
name = country_name(temp_name, to = "name_en", fuzzy_match = TRUE),
# resolve alguns casos extremos "Channel Islands"
name = if_else(is.na(name), temp_name, name),
# encontra o ISO3 code de cada país
iso3c = country_name(name, to = "ISO3", fuzzy_match = FALSE)
)
Os erros finais acontecem porque alguns dos países listados não possuem ISO3, como "Netherlands Antilles"
Tabela Final
A tabela final é apresentada abaixo. Vale notar que alguns dos valores estão truncados, o que gera uma pequena imprecisão, como é o caso das entradas que eram listadas como “< 0.1”.
= table_data %>%
table_data select(name, iso3c, country_population:percent_jewish)
::datatable(table_data) DT
Tidy data
Por fim, vale notar que podemos melhorar ainda mais os dados acima. Na tabela acima, muitas das colunas são valores da variável “religião”; então podemos, transformar os dados em “tidy” convertendo-os em longitudinais da seguinte forma.
= table_data |>
tab_religion pivot_longer(
cols = percent_christian:percent_jewish,
names_to = "religion",
values_to = "share"
|>
) mutate(religion = str_remove(religion, "(percent_)|(_religion)"))
tab_religion
# A tibble: 1,872 × 5
name iso3c country_population religion share
<chr> <chr> <dbl> <chr> <dbl>
1 Afghanistan AFG 31410000 christian 0.1
2 Afghanistan AFG 31410000 muslim 99.7
3 Afghanistan AFG 31410000 unaffil 0.1
4 Afghanistan AFG 31410000 hindu 0.1
5 Afghanistan AFG 31410000 buddhist 0.1
6 Afghanistan AFG 31410000 folk 0.1
7 Afghanistan AFG 31410000 other 0.1
8 Afghanistan AFG 31410000 jewish 0.1
9 Albania ALB 3200000 christian 18
10 Albania ALB 3200000 muslim 80.3
# ℹ 1,862 more rows
Agora temos uma coluna religion
que identifica cada uma das religiões consideradas na pesquisa e uma coluna share
que mostra a representatividade de cada religão em cada país. Como comentado anteriormente, existem algumas impreciões pois substituímos os valores “< 0.1” simplesmente por “0.1”, então alguns shares vão somar valores um pouco maiores do que 100.
Com os dados neste formato fica fácil responder perguntas como: qual a religão dominante em cada país? Olhando os dados vemos que há 149 países em que a religião “dominante”, definida simplesmente como a religão que tem o maior share de convertidos, é o cristianismo. Há um gap considerável entre a religão muçulmana, com 47 países.
|>
tab_religion filter(share == max(share), .by = c("name", "iso3c")) |>
count(religion, sort = TRUE)
# A tibble: 7 × 2
religion n
<chr> <int>
1 christian 160
2 muslim 50
3 buddhist 8
4 unaffil 7
5 folk 3
6 hindu 3
7 jewish 1
E qual o resultado quando se olha para o total da população religiosa? A religião cristã continua em primeiro lugar, com quase 1.6 bilhão de convertidos. Já a religião hindu, apesar de ser dominante em apenas 3 países, aparece com quase 1 bilhão de seguidores. Isto acontece porque esta é a religão dominante da Índia, que à época da pesquisa tinha 1.22 bilhão de habitantes.
|>
tab_religion filter(share == max(share), .by = c("name", "iso3c")) |>
mutate(total = country_population * share / 100) |>
summarise(total_relig = sum(total), .by = "religion") |>
arrange(desc(total_relig))
# A tibble: 7 × 2
religion total_relig
<chr> <dbl>
1 christian 1984890470
2 muslim 1174670350
3 hindu 998475870
4 unaffil 824792400
5 buddhist 138877020
6 folk 50377350
7 jewish 5565000
|>
tab_religion filter(share == max(share), .by = c("name", "iso3c")) |>
filter(religion == "hindu")
# A tibble: 3 × 5
name iso3c country_population religion share
<chr> <chr> <dbl> <chr> <dbl>
1 India IND 1224610000 hindu 79.5
2 Mauritius MUS 1300000 hindu 56.4
3 Nepal NPL 29960000 hindu 80.7
Resumo
O código abaixo resume os passos da importação e limpeza dos dados.
library(tabulizer)
library(dplyr)
library(janitor)
library(stringr)
library(tidyr, include.only = "pivot_longer")
library(countries, include.only = "country_name")
# (opcional: baixa os dados)
= "https://pewresearch ..."
url download.file(url, destfile = tempfile(fileext = "pdf"))
# Importa a tabela
= here::here("project/data/globalReligion-tables.pdf")
path = extract_tables(path)
tables
# Limpeza -------------------------------------------------------
# Nome das colunas
= tables[[1]][1:2, ]
header # Junta as colunas e 'limpa' os nomes
= apply(header, 2, \(x) make_clean_names(str_c(x, collapse = "_")))
col_names # Empilha os dados e define nome das colunas
= bind_rows(lapply(tables, as.data.frame))
table_data = table_data[3:nrow(table_data), ]
table_data names(table_data) = col_names
# Converte colunas para numérico
= table_data |>
table_data as_tibble() |>
mutate(across(2:last_col(), ~as.numeric(str_remove_all(.x, "[%<>,]"))))
# Remove as últimas sete linhas
<- slice_tail(table_data, n = 7)
table_regions <- slice(table_data, 1:(nrow(table_data) - 7))
table_data
# Nome dos países
# Correção manual
= table_data |>
test mutate(
name = country_name(country, to = "name_en", fuzzy_match = TRUE),
iso3c = country_name(country, to = "ISO3", fuzzy_match = TRUE)
)
= test |>
errors get_dupes(name) |>
select(name, country)
= c(
correction "c", "k", "n", "n", "p", "u", "m", "m", "p", "", "m", "", "l", "e", "p", "f",
"i", "n", "z", "g", "u", "i", "o", "r", "r", "p", "s", "g", "u", "f", "u"
)
= errors |>
fix_table mutate(country_fixed = str_c(str_to_upper(correction), country)) |>
select(country, country_fixed)
# Matching dos nomes
= table_data |>
table_data left_join(fix_table, by = 'country', relationship = "many-to-many") |>
mutate(
temp_name = case_when(
== "ambia" & country_population == 1730000 ~ "Gambia",
country == "ambia" & country_population > 1730000 ~ "Zambia",
country is.na(country_fixed) ~ country,
TRUE ~ country_fixed
),# faz o matching do nomes dos países
name = country_name(temp_name, to = "name_en", fuzzy_match = TRUE),
# resolve alguns casos extremos "Channel Islands"
name = if_else(is.na(name), temp_name, name),
# encontra o ISO3 code de cada país
iso3c = country_name(name, to = "ISO3", fuzzy_match = FALSE)
|>
) select(name, iso3c, country_population:percent_jewish)
# Tidy
# Converte os dados para long
= table_data |>
tab_religion pivot_longer(
cols = percent_christian:percent_jewish,
names_to = "religion",
values_to = "share"
|>
) mutate(religion = str_remove(religion, "(percent_)|(_religion)"))