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?

Es una pregunta ambiciosa que merece una respuesta larga (Wikipedia ofrece una, Advanced R ofrece otra).

Para lo que nos ocupa, 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 pueden tratarse como valores igual que cualquier otro tipo.
  • Funciones de orden superior, que reciben otras funciones como argumentos o como valores devueltos.

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

Considera R un lenguaje pragmático que (cada vez más) ofrece capacidades funcionales cuando estas son una forma limpia y sencilla de hacer el trabajo. Por lo general, el análisis de datos es el objetivo, y la programación no es más que un medio para lograrlo.

Los 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 pueden incluirse 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 siempre son necesarios (esto difiere de otros lenguajes).

A algunos programadores les resulta útil pronunciar el pipe como «then» cuando revisan código: haz el paso 1, THEN 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 añadió al R base en la versión 4.1.0, y puede que lo veas mencionado como el «pipe nativo».

Antes, el pipe 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 su implementación es más sencilla en algunos detalles.

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

Las funciones apply

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

Es probable que las funciones que solo usan funciones vectorizadas estén ellas mismas vectorizadas automáticamente. 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 puede que no acepten una 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 otros lenguajes).

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

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

lapply()

«List-apply» está pensado para trabajar con entradas de tipo lista, pero los vectores se convertirán en listas de forma silenciosa cuando sea necesario.

La salida es una lista, así que puede que haya que desempaquetarla 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

Quienes estén familiarizados con el concepto Switch quizá se den cuenta de que también podríamos sustituir el switch escalar por la versión vectorizada de recode_values() del paquete dplyr. La vectorización es buena, y el R moderno ofrece varias formas de conseguirla.

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 devuelto: por ejemplo, un vector con nombres en lugar de una lista. Según el contexto, puede que sea necesario usar unname():

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

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

El paquete purrr

lapply y sapply son sencillas, conocidas por la mayoría de los usuarios de R y siguen siendo muy populares para vectores y listas simples.

El R base también tiene otras cuantas funciones de tipo 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 varios 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 una alternativa más sencilla y coherente a este tipo de confusión es uno de los objetivos clave del Tidyverse.

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

Menos limitado por la retrocompatibilidad que el R base, este paquete acerca R de forma notable a la programación funcional moderna que se encuentra en otros lenguajes.

En purrr hay muchísimas funciones diversas, que no admiten un resumen sencillo. Algunos recursos que te ayudarán a moverte por el paquete:

Note

El paquete purrr opera casi por completo con datos unidimensionales: vectores y listas.

En un concepto posterior veremos que el paquete dplyr ofrece la misma funcionalidad para dataframes bidimensionales.

Las muchas funciones map

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

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

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

Algunas preguntas que conviene plantearse al elegir una función:

  • ¿Qué quieres que devuelva? map() siempre devuelve una lista, pero si quieres un tipo concreto de vector atómico, usa map_chr() para strings, map_int() para enteros, etc.
  • ¿Necesitas el índice de cada posición de la entrada? Añade el prefijo i a los nombres de las funciones, como en imap().
  • ¿Quieres procesar varias entradas en paralelo?
    • Sustituye map por map2 en el nombre, como en map2_dbl() para dos entradas (por ejemplo, valores y pesos, para calcular una media ponderada)
    • Sustituye 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 es así, piensa en map_if() para aplicar un filtro por predicado (Boolean), map_at() para especificar criterios por índice de posición, head_while() para procesar la entrada hasta que un elemento no cumpla un predicado.
  • ¿Los datos de entrada están muy anidados? Existen funciones para esa situación.
  • ¿Quieres que el valor devuelto 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 que funcionen bien con los pipes.

Además, purrr es (a propósito) más quisquilloso que el R base. Si pides algo concreto, las funciones devolverán exactamente eso o se detendrán con un mensaje de error. Duro, pero muy eficaz para reducir bugs difíciles de encontrar.

Las funciones de reducción

Por lo general, las funciones map tienen un valor devuelto de la misma longitud que la entrada.

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. En un futuro concepto sobre matrices y arrays veremos que el concepto de reducción de dimensiones es más general.

La función sum() es nativa (al igual que muchas otras funciones estadísticas). Sin embargo, necesitamos una forma de aplicar funciones arbitrarias de reducción de dimensiones a una estructura de datos, usando alguna función de orden superior equivalente a map().

En otros lenguajes se usan nombres de funciones como fold, foldl y foldr. En R, la función correspondiente es reduce(), que se corresponde con el conocido (entre los aficionados a los algoritmos) framework MapReduce.

El primer argumento es, como de costumbre, los datos de entrada. El segundo argumento es una función de dos argumentos: a menudo una función anónima, pero puede ser 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 *. Está claro que (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 en las que importa la dirección, 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 existe la función reduce2() para operar con 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 están las funciones accumulate() y accumulate2(). La sintaxis es similar a la de 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 acumulación tienen sus propias funciones. El concepto Vector Functions trataba de cumsum() y cumprod() en el R base, y el paquete dplyr tiene cummean() para medias numéricas, y cumall() y cumany() para vectores lógicos.

Las funciones de filtrado

A menudo queremos incluir (o excluir) elementos de la entrada que cumplan algún predicado.

En el concepto Vector Filtering vimos una forma idiomática de hacerlo.

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

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

Por eso, 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 algo distintos de los de la mayoría de los otros lenguajes (nombres como filter y drop ya significan algo completamente distinto en R).

Entre las funciones correspondientes de purrr están keep() y discard(), además de varias otras.

También tenemos every(), some() y none(), que sustituyen a all() y any() a la hora de 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, R admite la recursión, 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 bastante generoso (en Python es 1000). Es posible cambiar ese valor predeterminado.

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

La tradición exige que mostremos aquí un ejemplo de factorial, como ejemplo clásico de 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 resulta valiosa, pero a menudo hay enfoques más sencillos.

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

Aprende Programación funcional