Tracks
/
R
R
/
Temario
/
Programación funcional
Pr

Programación funcional en R

1 ejercicio

Acerca de Programación funcional

¿Qué es la programación funcional?

Esa es una gran pregunta que merece una respuesta larga (Wikipedia ofrece una, Advanced R ofrece otra).

Para nuestros propósitos, estas son algunas características clave:

  • Funciones puras, que asignan entradas a salidas de forma consistente y sin efectos secundarios.
  • Datos inmutables.
  • Funciones de primera clase, que se pueden tratar como valores igual que cualquier otro tipo.
  • Funciones de orden superior, que toman otras funciones como argumentos o como valores de retorno.

Sin embargo, R no es un lenguaje funcional puro (a diferencia de, por ejemplo, Haskell).

Considera a R un lenguaje pragmático, que (cada vez más) ofrece capacidades funcionales cuando estas son una forma limpia y fácil de hacer un trabajo. En general, el análisis de datos es el objetivo, y la programación es solo un medio para ese fin.

Pipes

El uso de pipes |> para conectar funciones es una técnica cada vez más común en varios lenguajes.

En resumen:

  • Un resultado a la izquierda del pipe se convierte en el primer argumento de la función a la derecha (donde «derecha» ignora los saltos de línea opcionales).
  • Los argumentos restantes se pueden incluir en la llamada a la función.
  • En R, debemos hacer el pipe hacia una llamada a función, no solo hacia un nombre, así que los paréntesis son siempre obligatorios (esto difiere de algunos otros lenguajes).

Algunos programadores encuentran útil pronunciar el pipe como «entonces» al revisar código: haz el paso 1 ENTONCES haz el paso 2...

El ejemplo siguiente ilustra la sintaxis.

library(stringr)

"Monday-25-February" |> 
  str_split("-") |>     
  unlist() |>               # [1] "Monday"   "25"       "February"
  str_sub(1, 3) |>          # [1] "Mon" "25"  "Feb"
  str_to_upper() |>         # [1] "MON" "25"  "FEB"
  str_flatten(collapse = ", ")
#> [1] "MON, 25, FEB"

Este tipo de pipe se agregó al R base en la versión 4.1.0, y puede que lo veas mencionado como el «pipe nativo».

Antes, el uso de pipes se hacía con %>% del paquete magrittr (el nombre es un juego de palabras para los amantes del arte). El «pipe de magrittr» sigue disponible, pero los pipes nativos evitan la necesidad de un paquete externo, y la implementación es más simple en algunos detalles.

El editor RStudio (entre otros) usa Ctrl-Shift-M como atajo de teclado para agregar un pipe con los espacios adecuados.

Funciones apply

Vimos antes que muchas funciones en R están «vectorizadas»: operan sobre vectores completos, sin necesidad de escribir bucles explícitos.

Es probable que las funciones que solo usan funciones vectorizadas también se vectoricen automáticamente. En un ejemplo trivial:

triple <- function(x) return(3 * x)
triple(1:10)
 [1]  3  6  9 12 15 18 21 24 27 30

Sin embargo, las funciones más complejas pueden no aceptar entrada vectorial, por ejemplo si contienen bucles o bloques if-else.

Necesitamos una forma más general de aplicar funciones a vectores (similar a map() en algunos otros lenguajes).

Como R tiene una gran variedad de estructuras de datos en el lenguaje base, también tiene toda una familia de funciones *apply() para operar sobre ellas. Más técnicamente, los programadores suelen llamarlas «funciones de orden superior» y los matemáticos «funcionales», porque toman funciones como argumentos.

Por ahora, considera lapply() y sapply().

lapply()

«List-apply» está diseñada para trabajar con entradas de tipo lista, pero los vectores se convertirán silenciosamente a listas cuando sea necesario.

La salida es una lista, así que puede ser necesario descomprimirla para obtener un vector:

h <- function(size) {
    switch(size,
           "small" = 6,
           "medium" = 8,
           "large" = 10
    )
}

v <- c("small", "large", "medium")

h(v) # bad use of an unvectorized function
#> Error in switch(size, small = 6, medium = 8, large = 10) : 
#>   EXPR must be a length 1 vector

lapply(v, h)  # output is a list
#> [[1]]
#> [1] 6

#> [[2]]
#> [1] 10

#> [[3]]
#> [1] 8

lapply(v, h) |> unlist() # output is a vector
#> [1]  6 10  8

Los estudiantes familiarizados con el concepto de Switch pueden notar que, alternativamente, podríamos reemplazar el switch escalar por recode_values() vectorizada de la biblioteca dplyr. La vectorización es buena, y el R moderno ofrece múltiples formas de lograrla.

library(dplyr)

h <- function(size) {
  recode_values(size,
         "small" ~ 6,
         "medium" ~ 8,
         "large" ~ 10
  )
}

v <- c("small", "large", "medium")
h(v)
#> [1]  6 10  8

sapply()

«Simplified-apply» hace algo similar a lapply, pero además intenta simplificar el valor de retorno: por ejemplo, un vector con nombres en lugar de una lista. Según el contexto, puede ser necesario usar unname():

sapply(v, h)
 small  large medium 
     6     10      8 

sapply(v, h) |> unname()
#> [1]  6 10  8

La biblioteca purrr

lapply y sapply son simples, conocidas por la mayoría de los usuarios de R, y todavía bastante populares para vectores y listas simples.

El R base también tiene varias otras funciones apply:

  • vapply() es similar a sapply() pero además tiene un parámetro obligatorio para especificar el formato de salida.
  • apply() para matrices y arrays de dimensiones superiores.
  • mapply() para múltiples argumentos de lista o vector en paralelo.
  • tapply() es más difícil de explicar, pero funciona con operaciones de agrupación y arrays irregulares.
  • vectorize() es una función bastante misteriosa, que parece ser un envoltorio de mapply().

¡Basta ya!

Ofrecer un reemplazo más simple y más consistente para este tipo de confusión es un objetivo clave del Tidyverse.

Ahora existe el paquete purrr, que ofrece varias opciones adicionales como map(), a la vez que mejora la consistencia en el diseño y la claridad en los mensajes de error.

Menos limitado por la compatibilidad con versiones anteriores que el R base, este paquete acerca a R significativamente a la programación funcional moderna que se encuentra en otros lenguajes.

Hay muchas y muy diversas funciones en purrr, que desafían cualquier resumen fácil. Entre los recursos que ayudan a navegar por la biblioteca están:

Note

La biblioteca purrr opera casi por completo con datos unidimensionales: vectores y listas.

Veremos en un concepto posterior que la biblioteca dplyr ofrece funcionalidad equivalente para dataframes bidimensionales.

Las numerosas funciones map

La función map() aplica una función a cada elemento de la entrada, y siempre devuelve una lista.

Hasta aquí, suena como lapply(), solo que con otro nombre, y quizá con mejores mensajes de error.

Sin embargo, map() es solo un miembro de una familia grande.

Algunas preguntas que considerar al elegir una función:

  • ¿Qué quieres que devuelva? map() siempre devuelve una lista, pero si quieres un tipo particular de vector atómico usa map_chr() para strings, map_int() para enteros, etc.
  • ¿Necesitas cada índice posicional de la entrada? Antepón i a los nombres de las funciones, como en imap().
  • ¿Quieres procesar varias entradas en paralelo?
    • Reemplaza map por map2 en el nombre, como en map2_dbl() para dos entradas (por ejemplo, valores y pesos, para calcular una media ponderada)
    • Reemplaza por pmap para una lista de entradas en paralelo, como en pmap_chr(). Y sí, el paralelismo a nivel de hardware es posible.
  • ¿Quieres procesar todos los elementos de la entrada? Si no, considera map_if() para aplicar un filtro de predicado (Boolean), map_at() para especificar criterios de índice posicional, head_while() para procesar la entrada hasta que un elemento no cumpla un predicado.
  • ¿Los datos de entrada están profundamente anidados? Hay funciones para esa situación.
  • ¿Quieres que el valor de retorno sea siempre del mismo tipo que la entrada? Usa las funciones modify() en lugar de map, o modify_tree() para una aplicación recursiva.

Estas funciones tienen los datos de entrada como primer argumento siempre que es posible, para asegurar que funcionen bien con los pipes.

Además, purrr es (deliberadamente) más pedante que el R base. Si pides algo específico, las funciones devolverán exactamente eso o se detendrán con un mensaje de error. Duro, pero muy eficaz para reducir errores difíciles de encontrar.

Funciones reduce

Las funciones map generalmente tienen un valor de retorno de la misma longitud que la entrada.

Algunas otras funciones reducen la dimensionalidad de los datos, por ejemplo sum().

sum(1:100)
#> [1] 5050

En el caso anterior, convertimos un vector de longitud 100 en un entero de longitud 1. Veremos en un concepto futuro sobre matrices y arrays que el concepto de reducción de dimensiones es más general.

La función sum() viene integrada (como muchas otras funciones estadísticas). Sin embargo, necesitamos una forma de aplicar funciones arbitrarias que reducen dimensiones a lo largo de una estructura de datos, usando alguna función de orden superior equivalente a map().

En varios otros lenguajes se usan nombres de funciones como fold, foldl y foldr. En R, la función relevante es reduce(), que corresponde al bien conocido (entre los entusiastas de los algoritmos) marco de trabajo MapReduce.

El primer argumento es, como de costumbre, los datos de entrada. El segundo argumento es una función de 2 argumentos: a menudo una función anónima, pero posiblemente algo tan simple como un operador aritmético como +.

# surround an infix operator with backticks, to use as a function
reduce(1:100, `+`)
#> [1] 5050  # same as sum(1:100)

# anonymous function, alternately add and subtract
reduce(1:10, \(accum, nextval) ifelse(nextval %% 2 == 0, accum + nextval, accum - nextval))
#> [1] 7

¿Importa si aplicamos la función desde el principio o desde el final de la entrada?

No con operadores asociativos como + o *. Claramente, (1 + 2) + 3 == 1 + (2 + 3).

En cambio, - y / no son asociativos:

(1 - 2) - 3
#> [1] -4
1 - (2 - 3)
#> [1] 2

Para las funciones donde la dirección importa, hay un argumento opcional .dir.

# default is .dir = "forward"
reduce(1:10, `-`)
#> [1] -53
reduce(1:10, `-`, .dir = "backward")
#> [1] -5

Al igual que con map() y map2(), también hay una función reduce2() para operar sobre dos vectores de entrada en paralelo.

En R, nos gusta trabajar con vectores. Supón que, en lugar de un único valor final, quieres ver todos los pasos intermedios.

Para eso, las funciones correspondientes son accumulate() y accumulate2(). La sintaxis es similar a reduce(), con algunos argumentos opcionales adicionales para especificar el tipo de salida.

accumulate(1:10, `+`)
#> [1]  1  3  6 10 15 21 28 36 45 55

Algunos tipos comunes de accumulate tienen sus propias funciones. El concepto de Funciones de vectores trató cumsum() y cumprod() en el R base, y la biblioteca dplyr tiene cummean() para promedios numéricos, cumall() y cumany() para vectores lógicos.

Funciones de filtrado

Con frecuencia, queremos incluir (o excluir) elementos de la entrada que coincidan con algún predicado.

En el concepto de Filtrado de vectores vimos una forma idiomática de hacerlo.

v <- 1:3
v[v >= 2]
#> [1] 2 3

Esto se usa mucho en el R tradicional, pero encaja mal en los pipelines funcionales modernos.

Por consiguiente, purrr ofrece un conjunto de funciones de predicado, que siguen la convención habitual de argumentos f(input, func, ...) para funcionar con pipes.

Los nombres son un poco distintos de los de la mayoría de los otros lenguajes (nombres como filter y drop ya significan algo completamente distinto en R).

Las funciones correspondientes de purrr incluyen keep() y discard(), además de varias otras.

Además, tenemos every(), some() y none(), que reemplazan a all() y any() al devolver un único valor lógico.

Recursión

La mayoría de los lenguajes funcionales usan la recursión como su alternativa preferida a los bucles.

R tiene otras opciones, a menudo mejores: funciones vectorizadas y funciones de orden superior.

Sin embargo, la recursión es compatible con R, y puede ser útil en contextos como el recorrido de estructuras de árbol.

La profundidad máxima de recursión es 5000 de forma predeterminada, lo cual es relativamente generoso (Python usa 1000 de forma predeterminada). Es posible anular el valor predeterminado.

La optimización de llamadas de cola no es estándar en R, aunque se han desarrollado algunos trucos para lograrla.

La tradición exige que mostremos un ejemplo de factorial en este punto, como el ejemplo clásico de una definición recursiva.

fact <- function(n) ifelse(n == 0, 1, n * fact(n - 1))
fact(5)
#> [1] 120

# some more idiomatic R:
factorial(5)
#> [1] 120
prod(1:5)
#> [1] 120

Puedes usar la recursión en R, y a veces es valiosa, pero a menudo hay enfoques más simples.

Editar en GitHub El enlace se abre en una ventana o pestaña nueva

Aprende Programación funcional