5  Manipulação de dados com Tidier

No livro original, este é o capítulo do tidyverse. Julia tem uma tradução notavelmente fiel: a família Tidier, uma reimplementação em Julia puro da mini-linguagem do tidyverse sobre DataFrames.jl.

using TidierData
using DataFrames
using Statistics

5.1 Os dados

Usaremos ao longo do livro um levantamento simulado de anuros em 180 lagoas distribuídas por três regiões. Os dados são simulados de propósito: como o processo gerador é conhecido, cada modelo ajustado nos capítulos Capítulo 8 e Capítulo 9 pode ser confrontado com a verdade. O script gerador está em scripts/make_data.jl.

using CSV

ponds = CSV.read(joinpath("..", "data", "ponds.csv"), DataFrame)
first(ponds, 5)
5×9 DataFrame
Row pond_id region hydro area_m2 canopy temp_c richness focal body_size
String7 String15 String15 Float64 Float64? Float64 Int64 Int64 Float64
1 P001 Atlantic permanent 174.1 70.0 22.4 8 0 34.55
2 P002 Amazon permanent 161.6 42.6 23.9 12 1 30.36
3 P003 Cerrado temporary 233.9 57.5 25.2 6 1 34.44
4 P004 Amazon temporary 174.5 49.4 25.6 13 1 39.3
5 P005 Cerrado permanent 314.4 30.0 27.6 6 0 43.65

O livro é gerado de dentro da pasta pt/, por isso o ".." (a pasta de cima) neste e nos próximos capítulos. Na sua sessão, com a pasta do livro aberta, use joinpath("data", "ponds.csv"), como no quadro de exercícios abaixo.

describe(ponds, :eltype, :nmissing, :mean, :min, :max)
9×6 DataFrame
Row variable eltype nmissing mean min max
Symbol Type Int64 Union… Any Any
1 pond_id String7 0 P001 P180
2 region String15 0 Amazon Cerrado
3 hydro String15 0 permanent temporary
4 area_m2 Float64 0 240.215 10.0 1981.8
5 canopy Union{Missing, Float64} 7 49.1422 6.4 95.8
6 temp_c Float64 0 24.4406 17.0 33.9
7 richness Int64 0 7.13333 0 31
8 focal Int64 0 0.45 0 1
9 body_size Float64 0 35.6777 26.91 44.26
Coluna Significado
pond_id identificador da lagoa
region região (Atlantic, Cerrado, Amazon)
hydro hidroperíodo (permanent, temporary)
area_m2 área da lagoa (m²)
canopy cobertura de dossel (%)
temp_c temperatura da água (°C)
richness riqueza de espécies (contagem)
focal ocorrência da espécie-foco (0/1)
body_size tamanho corporal médio (mm)
NotaExercícios deste capítulo

Os quadros Sua vez funcionam como no Capítulo 4. Aqui as respostas são tabelas e números calculados a partir dos dados das lagoas; por isso, comece cada sessão carregando o verificador, os pacotes e os dados, com a pasta do livro aberta no VS Code:

include("exercises/checar.jl")
using TidierData, DataFrames, Statistics, CSV
ponds = CSV.read(joinpath("data", "ponds.csv"), DataFrame)
Nota

Os nomes das colunas estão em inglês nas duas versões do livro, de modo que o código seja literalmente idêntico em português e em inglês.

5.2 @chain: o pipe

@chain é o eixo de tudo o que vem a seguir. Ele passa o resultado de cada linha como primeiro argumento da próxima — exatamente o papel de |> (ou %>%) no R.

@chain ponds begin
    @filter(region == "Amazon")
    @select(pond_id, area_m2, canopy, richness)
    @arrange(desc(richness))
    first(5)
end
5×4 DataFrame
Row pond_id area_m2 canopy richness
String7 Float64 Float64? Int64
1 P065 1048.6 18.0 31
2 P044 568.1 31.7 24
3 P067 411.4 30.9 21
4 P085 204.6 58.2 19
5 P073 140.5 17.7 18

Em R

ponds %>%
  filter(region == "Amazon") %>%
  select(pond_id, area_m2, canopy, richness) %>%
  arrange(desc(richness)) %>%
  head(5)

A correspondência é linha a linha. A única diferença sintática é o @ e o begin ... end.

NotaSua vez 5.1 — As maiores lagoas do Cerrado

Escreva um @chain que retorne as três maiores lagoas (por area_m2) do Cerrado, da maior para a menor. Guarde o resultado em top3 e verifique com checar("5.1", top3).

Três passos, um por linha: fique com as linhas do Cerrado, ordene pela área da maior para a menor e fique com as três primeiras. O bloco acima faz algo bem parecido.

top3 = @chain ponds begin
    @filter(region == "Cerrado")
    @arrange(desc(area_m2))
    first(3)
end
3×9 DataFrame
Row pond_id region hydro area_m2 canopy temp_c richness focal body_size
String7 String15 String15 Float64 Float64? Float64 Int64 Int64 Float64
1 P130 Cerrado temporary 1981.8 84.9 26.0 9 0 38.53
2 P034 Cerrado temporary 1126.3 missing 22.9 6 0 33.53
3 P143 Cerrado temporary 932.7 39.8 25.1 11 0 34.15
checar("5.1", top3)
✓ Correto!

5.3 Os cinco verbos

5.3.1 @filter — selecionar linhas

@chain ponds begin
    @filter(richness > 8 && hydro == "permanent")
    nrow
end
39

Atenção: dentro de @filter, use && e ||, não & e | como no dplyr.

NotaSua vez 5.2 — Várias condições ao mesmo tempo

Quantas lagoas temporárias têm cobertura de dossel acima de 60% e riqueza abaixo da mediana geral de riqueza? Verifique o número com checar("5.2", n).

Calcule a mediana antes, fora da cadeia: med = median(ponds.richness). Dentro de uma macro do Tidier, um nome solto significa uma coluna; para usar a variável med, escreva !!med.

Se aparecer TypeError: non-boolean (Missing) used in boolean context, algumas lagoas não têm valor de dossel, e missing > 60 não é nem verdadeiro nem falso. Remova essas linhas antes com @drop_missing(canopy) (Seção 5.4).

med = median(ponds.richness)

n = @chain ponds begin
    @drop_missing(canopy)
    @filter(hydro == "temporary" && canopy > 60 && richness < !!med)
    nrow
end
18
checar("5.2", n)
✓ Correto!

Descartar as lagoas sem dossel é a decisão certa aqui: não dá para saber se o dossel delas passa de 60%. Já a mediana é calculada sobre as 180 lagoas, antes de descartar qualquer coisa.

5.3.2 @select — selecionar colunas

@chain ponds begin
    @select(pond_id, region, richness)
    first(3)
end
3×3 DataFrame
Row pond_id region richness
String7 String15 Int64
1 P001 Atlantic 8
2 P002 Amazon 12
3 P003 Cerrado 6
# exclusão e intervalos funcionam como no dplyr
@chain ponds begin
    @select(-pond_id, -focal)
    names
end
9-element Vector{String}:
 "region"
 "hydro"
 "area_m2"
 "canopy"
 "temp_c"
 "richness"
 "focal"
 "body_size"
 "pond_id"

5.3.3 @mutate — criar colunas

ponds_t = @chain ponds begin
    @mutate(
        log_area   = log(area_m2),
        canopy_std = (canopy - mean(skipmissing(canopy))) / std(skipmissing(canopy)),
        open       = canopy < 40,
    )
end

@chain ponds_t begin
    @select(pond_id, area_m2, log_area, canopy, canopy_std, open)
    first(5)
end
5×6 DataFrame
Row pond_id area_m2 log_area canopy canopy_std open
String7 Float64 Float64 Float64? Float64? Bool?
1 P001 174.1 5.15963 70.0 0.946503 false
2 P002 161.6 5.08512 42.6 -0.296877 false
3 P003 233.9 5.45489 57.5 0.379268 false
4 P004 174.5 5.16192 49.4 0.0116988 false
5 P005 314.4 5.75067 30.0 -0.868651 true
NotaSua vez 5.3 — Riqueza por 100 m²

Lagoas maiores tendem a ter mais espécies. Adicione uma coluna rich_per_100m2 com o número de espécies por 100 m² de lagoa, mantendo todas as linhas e colunas de ponds. Guarde o resultado em ponds_d e verifique com checar("5.3", ponds_d).

richness / area_m2 é espécies por m²; multiplique por 100.

ponds_d = @chain ponds begin
    @mutate(rich_per_100m2 = richness / area_m2 * 100)
end

checar("5.3", ponds_d)
✓ Correto!

5.3.4 @group_by + @summarize — agregar

@chain ponds begin
    @group_by(region, hydro)
    @summarize(
        n            = n(),
        mean_rich    = mean(richness),
        sd_rich      = std(richness),
        prop_focal   = mean(focal),
    )
    @arrange(region, hydro)
end

GroupedDataFrame with 3 groups based on key: region

First Group (2 rows): region = "Amazon"
Row region hydro n mean_rich sd_rich prop_focal
String15 String15 Int64 Float64 Float64 Float64
1 Amazon permanent 26 11.2692 5.99038 0.538462
2 Amazon temporary 22 10.9091 5.95946 0.318182

⋮

Last Group (2 rows): region = "Cerrado"
Row region hydro n mean_rich sd_rich prop_focal
String15 String15 Int64 Float64 Float64 Float64
1 Cerrado permanent 31 4.77419 2.90643 0.580645
2 Cerrado temporary 35 4.8 2.95854 0.4
NotaSua vez 5.4 — Onde está a espécie-foco?

Calcule, para cada região, a proporção de lagoas onde a espécie-foco ocorre, em uma coluna chamada prop_focal, ordenada da maior para a menor. Verifique com checar("5.4", por_regiao).

focal vale 0 ou 1, então a média é a proporção de lagoas onde ela vale 1. Ordenar da maior para a menor é @arrange(desc(...)).

por_regiao = @chain ponds begin
    @group_by(region)
    @summarize(prop_focal = mean(focal))
    @arrange(desc(prop_focal))
end

checar("5.4", por_regiao)
✓ Correto!

5.3.5 @arrange — ordenar

@chain ponds begin
    @arrange(region, desc(richness))
    @select(region, pond_id, richness)
    first(6)
end
6×3 DataFrame
Row region pond_id richness
String15 String7 Int64
1 Amazon P065 31
2 Amazon P044 24
3 Amazon P067 21
4 Amazon P085 19
5 Amazon P073 18
6 Amazon P164 18

5.4 Dados faltantes

A coluna canopy tem valores ausentes propositalmente.

sum(ismissing.(ponds.canopy))
7
@chain ponds begin
    @drop_missing(canopy)
    nrow
end
173
# agregação ignorando ausentes
@chain ponds begin
    @group_by(region)
    @summarize(mean_canopy = mean(skipmissing(canopy)))
end
3×2 DataFrame
Row region mean_canopy
String15 Float64
1 Atlantic 51.7125
2 Amazon 45.313
3 Cerrado 49.327
Aviso

mean(canopy) devolve missing se qualquer valor for ausente — o mesmo comportamento de mean() em R sem na.rm = TRUE. Seja explícito com skipmissing.

NotaSua vez 5.5 — Onde estão as lacunas?

Antes de descartar valores faltantes, vale saber onde eles estão: se estiverem concentrados em uma região, descartá-los muda essa região mais do que as outras. Conte, por região, quantas lagoas não têm valor de dossel, em uma coluna chamada n_missing. Verifique com checar("5.5", lacunas).

ismissing.(canopy) dá true para cada valor faltante, e sum conta os true. Os dois funcionam dentro de @summarize.

lacunas = @chain ponds begin
    @group_by(region)
    @summarize(n_missing = sum(ismissing.(canopy)))
end

checar("5.5", lacunas)
✓ Correto!

Sete valores faltantes espalhados pelas três regiões: nenhuma região é muito mais afetada que as outras, então descartá-los dificilmente distorce comparações entre regiões.

5.5 Formato longo e largo

longo = @chain ponds begin
    @select(pond_id, region, area_m2, canopy, temp_c)
    @pivot_longer(area_m2:temp_c, names_to = "variable", values_to = "value")
end

first(longo, 6)
6×4 DataFrame
Row pond_id region variable value
String7 String15 String Float64?
1 P001 Atlantic area_m2 174.1
2 P002 Amazon area_m2 161.6
3 P003 Cerrado area_m2 233.9
4 P004 Amazon area_m2 174.5
5 P005 Cerrado area_m2 314.4
6 P006 Cerrado area_m2 620.2
@chain longo begin
    @pivot_wider(names_from = variable, values_from = value)
    first(3)
end
3×5 DataFrame
Row pond_id region area_m2 canopy temp_c
String7 String15 Float64? Float64? Float64?
1 P001 Atlantic 174.1 70.0 22.4
2 P002 Amazon 161.6 42.6 23.9
3 P003 Cerrado 233.9 57.5 25.2

5.6 Junções

# uma tabela auxiliar com informação em nível de região
regioes = DataFrame(
    region     = ["Atlantic", "Cerrado", "Amazon"],
    biome_type = ["forest", "savanna", "forest"],
    mean_rain  = [1400, 1500, 2300],
)

@chain ponds begin
    @left_join(regioes, region)
    @select(pond_id, region, biome_type, mean_rain, richness)
    first(5)
end
5×5 DataFrame
Row pond_id region biome_type mean_rain richness
String7 String15 String? Int64? Int64
1 P001 Atlantic forest 1400 8
2 P002 Amazon forest 2300 12
3 P003 Cerrado savanna 1500 6
4 P004 Amazon forest 2300 13
5 P005 Cerrado savanna 1500 6
NotaSua vez 5.6 — Floresta contra savana

Usando a tabela regioes acima, calcule a riqueza média das lagoas de floresta e de savana, em uma coluna chamada mean_rich. Verifique com checar("5.6", por_bioma).

Primeiro faça a junção, para que cada lagoa ganhe seu biome_type; depois agrupe por biome_type e resuma.

por_bioma = @chain ponds begin
    @left_join(regioes, region)
    @group_by(biome_type)
    @summarize(mean_rich = mean(richness))
end

checar("5.6", por_bioma)
✓ Correto!

5.7 DataFrames.jl por baixo

Tidier é uma camada de conveniência. Vale saber a forma nativa, que é o que você vai encontrar na documentação da maior parte do ecossistema:

combine(
    groupby(ponds, :region),
    :richness => mean => :mean_rich,
    :richness => length => :n,
)
3×3 DataFrame
Row region mean_rich n
String15 Float64 Int64
1 Atlantic 6.59091 66
2 Amazon 11.1042 48
3 Cerrado 4.78788 66

Em Tidier, isso seria @group_by(region) seguido de @summarize(mean_rich = mean(richness), n = n()): a mesma ideia do @group_by/@summarize acima, mas agrupando só por região. :region é um Symbol, o jeito de Julia nomear uma coluna, e :richness => mean => :mean_rich se lê “pegue richness, aplique mean e chame o resultado de mean_rich”. Use Tidier para escrever, e reconheça a forma nativa para ler.

NotaSua vez 5.7 — O mesmo, na forma nativa

Reescreva sua resposta ao 5.4 com groupby, combine e sort do DataFrames.jl, sem as macros do Tidier. Verifique com checar("5.7", por_regiao_nativo).

sort(df, :coluna, rev = true) ordena da maior para a menor.

por_regiao_nativo = sort(
    combine(groupby(ponds, :region), :focal => mean => :prop_focal),
    :prop_focal, rev = true)

checar("5.7", por_regiao_nativo)
✓ Correto!

Desafios

Estes não têm verificador. São sobre o que os números dizem — e o que não dizem.

  1. No 5.4 o Cerrado fica em primeiro, mas as três proporções vão só de 0,42 a 0,48, cada uma calculada sobre 48 a 66 lagoas. Abra scripts/make_data.jl: a região afeta a espécie-foco na simulação? O que isso diz sobre ler um ranking em uma tabela resumo?
  2. No 5.6 as lagoas de floresta são mais ricas que as de savana. Mas “floresta” aqui significa Atlantic mais Amazon. Calcule a riqueza média por região e decida se “floresta” é um agrupamento útil para essa pergunta.
  3. Use @pivot_longer para colocar area_m2, canopy e temp_c em uma única coluna e depois calcule a média de cada variável por região em um único pipeline. Como você lida com os valores faltantes de dossel?