Café com R
Edição 09: across(): quando você percebe que está repetindo código, já é tarde demais
- Que cada gole desperte uma nova ideia.
- Que cada script abra uma nova conversa.
- Que o Café com R se torne um ponto de encontro nosso.
O gole da semana
Você já se pegou escrevendo a mesma linha de código quatro vezes seguidas, mudando apenas o nome da coluna?
Se já passou por isso, sabe exatamente como é.
O código funciona. Ufa
Os resultados estão certos. Que beleza!
Mas há algo incômodo naquele bloco, uma sensação de que existe uma forma melhor de fazer aquilo, e que você ainda não chegou lá. 😬
Esse incômodo tem nome.
Chama-se reconhecimento de padrão.
E é um dos sinais mais confiáveis de que você está avançando.
IMPORTANTE
Quem está começando reproduz código. Isso é normal, faz parte do processo.
Quem está avançando percebe padrões no código que reproduz.
Quem está mais avançado ainda aprende a abstrair esses padrões em estruturas que escalam, que funcionam para cinco colunas da mesma forma que funcionam para cinquenta, sem que você precise alterar nada.
O across() é uma dessas estruturas.
Ele não resolve um problema novo.
Ele resolve um problema antigo de uma forma que muda a forma como você pensa sobre transformação de dados.
Esta edição é sobre esse momento de mudança.
A dose técnica
O problema que você provavelmente já teve
Imagine que você tem um conjunto de dados com várias colunas numéricas e precisa arredondar todas elas para duas casas decimais.
Ou calcular a média de cada uma.
Ou substituir os valores ausentes por zero.
Como você faria?
Se você aprendeu mutate() antes de conhecer across(), a resposta mais provável é esta:
library(tidyverse)
airquality |>
mutate(
Ozone = round(Ozone, 2),
Solar.R = round(Solar.R, 2),
Wind = round(Wind, 2),
Temp = round(Temp, 2))O código funciona.
Mas repare no que está acontecendo: você está escrevendo round(..., 2) quatro vezes.
A única coisa que muda entre uma linha e a próxima é o nome da coluna.
Agora pense no que acontece quando o conjunto de dados muda. Atenção aqui.
Uma coluna nova é adicionada.
Outra é removida. Você precisa lembrar de atualizar o
mutate()manualmente e se esquecer, o código continua rodando sem erro, mas sem processar a coluna nova.
Esse é o problema. Vejo muito nos scripts que chegam até mim.
Não é a repetição em si.
É a fragilidade que a repetição cria.
O que o across() faz
O
across()permite aplicar uma ou mais funções a múltiplas colunas de uma vez, dentro demutate(),summarise()oufilter().Em vez de repetir a mesma operação coluna por coluna, você descreve o padrão uma única vez.
A estrutura é simples, vejam:
across(.cols, .fns, .names)Três argumentos:
.cols: quais colunas selecionar. Aceita nomes diretos, helpers de seleção comowhere(),starts_with(),ends_with(), ou vetores de nomes..fns: qual função aplicar. Pode ser uma função simples, uma função anônima com\(x), ou uma lista nomeada de funções..names: como nomear as colunas resultantes. Opcional, mas importante quando você aplica múltiplas funções.
O mesmo código anterior, reescrito com across():
airquality |>
mutate(across(c(Ozone, Solar.R, Wind, Temp), ~ round(.x, 2)))O resultado é idêntico.
Mas a lógica agora mudou: você declarou o que quer fazer, arredondar para duas casas e quais colunas recebem essa operação.
Se uma coluna nova aparecer no conjunto de dados, basta incluí-la na seleção.
Seleção dinâmica com where() e starts_with()
Aqui é onde o across() ganha o seu coraçãozinho!
Você percebe o que está acontecendo no exemplo anterior?
Ainda estamos listando os nomes das colunas manualmente. Se o conjunto de dados mudar, ainda precisamos lembrar de atualizar a lista.
A solução é usar seleção dinâmica, deixar o R descobrir quais colunas aplicar a função, com base em uma condição. 🤭
where() seleciona colunas que satisfazem uma condição lógica:
# Arredondar todas as colunas numéricas - sem listar nenhuma pelo nome
airquality |>
mutate(across(where(is.numeric), ~ round(.x, 2)))Agora o código não depende mais dos nomes das colunas.
Se amanhã o conjunto de dados ganhar três colunas numéricas novas, o across() as incluirá automaticamente.
Se uma coluna for removida, nenhum erro será gerado.
Isso é o que significa código que escala.
starts_with() e seus equivalentes selecionam colunas pelo padrão do nome:
# Selecionar apenas colunas cujo nome começa com "S"
airquality |>
summarise(across(starts_with("S"), mean, na.rm = TRUE))Outros helpers úteis na mesma lógica:
| Helper | O que seleciona |
|---|---|
starts_with("x") |
Colunas cujo nome começa com “x” |
ends_with("_id") |
Colunas cujo nome termina com “_id” |
contains("data") |
Colunas cujo nome contém “data” |
matches("^col_[0-9]") |
Colunas que correspondem a uma expressão regular |
where(is.numeric) |
Colunas numéricas |
where(is.character) |
Colunas de texto |
across() dentro de mutate()
O caso mais comum.
Você quer transformar várias colunas ao mesmo tempo.
- O dataset
airqualitytem valores ausentes nas colunasOzoneeSolar.R. Suponha que você queira substituir todos os valores ausentes nas colunas numéricas por zero, uma decisão analítica discutível, mas útil para ilustrar o padrão:
# Substituir NA por 0 em todas as colunas numéricas
airquality |>
mutate(across(where(is.numeric), ~ replace_na(.x, 0)))Ou normalizar todas as colunas numéricas para a escala de 0 a 1:
# Normalização min-max em todas as colunas numéricas
airquality |>
mutate(across(
where(is.numeric),
~ (.x - min(.x, na.rm = TRUE)) /
(max(.x, na.rm = TRUE) - min(.x, na.rm = TRUE))))Repare na função anônima
\(x)- ou na notação~ .xdo tidyverse.Quando a transformação não é uma função simples, você escreve a lógica inline. O
.xrepresenta cada coluna que oacross()está processando.
Uma pergunta que ajuda a decidir quando usar across() no mutate():
se eu precisar aplicar essa mesma transformação em uma coluna nova amanhã, vou precisar alterar o código?
Se a resposta for sim,
across()com seleção dinâmica provavelmente resolve.
across() dentro de summarise()
Aqui o
across()é ótimo. Vejam só:Calcular estatísticas resumidas para múltiplas colunas é um dos padrões mais frequentes em análise exploratória e sem
across(), o código cresce de forma proporcional ao número de colunas.
# Média de todas as colunas numéricas, removendo ausentes
airquality |>
summarise(across(where(is.numeric), mean, na.rm = TRUE))- Mas o que torna o
across()dentro desummarise()ainda mais poderoso é a possibilidade de aplicar múltiplas funções ao mesmo tempo, com nomes controlados:
# Média e desvio padrão de todas as colunas numéricas
airquality |>
summarise(across(
where(is.numeric),
list(media = ~ mean(.x, na.rm = TRUE),
dp = ~ sd(.x, na.rm = TRUE)),
.names = "{.col}_{.fn}"))O argumento .names = "{.col}_{.fn}" controla como as colunas resultantes são nomeadas: {.col} é substituído pelo nome da coluna original e {.fn} pelo nome da função na lista.
O resultado será colunas como Ozone_media, Ozone_dp, Wind_media, Wind_dp e assim por diante.
- Você consegue ver o que aconteceria sem
across()aqui? Seriam oito linhas desummarise(), uma para cada combinação de coluna e estatística. E se uma coluna nova aparecesse, seriam mais duas linhas. Comacross(), o código não muda.
Combinado com group_by(), o padrão fica ainda melhor:
# Média de todas as colunas numéricas por mês
airquality |>
group_by(Month) |>
summarise(across(
where(is.numeric),
~ mean(.x, na.rm = TRUE),
.names = "{.col}_media"))across() dentro de filter()
Esse uso é menos conhecido e por isso vale atenção.
O
filter()normalmente recebe uma condição lógica sobre uma coluna.Mas e quando você quer filtrar linhas onde qualquer coluna numérica tem valor ausente? Ou onde todas as colunas de um determinado grupo satisfazem uma condição?
Para isso existe o
if_any()e oif_all()- dois helpers que trabalham junto comacross()dentro defilter().if_any()retorna verdadeiro se a condição for satisfeita em pelo menos uma das colunas selecionadas:
# Remover linhas onde qualquer coluna numérica tem valor ausente
airquality |>
filter(!if_any(where(is.numeric), is.na))if_all()retorna verdadeiro apenas se a condição for satisfeita em todas as colunas selecionadas:
# Manter apenas linhas onde todas as colunas numéricas têm valor
airquality |>
filter(if_all(where(is.numeric), ~ !is.na(.x)))- Para ver um exemplo com mais colunas, o dataset
stormsdo dplyr tem dados de tempestades tropicais com várias variáveis meteorológicas numéricas:
# Tempestades onde vento, pressão e diâmetro têm valores registrados
storms |>
filter(if_all(c(wind, pressure, tropicalstorm_force_diameter),
~ !is.na(.x))) |>
select(name, year, wind, pressure, tropicalstorm_force_diameter)A pergunta que vale fazer aqui: antes de conhecer if_any() e if_all(), como você resolveria isso?
Provavelmente com múltiplos filter() encadeados, um por coluna.
O código funcionaria, mas não escalaria para dez colunas da mesma forma que uma linha com if_all() escala.
O que muda quando você adota across()
Não é só o código que fica mais limpo. É a forma como você pensa sobre o problema.
- Quando você escreve
mutate()coluna por coluna, está pensando em cada coluna individualmente. Quando você escreveacross(where(is.numeric), ...), está pensando em uma propriedade do conjunto de dados “todas as colunas numéricas” e aplicando uma operação a essa propriedade.
Essa mudança de perspectiva tem consequências práticas. Código que opera sobre propriedades em vez de nomes específicos é mais robusto a mudanças nos dados. É mais fácil de revisar porque a intenção está explícita. E é mais fácil de reutilizar porque não está acoplado à estrutura específica de um conjunto de dados.
Você vai continuar usando mutate() sem across() para transformações simples em colunas específicas e está certo fazê-lo. O across() não substitui o mutate() coluna a coluna. Ele complementa, para os casos onde o padrão de repetição aparece.
A habilidade está em reconhecer quando esse padrão aparece. E agora que você sabe o que ele parece, vai começar a vê-lo em todo lugar.
O que estou acompanhando
Documentação oficial do across() - dplyr A documentação do dplyr tem exemplos detalhados de across(), if_any() e if_all(), incluindo os casos de uso menos óbvios. Vale manter aberta enquanto pratica: dplyr.tidyverse.org/reference/across.html
Próxima edição - Cafezinho 10 - 26 de maio
O que reportar em conjunto/alternativa ao p-valor?
Tem projeto no Youtube!!
Os vídeos estão sendo lançados, mas já temos 5 disponíveis.