Pipes

data-science
tutorial-R
Author

Vinicius Oike

Published

September 15, 2023

Pipes

Introdução

A partir da versão 4.1.0, o R passou a oferecer o operador |> chamado de pipe (literalmente, cano)1. Este operador foi fortemente inspirado no operador homônimo %>% do popular pacote magrittr. Neste post, explico como utilizar o pipe nativo e como ele difere do pipe do magrittr.

O operador pipe (em ambos os casos), essencialmente, ordena uma função composta.

Lembrando um pouco sobre funções compostas: a expressão abaixo mostra a aplicação de três funções onde primeiro aplica-se a função f sobre x, depois a função g e, por fim, a função h. Lê-se a função de dentro para fora.

\[ h(g(f(x))) = \dots \]

Para tornar o exemplo mais concreto considere o exemplo abaixo onde calcula-se a média geométrica de uma sequência de números aleatórios.

A média geométrica é dada pela expressão:

\[ \overline{x} = (\prod_{i = 1}^{n}x_{i})^{\frac{1}{n}} = \text{exp}(\frac{1}{n}\sum_{i = 1}^{n}\text{log}(x_{i})) \]

x <- rnorm(n = 100, mean = 10)
#> Calcula a média geométrica
exp(mean(log(x)))
[1] 10.221

Usando a mesma notação acima, aplica-se primeiro a função log (f), depois a função mean (g) e, por fim, a função exp (h). Usando o operador pipe, pode-se reescrever a expressão da seguinte forma.

x |> log() |> mean() |> exp()
[1] 10.221

Note que o resultado da função vai sendo “carregado” da esquerda para a direita sucessivamente. Para muitos usuários, a segunda sintaxe é mais intuitiva e/ou fácil de ler. No segundo código a ordem em que o nome das funções aparecem coincide com a ordem da sua aplicação.

Por fim, note que o uso de várias funções numa mesma linha de código também nos poupa de ter de criar objetos intermediários como no exemplo abaixo.

log_x <- log(x)
log_media <- mean(log_x)
media_geometrica_x <- exp(log_media)

media_geometrica_x
[1] 10.221

Os exemplos acima funcionaram sem problemas porque usou-se o operador pipe para “abrir” uma função composta. O argumento de cada função subsequente é o resultado da função antecedente: funciona como uma linha de montagem, em que cada nova etapa soma-se ao resultado da etapa anterior.

Quando o resultado da função anterior não vai diretamente no primeiro argumento da função subsequente, precisa-se usar o operador _ (underline/underscore)2. Este operador serve como um placeholder: indica onde que o resultado da etapa anterior deve entrar. No exemplo abaixo, uso o placeholder para colocar a base de dados filtrada no argumento data dentro da função lm.

carros_4 <- subset(mtcars, cyl == 4)
fit <- lm(mpg ~ wt, data = carros_4)

mtcars |> 
  subset(cyl == 4) |> 
  lm(mpg ~ wt, data = _)

Por fim, temos o caso das funções anônimas3. Uma função anônima é simplesmente uma função sem nome que é chamada uma única vez. Infelizmente, a sintaxe de um pipe com uma função anônima é bastante carregada.

objeto |> (\(x, y, z, ...) {define função})()

# Nova sintaxe de funções anônimas (similar a lambda no Python)
objeto |> (\(x, y) {x^2 + y^2})()
# Antiga sintaxe de funções anônimas
objeto |> (function(x, y) {x^2 + y^2})()

O exemplo repete o código acima, mas agora usa uma função anônima para pegar o R2 ajustado da regressão.

mtcars |> 
  subset(cyl == 4) |> 
  lm(mpg ~ wt, data = _) |> 
  (\(x) {summary(x)$adj.r.squared})()

Limitações

Imagine agora que se quer calcular o erro absoluto médio de uma regressão. Lembre-se que o EAM é dado por

\[ \text{EAM} = \frac{1}{N}\sum_{i = 1}^{N}|e_{i}| \]

onde \(e_{i}\) é o resíduo da regressão. O código abaixo mostra como fazer isto usando pipes.

#> Estima uma regressão qualquer
fit <- lm(mpg ~ wt, data = mtcars)

#> Calcula o erro absoluto médio
fit |> residuals() |> abs() |> mean()
[1] 2.340642

Note, contudo, que a situação fica um pouco mais complicada no caso em que se quer calcular a raiz do erro quadrado médio.

\[ \text{REQM} = \sqrt{\frac{1}{N}\sum_{i = 1}^{N}(e_{i})^2} \]

Na sintaxe convencional temos

sqrt(mean(residuals(fit)^2))
[1] 2.949163

O problema é que a exponenciação acontece via um operador e não uma função. Nenhum dos exemplos abaixo funciona.

fit |> residuals() |> ^2 |> mean() |> sqrt()
Error: <text>:1:23: unexpected '^'
1: fit |> residuals() |> ^
                          ^
fit |> residuals()^2 |> mean() |> sqrt()
Error: function '^' not supported in RHS call of a pipe

Para chegar no mesmo resultado, novamente precisa-se usar uma sintaxe bastante esotérica que envolve passar o resultado de residuals para uma função anônima.

fit |> residuals() |> (\(y) {sqrt(mean(y^2))})()
[1] 2.949163

Resumo

Assim, apesar de muito útil, o operador pipe tem suas limitações. O operador sempre espera encontrar uma função à sua direita; a única maneira de seguir |> com um operador é criando uma função anônima, cuja sintaxe é um pouco carregada. Pode-se resumir os principais fatos sobre o operador pipe:

  1. Simplifica funções compostas. Na expressão x |> f |> g o operador |> aplica a função f sobre o objeto x usando x como argumento de f. Depois, aplica a função g sobre o resultado de f(x). Isto é equivalente a g(f(x)).
  2. Evita a definição de objetos intermediários. O uso de pipes evita que você precise “salvar” cada passo intermediário da aplicação de funções. Isto deixa seu espaço de trabalho mais limpo e também consome menos memória.
  3. Placeholder. Quando o objeto anterior não serve como o primeiro argumento da função subsequente, usa-se o placeholder para indicar onde ele deve ser inserido. x |> f(y = 2, data = _).
  4. Função anônima. Em casos mais complexos, é necessário montar uma função anônima usando x |> (\(y) {funcao})().

Aplicações comuns

tidyverse

O uso mais comum de pipes é junto com funções do tidyverse, que foram desenvolvidas com este intuito.

library(tidyverse)

As funções do tidyverse (quase) sempre recebem um data.frame como primeiro argumento; isto facilita a construção de código usando pipe, pois basta encadear as funções em sequência.

filtered_df <- filter(mtcars, wt == 2)
grouped_df <- group_by(filtered_df, cyl)
tbl <- summarise(grouped_df, avg = mean(mpg), count = n())

mtcars |> 
  filter(wt > 2) |> 
  group_by(cyl) |> 
  summarise(avg = mean(mpg))

A leitura do código fica mais “gramatical”: pegue o objeto mtcars filtre as linhas onde wt > 2 depois agrupe pela variável cyl e, por fim, tire uma média de mpg.

Pode-se terminar um pipe com uma chamada para um plot em ggplot2 para uma rápida visualização dos resultados

mtcars |> 
  filter(wt > 2) |> 
  group_by(cyl) |> 
  summarise(avg = mean(mpg)) |> 
  ggplot(aes(x = as.factor(cyl), y = avg)) +
  geom_col()

Não se recomenda fazer longas sequências de pipes, pois o código pode acabar muito confuso para quem está lendo. O exemplo abaixo mostra justamente isto.

library(realestatebr)

abecip <- get_abecip_indicators(cached = TRUE)

abecip$units |> 
  pivot_longer(-date) |> 
  mutate(yq = lubridate::floor_date(date, unit = "quarter")) |> 
  group_by(yq, name) |> 
  summarise(total = sum(value)) |> 
  separate(name, into = c("category", "type")) |> 
  filter(category == "units", type != "total") |> 
  ggplot(aes(x = yq, y = total, fill = type)) +
  geom_area() +
  scale_fill_brewer() +
  theme_light() +
  theme(legend.position = "top")

É recomendável quebrar o código acima em passos distintos. Além de ficar mais organizado, pode-se salvar objetos úteis como a tabela agrupada por trimestre, antes de se aplicar o filtro de unidades. A tabela final também fica salva num objeto, permitindo que se faça outros gráficos e análises com estes dados.

units <- abecip$units

#> Converte em long e agrega os dados por trimestre
tab_quarter <- units |> 
  pivot_longer(-date) |> 
  mutate(yq = lubridate::floor_date(date, unit = "quarter")) |> 
  group_by(yq, name) |> 
  summarise(total = sum(value)) |> 
  separate(name, into = c("category", "type")) 

#> Filtra apenas dados de unidades e retira o 'total'
tab_units <- tab_quarter |> 
  filter(category == "units", type != "total")

#> Faz o gráfico
ggplot(tab_units, aes(x = yq, y = total, fill = type)) +
  geom_area() +
  scale_fill_brewer() +
  theme_light() +
  theme(legend.position = "top")

sf

O pacote sf também funciona bem com pipes pois há vários casos em que se quer aplicar múltiplas funções num mesmo objeto.

# Transforma um data.frame num objeto espacial (pontos)
# depois faz a interseção dos pontos num polígono e
# por fim limpa as geometrias

dat |> 
  st_as_sf(coords = c("lng", "lat"), crs = 4326) |> 
  st_join(poly) |> 
  filter(!is.na(gid)) |> 
  st_make_valid()

O exemplo abaixo é emprestado do pacote censobr e mostra como combinar a manipulação de dados do dplyr com objetos espaciais manipulados via sf.

library(censobr)
library(geobr)
library(sf)
library(mapview)

# Importa alguns dados do Censo IBGE 2010
pop <- read_population(
  year = 2010,
  columns = c("code_weighting", "abbrev_state", "V0010")
  )
# Calcula a população total das áreas de ponderação no Rio de Janeiro
df <- pop |>
      filter(abbrev_state == "RJ") |>
      group_by(code_weighting) |>
      summarise(total_pop = sum(V0010)) |>
      collect()

# Import o shape das áreas de ponderação do Censo
areas <- read_weighting_area(3304557, showProgress = FALSE)

areas |> 
  # Converte o CRS da geometria
  st_transform(crs = 4326) |> 
  # "Limpa" as geometrias
  st_make_valid() |> 
  # Junta com os dados do Censo
  left_join(df, by = "code_weighting") |> 
  # Visualiza os dados num mapa interativo
  mapview(zcol = "total_pop")

Boas práticas

O guia de estilo do tidyverse propõe algumas orientações gerais sobre o uso de pipes. Abaixo eu reutilizao vários exemplos do guia.

Pipes longos

Primeiro, deve-se evitar de fazer um pipe long numa mesma linha. Esta recomendação visa melhorar a leitura do código. Também se recomenda deixar um espaço em branco antes do |>.

# Bom
iris |>
  group_by(Species) |>
  summarise(across(where(is.numeric), mean)) |> 
  ungroup() |> 
  pivot_longer(-Species, names_to = "measure") |> 
  arrange(value)
  
# Ruim
iris |> group_by(Species)|>
  summarise(across(where(is.numeric), mean))|>ungroup() |> 
  pivot_longer(-Species, names_to = "measure") |> arrange(value)
# A tibble: 12 × 3
   Species    measure      value
   <fct>      <chr>        <dbl>
 1 setosa     Petal.Width  0.246
 2 versicolor Petal.Width  1.33 
 3 setosa     Petal.Length 1.46 
 4 virginica  Petal.Width  2.03 
 5 versicolor Sepal.Width  2.77 
 6 virginica  Sepal.Width  2.97 
 7 setosa     Sepal.Width  3.43 
 8 versicolor Petal.Length 4.26 
 9 setosa     Sepal.Length 5.01 
10 virginica  Petal.Length 5.55 
11 versicolor Sepal.Length 5.94 
12 virginica  Sepal.Length 6.59 

Funções longas

No caso de funções longas, deve-se quebrar/indentar o código.

#> Bom
iris |>
  group_by(Species) |>
  summarise(
    Sepal.Length = mean(Sepal.Length),
    Sepal.Width = mean(Sepal.Width),
    Species = n_distinct(Species)
  )

#> Bom
iris |>
  group_by(Species) |>
  summarise(Sepal.Length = mean(Sepal.Length),
            Sepal.Width = mean(Sepal.Width),
            Species = n_distinct(Species))

#> Ruim
iris |>
  group_by(Species) |>
  summarise(Sepal.Length = mean(Sepal.Length), Sepal.Width = mean(Sepal.Width), Species = n_distinct(Species))

Pipes curtos

Este aqui é um gatilho pessoal; tento evitar ao máximo usar um pipe com uma única função. Pipes são úteis quando se aplica uma sequência de funções a um mesmo objeto; faz pouco sentido usar um pipe quando se aplica somente uma função.

#> Bom
plot(AirPassengers)
mean(AirPassengers)

#> Horrível
AirPassengers |> plot()
#> Pior ainda
AirPassengers |> mean(, na.rm = TRUE)
#> Péssimo
mtcars |> ggplot() + geom_point(aes(x = wt, y = mpg))

Pipes dentro de funções

É possível utilizar pipes dentro de funções para fazer pequenas transformações. Pessoalmente, acho que isto deixa o código confuso; quase sempre vale a pena criar um objeto intermediário ao invés de usar o pipe.

O código abaixo, por exemplo, usa a base economics_long, que reúne um conjunto de séries de tempo, e indexa elas na primeira observação. Assim o primeiro valor de cada série tem valor igual a 100 e os valores subsequentes são “proporcionais” a este valor inicial.

Para chegar neste cálculo eu uso um pipe, dentro de uma sequência de pipes.

economics_long |> 
  left_join(economics_long |> 
              filter(date == min(date)) |> 
              select(variable, base = value)
            ) |> 
  group_by(variable) |> 
  mutate(index = value / base * 100) |> 
  filter(date <= as.Date("1980-01-01"))

A versão alternativa do código cria um objeto intermediário com os valores iniciais de cada uma das séries.

base_index <- economics_long |> 
  filter(date == min(date)) |> 
  select(variable, base = value)

economics_index <- economics_long |> 
  left_join(base_index, by = "variable") |> 
  group_by(variable) |> 
  mutate(index = value / base * 100) |> 
  filter(date <= as.Date("1980-01-01"))

Faço um gráfico do resultado final para tornar evidente o que está acontecendo.

ggplot(economics_index, aes(x = date, y = index)) +
  geom_line() +
  facet_wrap(vars(variable))

Qual pipe usar?

Como comentei acima, o pipe nativo do R foi inspirado no pipe do pacote magrittr, um dos mais populares pacotes criados. Há algumas pequenas diferenças entre o %>% e o |>.

Primeiro, em termos de eficiência, o |> é levemente mais rápido, mas a diferença é praticamente imperceptível.

library(microbenchmark)

microbenchmark(
  {rnorm(n = 1000) |> mean()},
  {rnorm(n = 1000) %>% mean()}
  )
Unit: microseconds
                               expr    min      lq     mean median     uq
      {     mean(rnorm(n = 1000)) } 28.536 29.0075 30.17026 29.315 30.094
 {     rnorm(n = 1000) %>% mean() } 29.438 29.9300 30.82503 30.299 31.078
    max neval cld
 58.015   100   a
 52.726   100   a

A principal diferença entre os dois é o uso do placeholder. O operador placeholder do magrittr é o ponto . e é mais versátil que o _. No exemplo abaixo, eu consigo colocar a tabela filtrada dentro da função rownames durante o pipe.

mtcars %>%
  filter(cyl == 6) %>%
  mutate(label = rownames(.))

Isto não funciona com o pipe nativo.

mtcars |> 
  filter(cyl == 6) |> 
  mutate(label = rownames(_))
Error: invalid use of pipe placeholder

Isto pode ser bastante conveniente quando se lida com objetos espaciais. Imagine o caso em que se tem um objeto criado com sf que guarda polígonos e se quer encontrar as coordenadas dos centróides destes polígonos.

dat %>%
  st_transform(crs = 4674) %>%
  mutate(
    center = st_centroid(.),
    coords = st_coordinates(center)
    )

Também consegue-se usar o . junto com operadores e não apenas com funções. O código abaixo calcula a raiz do erro quadrado médio dos resíduos da regressão. Como vimos acima, isto não é possível com o pipe nativo e exigiria uma função anônima.

fit <- lm(mpg ~ wt, data = mtcars)

fit %>%
  residuals() %>%
  .^2 %>%
  mean() %>%
  sqrt()

O . pode ser seguido de outros operadores como o $4. Assim pode-se extrair uma coluna específica depois de filtrar uma tabela.

mtcars %>%
  filter(cyl == 6) %>%
  .$mpg

O . também pode ser fornecido para múltiplos argumentos como no exemplo abaixo.

mtcars %>%
  anti_join(x = ., y = .)
 [1] mpg  cyl  disp hp   drat wt   qsec vs   am   gear carb
<0 rows> (or 0-length row.names)
mtcars |> 
  anti_join(x = _, y = _)
Error: pipe placeholder may only appear once

Por fim, o pipe nativo também é inútil quando se usa o pacote data.table. Isto acontece por causa da maneira como o data.table encadeia as suas operações.

library(data.table)
dtmtcars <- copy(mtcars)
dtmtcars <- setDT(dtmtcars)

Na sintaxe do data.table é possível encadear as operação naturalmente usando [.

# Calcula a média de 'mpg' por 'cyl' e depois ordena segundo 'cyl'
dtmtcars[, .(mpg = mean(mpg)), by = cyl][order(cyl)]
     cyl      mpg
   <num>    <num>
1:     4 26.66364
2:     6 19.74286
3:     8 15.10000

Muitos, contudo, preferem usar o pipe %>% para encadear as operações, já que o código fica mais legível (especialmente em operações mais longas).

dtmtcars[, .(mpg = mean(mpg)), by = cyl] %>%
  .[order(cyl)]
     cyl      mpg
   <num>    <num>
1:     4 26.66364
2:     6 19.74286
3:     8 15.10000

Novamente, como o placeholder do pipe nativo espera sempre receber uma função, o código abaixo não funciona.

dtmtcars[, .(mpg = mean(mpg)), by = cyl] |>
  _[order(cyl)]
Error: pipe placeholder can only be used as a named argument

A solução, como vimos acima, seria criar uma função anônima. Neste caso, também seria possível disfarçar o [ como uma função (por favor, não faça isto).

.p <- `[`

dtmtcars[, .(mpg = mean(mpg)), by = cyl] |>
  .p(order(cyl))
     cyl      mpg
   <num>    <num>
1:     4 26.66364
2:     6 19.74286
3:     8 15.10000

Resumo

O pipe é um operador simples que serve para deixar o código mais limpo. Sempre é uma boa escolha usar pipes quando se quer aplicar múltiplas funções num mesmo objeto. O custo do |> é mínimo em termos de eficiência; além disso, ele diminui a necessidade de criar objetos intermediários o que poupa memória do sistema.

O pipe nativo é embutido no R a partir da versão 4.1.0 enquanto o %>% exige library(magrittr). Vale notar que muitos pacotes carregam o %>% automaticamente. De fato, pacotes como dplyr, leaflet, mapview, rvest, gt, flextable e tantos outros são praticamente inutilizáveis sem pipes5.

Antes de pensar em usar o pipe nativo, vale a pena reforçar que ele foi criado recentemente. Isto significa que qualquer código ou pacote desenvolvido com uso de pipes vai exigir a versão 4.1.0 ou superior, o que pode exigir que o usuário atualize a sua versão do R. Além disso, algumas funcionalidades do pipe nativo mudaram nas versões 4.2 e 4.3 o que pode complicar ainda mais o uso do pipe. Em contrapartida, o pipe do magrittr é bastante estável e seu comportamento é consistente há muitos anos.

Pessoalmente, tento utilizar o |> nos posts do blog, mas sem nunca utilizar o _. Quando preciso de um placeholder utilizo o %>%, mas evito usá-lo com operadores como .^2.

Referências

Footnotes

  1. Para a lista completa de mudanças veja News and Notes.↩︎

  2. Tecnicamente, o placeholder foi apenas introduzido na versão 4.2.0 como uma melhoria em relação ao pipe nativo implementado anteriormente. “In a forward pipe |> expression it is now possible to use a named argument with the placeholder _ in the rhs call to specify where the lhs is to be inserted. The placeholder can only appear once on the rhs.”. Link original.↩︎

  3. A notação abaixo de função anônima, usando \(x), também foi introduzida na versão 4.1.0 do R. Antigamente, para se definir uma função era necessário usar function(x).↩︎

  4. Vale notar que este comportamento foi introduzido também no placeholder do pipe nativo a partir da versão 4.3.0 do R. Contudo, este comportamento ainda está em fase experimental. “As an experimental feature the placeholder _ can now also be used in the rhs of a forward pipe |> expression as the first argument in an extraction call, such as _$coef.”. Link Original.↩︎

  5. Uma quantidade enorme de pacotes utiliza o magrittr como dependência. Veja a página do CRAN.↩︎