Tracks
/
R
R
/
Ejercicios
/
Club del queso
Club del queso

Club del queso

Ejercicio de aprendizaje

Introducción

¿Qué es la programación funcional?

Para nuestros fines, estas son algunas características clave:

  • Funciones puras, que mapean 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 reciben 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 sencilla de hacer el trabajo. Por lo general, el análisis de datos es el objetivo, y la programación es solo un medio para lograrlo.

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).
  • Cualquier argumento restante se puede 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 siempre son obligatorios (esto difiere de otros lenguajes).

A algunos programadores les resulta útil pronunciar el pipe como «then» al revisar el código: haz el paso 1, LUEGO 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 es posible que lo veas mencionado como el «pipe nativo».

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

Aplicar funciones

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 usan solo funciones vectorizadas estén ellas mismas vectorizadas de forma automática. 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 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 tiene toda una familia de funciones *apply() para operar sobre ellas. De forma más técnica, los programadores suelen llamarlas «funciones de orden superior» y los matemáticos «funcionales», porque reciben 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 en listas cuando sea necesario.

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

Quienes estén familiarizados con el concepto Switch quizá noten que, como alternativa, podríamos reemplazar el switch escalar por recode_values() vectorizada de la biblioteca dplyr. La vectorización es buena, y el R moderno ofrece varias 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 que sea 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 sencillas, 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 muchas otras funciones apply. Ofrecer un reemplazo más simple y más consistente para 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 consistencia del diseño y la claridad de los mensajes de error.

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

En purrr hay muchas funciones diversas, que no se dejan resumir con facilidad. Algunos recursos para navegar por la biblioteca son:

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 para tener en cuenta al elegir una función:

  • ¿Qué quieres devolver? 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 posicional de cada elemento 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 paralelas, 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 de predicado (booleano), 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 muy anidados? Existen 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 bugs difíciles de encontrar.

Funciones reduce

Por lo general, las funciones map tienen un valor de retorno 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. 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() está incorporada (como muchas otras funciones estadísticas). Sin embargo, necesitamos una forma de aplicar funciones arbitrarias de reducción de dimensiones a través de 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 relevante es reduce(), que corresponde al bien conocido (entre los entusiastas de los algoritmos) framework MapReduce.

El primer argumento es, como siempre, 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 en las que 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 están las funciones correspondientes accumulate() y accumulate2(). La sintaxis es similar a la de reduce(), con algunos argumentos opcionales extra 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 vectoriales 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 cumplan algún predicado.

En el Concepto de Filtrado de vectores vimos una forma idiomática de hacer esto.

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.

En consecuencia, 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.

También tenemos every(), some() y none(), para reemplazar a all() y any() al devolver un único valor lógico.

Instrucciones

Estamos creando un club del queso, que usará aprendizaje automático para seleccionar nuevos quesos que ofrecer a nuestros clientes amantes del queso, según sus historiales y gustos.

Los nuevos miembros deben completar una encuesta inicial para que podamos reunir algunos datos básicos con los que empezar. A partir de esto, se ha descubierto que hay un subconjunto de clientes enfáticos que carecen de matices en sus críticas. Como esto puede llegar a sesgar de forma irrevocable un algoritmo más matizado, hay un algoritmo aparte configurado para atender sus necesidades. Se te pide que proporciones algunas funciones auxiliares para manejar sus datos.

Note

Aunque puede haber distintas formas de resolver las siguientes tareas, cada una se puede resolver con no más de dos funciones de orden superior.

1. Clasifica a los clientes

El sistema de calificaciones tiene una base de cinco estrellas, que simplemente consiste en los números enteros 1:5. Los clientes enfáticos solo darán calificaciones de 1 o 5, y queremos saber si un cliente muestra este comportamiento.

Implementa all_15(), que recibe un vector de calificaciones y devuelve true si todas las calificaciones son 1 o 5, y false en caso contrario.

all_15(c(2, 3, 4, 4, 1))
#> [1] FALSE

all_15(c(1, 5, 5, 1, 5))
#> [1] TRUE

2. Nombra a los clientes

Necesitamos asociar el nombre del cliente (o su ID) con sus calificaciones.

Implementa name_customers(), que recibe un vector de nombres y una lista de calificaciones, y los «combina» en una lista de listas.

Los elementos de las listas internas deben tener los nombres name y rating.

names <- c("c1", "c2")
ratings <- list(c(2, 3, 5), c(1, 1, 5))
name_customers(names, ratings)
#> [[1]]
#> [[1]]$name
#> [1] "c1"

#> [[1]]$rating
#> [1] 2 3 5

#> [[2]]
#> [[2]]$name
#> [1] "c2"

#> [[2]]$rating
#> [1] 1 1 5

3. Separa a los clientes enfáticos

Necesitamos separar a los clientes más enfáticos de los demás.

Implementa emphatics(), que recibe clientes y calificaciones. Devuelve una lista restringida a quienes solo usan calificaciones de 1 o 5 estrellas.`

# names and ratings as in the task 2 example

emphatics(names, ratings)
#> [[1]]
#> [[1]]$name
#> [1] "c2"

#> [[1]]$rating
#> [1] 1 1 5

4. Convierte las calificaciones a binario

Como los clientes enfáticos solo usan calificaciones de 1 y 5, será más conveniente desde el punto de vista computacional cambiarlas a 0 y 1.

Implementa to_binary(), que recibe un vector de calificaciones enfáticas. Devuelve las calificaciones en binario, donde 1 se ha cambiado a 0 y 5 se ha cambiado a 1.

to_binary(c(1, 1, 5, 5, 1))
#> [1] 0 0 1 1 0

5. Calcula el promedio de calificaciones a lo largo del tiempo

Quieres seguir mejorando las recomendaciones para cada cliente (no solo para los enfáticos) con el tiempo, a medida que entiendes mejor sus gustos.

Implementa satisfactions(), que recibe un vector de calificaciones. Devuelve un vector de promedios acumulados hasta ese punto. Redondea los resultados a 2 decimales.

¡Ojalá los números sigan creciendo, para tener clientes felices!

satisfactions(c(1, 2, 2, 3))
#> [1] 1.00 1.50 1.67 2.00
Editar en GitHub El enlace se abre en una ventana o una pestaña nuevas
R Exercism

¿Todo listo para empezar Club del queso?

Regístrate en Exercism para aprender y dominar R con 21 conceptos111 ejercicios y mentoría humana real, todo gratis.