Rutas
/
R
R
/
Ejercicios
/
Club del queso
Club del queso

Club del queso

Ejercicio de aprendizaje

Introducción

¿Qué es la programación funcional?

Para nosotros, estas son algunas de sus 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 toman 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 un trabajo. Por lo general, el análisis de datos es el objetivo, y la programación es solo un medio para ese fin.

El pipe

El uso del pipe |> 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 de 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, el pipe debe apuntar a una llamada a función, no solo a 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 y LUEGO haz el paso 2...

El siguiente ejemplo 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».

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

Funciones de aplicación

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

Las funciones que solo usan funciones vectorizadas probablemente 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 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 tiene toda una familia de funciones *apply() para operar sobre ellas. Técnicamente, los programadores suelen llamarlas «funciones de orden superior» y los matemáticos, «funcionales», porque toman funciones como argumentos.

Por ahora, fíjate en 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 haga falta.

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

Si conoces el concepto de switch, te darás cuenta de que, como alternativa, podemos sustituir el switch escalar por la función vectorizada recode_values() de la biblioteca 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 trata de 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

La biblioteca purrr

lapply y sapply son sencillas, conocidas para la mayoría de quienes usan R, y siguen siendo bastante populares para vectores y listas simples.

El R base también tiene muchas otras funciones de aplicación. Ofrecer una alternativa más simple y consistente 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 consistencia en el diseño y la claridad de los mensajes de error.

Menos limitado por la retrocompatibilidad que el R base, este paquete acerca mucho más R a la programación funcional moderna que encontramos en otros lenguajes.

Las funciones de purrr son muchas y muy variadas, y no es fácil resumirlas. Algunos recursos que ayudan a moverse por la biblioteca:

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 a lapply() con otro nombre, y quizá con mejores mensajes de error.

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

Algunas preguntas que plantearte a la hora de 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 de posición de cada elemento de la entrada? Añade i delante de 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í, plantéate usar map_if() para aplicar un filtro por predicado (booleano), map_at() para especificar criterios de índice de posición, o head_while() para procesar la entrada hasta que un elemento no cumpla un predicado.
  • ¿Los datos de entrada están muy anidados? Hay 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 aplicarla de forma recursiva.

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

Además, purrr es (a propósito) 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 de reducción

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

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

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

El primer argumento es, como de costumbre, los datos de entrada. El segundo 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

¿Da igual aplicar 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 .dir opcional.

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

Igual que con map() y map2(), también hay una 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 de funciones vectoriales trató cumsum() y cumprod() en el R base, y la biblioteca dplyr tiene cummean() para medias numéricas, y cumall() y cumany() para vectores lógicos.

Funciones de filtrado

A menudo queremos incluir (o excluir) los elementos de la entrada que cumplen cierto predicado.

En el concepto de filtrado de vectores 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 flujos 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 el pipe.

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).

Las funciones correspondientes de purrr incluyen 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.

Instrucciones

Estamos poniendo en marcha un club de quesos que usará aprendizaje automático para seleccionar nuevos quesos que ofrecer a nuestros clientes amantes del queso, basándose en sus historiales y sus gustos.

Los nuevos miembros deben rellenar una encuesta inicial para que podamos recopilar algunos datos básicos con los que empezar. Gracias a ella, se ha descubierto que hay un subconjunto de clientes enfáticos que carecen de matices en sus críticas. Como esto puede acabar sesgando de forma irrevocable un algoritmo más matizado, hay un algoritmo aparte para atender sus necesidades. Se te pide que escribas algunas funciones auxiliares para manejar sus datos.

Note

Aunque puede haber distintas maneras de resolver las siguientes tareas, todas se pueden resolver con no más de dos funciones de orden superior.

1. Clasifica a los clientes

El sistema de valoraciones se basa en cinco estrellas, que simplemente se corresponden con los números enteros del 1:5. Los clientes enfáticos solo darán valoraciones de 1 o 5, y queremos saber si un cliente muestra este comportamiento.

Implementa all_15(), que recibe un vector de valoraciones y devuelve true si todas las valoraciones 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 valoraciones.

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

Los elementos de las listas interiores 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 valoraciones. Devuelve una lista restringida a aquellos que solo usan valoraciones 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 valoraciones a binario

Como los clientes enfáticos solo usan valoraciones 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 valoraciones enfáticas. Devuelve las valoraciones 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 la media de las valoraciones 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 valoraciones. Devuelve un vector de medias acumuladas hasta ese punto. Redondea los resultados a 2 decimales.

¡Esperemos que 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 pestaña nueva
R Exercism

¿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.