21 Iteración

21.1 Introducción

En funciones, hablamos sobre la importancia de reducir la duplicación en el código creando funciones en lugar de copiar y pegar. Reducir la duplicación de código tiene tres beneficios principales:

  1. Es más fácil ver el objetivo de tu código, lo diferente llama más atención a la vista que aquello que permanece igual.

  2. Es más sencillo responder a cambios en los requerimientos. A medida que tus necesidades cambian, solo necesitarás realizar cambios en un lugar, en vez de recordar cambiar en cada lugar donde copiaste y pegaste el código.

  3. Es probable que tengas menos errores porque cada línea de código es utilizado en más lugares.

Una herramienta para reducir la duplicación de código son las funciones, que reducen dicha duplicación al identificar patrones repetidos de código y extraerlos en piezas independientes que pueden reutilizarse y actualizarse fácilmente. Otra herramienta para reducir la duplicación es la iteración, que te ayuda cuando necesitas hacer la misma tarea con múltiples entradas: repetir la misma operación en diferentes columnas o en diferentes conjuntos de datos. En este capítulo aprenderás sobre dos paradigmas de iteración importantes: la programación imperativa y la programación funcional. Por el lado imperativo, tienes herramientas como bucles for (del inglés para) y bucles while (del inglés mientras), que son un gran lugar para comenzar porque hacen que la iteración sea muy explícita, por lo que es obvio qué está pasando. Sin embargo, los bucles for son bastante detallados y requieren bastante código que se duplica para cada bucle. La programación funcional (PF) ofrece herramientas para extraer este código duplicado, por lo que cada patrón común de bucle obtiene su propia función. Una vez que domines el vocabulario de PF, podrás resolver muchos problemas comunes de iteración con menos código, más facilidad y menos errores.

21.1.1 Pre requisitos

Una vez que hayas dominado los bucles for proporcionados por R base, aprenderás algunas de las potentes herramientas de programación proporcionadas por purrr, uno de los paquetes principales de tidyverse.

21.2 Bucles for

Imagina que tenemos este simple tibble:

Queremos calcular la mediana de cada columna. Podrías hacerlo copiando y pegando el siguiente código:

Pero eso rompe nuestra regla de oro: nunca copie y pegue más de dos veces. En cambio, podríamos usar un bucle for:

Cada bucle tiene tres componentes:

  1. output: output <- vector("double", length(x)). Antes de comenzar el bucle, siempre debes asignar suficiente espacio para la salida. Esto es muy importante para la eficiencia: si aumentas el bucle for en cada iteración usando, por ejemplo, c () , el bucle for será muy lento.

    Una forma general de crear un vector vacío de longitud dada es la función vector (). Tiene dos argumentos: el tipo de vector (“logical”, “integer”, “double”, “character”, etc) y su longitud.

  2. La secuencia: i in seq_along (df). Este código determina sobre qué iterar: cada ejecución del bucle for asignará a i un valor diferente de seq_along (df). Es útil pensar en i como un pronombre, como “eso”.

    Es posible que no hayas visto seq_along () con anterioridad. Es una versión segura de la familiar 1:length(l), con una diferencia importante: si se tiene un vector de longitud cero, seq_along () hace lo correcto:

    Probablemente no vas a crear un vector de longitud cero deliberadamente, pero es fácil crearlos accidentalmente. Si usamos 1: length (x) en lugar de seq_along (x), es posible que obtengamos un mensaje de error confuso.

  3. El cuerpo: output[[i]] <- median(df[[i]]). Este es el código que hace el trabajo. Se ejecuta repetidamente, con un valor diferente para i cada vez. La primera iteración ejecutará output[[1]] <- median(df[[1]]), la segunda ejecutará output [[2]] <- median (df [[2]]), y así sucesivamente.

¡Eso es todo lo que hay para el bucle for! Ahora es un buen momento para practicar creando algunos bucles for básicos (y no tan básicos) usando los ejercicios que se encuentran a continuación. Luego avanzaremos en algunas variaciones del bucle for que te ayudarán a resolver otros problemas que surgirán en la práctica.

21.2.1 Ejercicios

  1. Escribir bucles for para:

    1. Calcular la media de cada columna en mtautos.
    2. Determinar el tipo de cada columna en vuelos.
    3. Calcular el número de valores únicos en cada columna de iris.
    4. Genera 10 normales aleatorias para cada valor de \(\mu = -10\), \(0\), \(10\) y \(100\).

    Piensa en el resultado, la secuencia y el cuerpo antes de empezar a escribir el bucle.

  2. Elimina el bucle for en cada uno de los siguientes ejemplos tomando ventaja de una función existente que trabaja con vectores:

  3. Combina tus habilidades para escribir funciones y bucles for:

    1. Escribe un bucle for que imprima (prints()) la letra de la canción de niños “Cinco ranitas verdes”.

    2. Convierte la canción infantil “10 monitos saltaban en la cama” en una función. Generalizar a cualquier cantidad de monitos en cualquier estructura para dormir.

    3. Convierte la canción “99 botellas de cerveza en la pared” en una función. Generalizar a cualquier cantidad, de cualquier tipo de recipiente que contenga cualquier líquido sobre cualquier superficie.

  4. Es común ver bucles for que no preasignan la salida y en su lugar aumentan la longitud de un vector en cada paso:

    ¿Cómo afecta esto el rendimiento? Diseña y ejecuta un experimento.

21.3 Variaciones de bucles for

Una vez que tienes el bucle for básico bajo tu cinturón, hay algunas variaciones que debes tener en cuenta. Estas variaciones son importantes independientemente de cómo hagas la iteración, así que no te olvides de ellas una vez que hayas dominado las técnicas de PF que aprenderás en la próxima sección.

Hay cuatro variaciones del ciclo for básico:

  1. Modificar un objeto existente, en lugar de crear un nuevo objeto.
  2. Iterar sobre nombres o valores, en lugar de índices.
  3. Manejo de outputs de longitud desconocida.
  4. Manejo de secuencias de longitud desconocida.

21.3.1 Modificar un objeto existente

Algunas veces quiere usar un bucle for para modificar un objeto existente. Por ejemplo, recuerda nuestro desafío de funciones. Queríamos reescalar cada columna en un data frame:

Para resolver esto con un bucle for, volvemos a pensar en los tres componentes:

  1. Output: ya tenemos el output — ¡es lo mismo que la entrada!

  2. Secuencia: podemos pensar en un data frame como una lista de columnas, por lo que podemos iterar sobre cada columna con seq_along (df).

  3. Cuerpo: aplicar rescale01 ().

Esto nos da:

Por lo general, se modificará una lista o un data frame con este tipo de bucle, así que recuerde usar [[, not [. Podrás haber visto que usamos [[ en todos los bucles for: creemos que es mejor usar [[ incluso para vectores atómicos porque deja en claro que queremos trabajar con un solo elemento.

21.3.2 Patrones de bucle

Hay tres formas básicas de hacer un bucle sobre un vector. Hasta ahora hemos visto la más general: iterar sobre los índices numéricos con for (i in seq_along (xs)), y extraer el valor con x [[i]]. Hay otras dos formas:

  1. Iterar sobre los elementos: for (x in xs). Esta forma es la más útil si solo te preocupas por los efectos secundario, como graficar o grabar un archivo, porque es dificil almacenar la salida (output) de forma eficiente.

  2. Iterar sobre los nombres: for (nm in names(xs)). te da el nombre, que se puede usar para acceder al valor con x [[nm]]. Esto es útil si queremos utilizar el nombre en el título de un gráfico o en el nombre de un archivo. Si estas creando una salida con nombre, asegúrate de nombrar el vector de resultados de esta manera:

  3. Iterar sobre los índices numéricos es la forma más general, porque dada la posición se puede extraer tanto el nombre como el valor:

21.3.3 Longitud de output desconocida

Es posible que algunas veces no sepas el tamaño que tendrá el output. Por ejemplo, imagina que quieres simular algunos vectores aleatorios de longitudes aleatorias. Podrías tentarte a resolver este problema haciendo crecer progresivamente el vector:

Pero esto no es muy eficiente porque en cada iteración, R tiene que copiar todos los datos de las iteraciones anteriores. En términos técnicos, obtiene un comportamiento “cuadrático” (\(O(n^2)\)), lo que significa que un bucle que tiene tres veces más elementos tomaría nueve (\(3^2\)) veces más tiempo de ejecución.

Una mejor solución es guardar los resultados en una lista, y luego combinarlos en un solo vector después de que se complete el ciclo:

Aquí usamos unlist () (del inglés deslistar) para aplanar una lista de vectores en un solo vector. Una opción más estricta es usar purrr :: flatten_dbl () (del inglés aplanar) — arrojará un error si la entrada no es una lista de dobles.

Este patrón ocurre también en otros lugares:

  1. Podriamos estar generando una cadena larga. En lugar de pegar (paste ()) juntos cada iteración con la anterior, guardamos el output en un vector de caracteres y luego combinamos ese vector en una cadena con paste(output, collapse = "").

  2. Podríamos estar generando un data frame grande. En lugar de enlazar (rbind ()) secuencialmente en cada iteración, guardamos el resultado en una lista y luego usamos dplyr::bind_rows(output) para combinar el output en un solo data frame.

Cuidado con este patrón. Cuando lo veamos, cambiemos a un objeto de resultado más complejo y luego lo combinamos en un paso al final.

21.3.4 Longitud de secuencia desconocida

A veces ni siquiera sabemos cuánto tiempo puede durar la secuencia de entrada. Esto es común cuando se hacen simulaciones. Por ejemplo, es posible que se quiera realizar un bucle hasta que se obtengan tres cabezas seguidas. No podemos hacer ese tipo de iteración con un bucle for. En su lugar, podemos utilizar un bucle while (del inglés mientras). Un bucle while es más simple que el bucle for porque solo tiene dos componentes, una condición y un cuerpo:

Un bucle while también es más general que un bucle for, porque podemos reescribir cualquier bucle for como un bucle while, pero no podemos reescribir cada bucle while como un bucle for:

Así es como podríamos usar un bucle while para encontrar cuántos intentos se necesitan para obtener tres cabezas (heads en inglés) seguidas:

Mencionamos los bucles while brevemente, porque casi nunca los usamos. Se utilizan con mayor frecuencia para la simulación, que está fuera del alcance de este libro. Sin embargo, es bueno saber que existen para que estemos preparado para problemas en los que el número de iteraciones no se conoce de antemano.

21.3.5 Ejercicios

  1. Imaginemos que tenemos un directorio lleno de archivos CSV que queremos leer. Tenemos sus ubicaciones en un vector, files <- dir("data/", pattern = "\\.csv$", full.names = TRUE), y ahora queremos leer cada uno con read_csv(). Escribe un bucle for que los cargue en un solo data frame.

  2. ¿Qué pasa si utilizamos for (nm in names(x)) y x no tiene names? ¿Qué pasa si solo algunos elementos están nombrados (named en inglés) ¿Qué pasa si los nombres (names en inglés) no son únicos?

  3. Escribe una función que imprima el promedio de cada columna numérica en un data frame, junto con su nombre. Por ejemplo, mostrar_promedio(iris) debe imprimir:

    (Desafío adicional: ¿qué función utilizamos para asegurarnos que los números queden alineados a pesar que los nombres de las variables tienen diferentes longitudes?)

  4. ¿Qué hace este código? ¿cómo funciona?

21.4 Bucles for vs. funcionales

Los bucles for no son tan importantes en R como en otros lenguajes porque R es un lenguaje de programación funcional. Esto significa que es posible envolver los bucles en una función y llamar a esa función en lugar de usar el bucle for directamente.

Para ver por qué esto es importante, consideremos (nuevamente) este simple data frame:

Imaginemos que queremos calcular la media de cada columna. Podríamos hacer eso con un bucle for:

Nos damos cuenta que vamos a querer calcular los promedios de cada columna con bastante frecuencia, por lo que lo extraemos en una función:

Pero entonces pensamos que también sería útil poder calcular la mediana y la desviación estándar, así que copiamos y pegamos la función col_mean () y reemplazamos mean () con median () y sd ():

¡Oh oh! Copiamos y pegamos este código dos veces, por lo que es hora de pensar cómo generalizarlo. Tengamos en cuenta que la mayoría de este código corresponde al for y es repetitivo, haciendo difícil ver la única cosa (mean (), median (), sd ()) que es diferente entre las funciones.

¿Qué podemos hacer si vemos un conjunto de funciones como esta?:

Con suerte, notaremos que hay mucha duplicación y la extraeremos en un argumento adicional:

Hemos reducido la posibilidad de errores (porque ahora tenemos 1/3 menos de código) y hemos facilitado la generalización de situaciones nuevas.

Podemos hacer exactamente lo mismo con col_mean (), col_median () y col_sd () agregando un argumento que proporciona la función para aplicar a cada columna:

La idea de pasar una función a otra es extremadamente poderosa, y es uno de los comportamientos que hacen de R un lenguaje de programación funcional. Puede que te tome un tiempo comprender la idea, pero vale la pena el esfuerzo. En el resto del capítulo, aprenderás y usarás el paquete purrr, que proporciona funciones que eliminan la necesidad de muchos bucles comunes. La familia de funciones de apply (aplicar en innglés) en base R (apply (), lapply (), tapply (), etc.) resuelve un problema similar, pero purrr es más consistente y por lo tanto es más fácil de aprender.

El objetivo de usar las funciones purrr en lugar de los bucles es permitir dividir los desafíos comunes de manipulación de listas en partes independientes:

  1. ¿Cómo podemos resolver el problema para un solo elemento de la lista? Una vez que encontramos la solución, purrr se encarga de generalizala a cada elemento de la lista.

  2. Si estamos resolviendo un problema complejo, ¿cómo podemos dividirlo en pequeñas etapas que nos permitan avanzar paso a paso hacia la solución? Con purrr, obtienes muchas piezas pequeñas que puedes ensamblar junto con el pipe.

Esta estructura facilita la resolución de nuevos problemas. También hace que sea más fácil entender nuestras soluciones a problemas antiguos cuando releemos un código anterior.

21.4.1 Ejercicios

  1. Lee la documentación para apply (). En el caso 2d, ¿qué dos bucles for generaliza?

  2. Adapta col_summary () para que solo se aplique a las columnas numéricas. Es posible que desees comenzar con la función is_numeric () que devuelve un vector lógico que tenga un TRUE por cada columna numérica.

21.5 Las funciones map (mapa en inglés)

El patrón de iterar sobre un vector, hacer algo con cada elemento y guardar los resultados es tan común que el paquete purrr proporciona una familia de funciones para que lo hagan por nosotros. Hay una función para cada tipo de output:

  • map () crea una lista.
  • map_lgl () crea un vector lógico.
  • map_int () crea un vector entero.
  • map_dbl () crea un vector doble.
  • map_chr () crea un vector de caracteres.

Cada función toma un vector como entrada, aplica una función a cada elemento y luego devuelve un nuevo vector que tiene la misma longitud (y tiene los mismos nombres) que la entrada. El tipo de vector está determinado por el sufijo de la función map.

Una vez que domines estas funciones, descubrirás que lleva mucho menos tiempo resolver los problemas de iteración. Pero nunca debes sentirse mal por usar un bucle for en lugar de una función de mapeo. Las funciones de mapeo son un nivel superior de abstracción, y puede llevar mucho tiempo entender cómo funcionan. Lo importante es que resuelvas el problema en el que estás trabajando, no que escribas el código más conciso y elegante (¡aunque eso es definitivamente algo hacia lo que quieres llegar!).

Algunas personas te dirán que evites los bucles porque son lentos. ¡Están equivocados! (Bueno, al menos están bastante desactualizados, ya que los bucles han dejado de ser lentos desde hace muchos años). Los principales beneficios de usar funciones como map () no es la velocidad, sino la claridad: hacen que tu código sea más fácil de escribir y leer.

Podemos usar estas funciones para realizar los mismos cálculos que el último bucle for. Esas funciones de resumen devolvían valores decimales, por lo que necesitamos usar map_dbl ():

Comparado con el uso de un bucle for, el foco está en la operación que se está realizando (es decir, mean (), median (), sd ()), y no en llevar la cuenta de las acciones requeridas para recorrer cada elemento y almacenar la salida. Esto es aún más evidente si usamos el pipe:

Existen algunas diferencias entre map _ * () y col_summary ():

21.5.1 Atajos

Existen algunos atajos que puedes usar con .f para ahorrar algo de escritura. Imagina que quieres ajustar un modelo lineal a cada grupo en un conjunto de datos. El siguiente ejemplo de juguete divide el dataset mtautos en tres partes (una para cada valor de cilindro) y ajusta el mismo modelo lineal a cada parte:

La sintaxis para crear una función anónima en R es bastante detallada, por lo que purrr provee un atajo conveniente: una fórmula unidireccional.

Hemos usado . como pronombre: se refiere al elemento actual de la lista (del mismo modo que i se refiere al índice actual en el loop for).

Cuando examinas múltiples modelos, puedes querer extraer un estadístico resumen como lo es \(R^2\). Para hacer eso primero necesitas correr summary() y luego extraer la componente r.squared (R-cuadrado). Podríamos hacerlo usando un atajo para las funciones anónimas:

Pero extraer las componentes con nombres es una operación común, por lo tanto purrr provee un atajo más corto: puedes usar una string.

También puedes usar un entero para seleccionar elementos de acuerdo a su posición:

21.5.2 R Base

Si estás familiarizado con la familia de funciones apply en R base, podrás haber notado algunas similitudes con las funciones de purrr:

Aquí nos enfocamos en las funciones de purrr ya que proveen nombres y argumentos consistentes, atajos útiles y en el futuro proveerán paralelización simple y barras de progreso.

21.5.3 Ejercicios

  1. Escribe un código que use una de las funciones de map para:

    1. Calcular la media de cada columna en mautos.
    2. Obtener de que tipo es cada columna en vuelos.
    3. Calcular la cantidad de valores únicos en cada columna de iris.
    4. Generar diez normales aleatorias para cada \(\mu = -10\), \(0\), \(10\), and \(100\).
  2. ¿Cómo puedes crear un vector tal que para cada columna en un cuadro de datos indique si corresponde o no a un factor?

  3. ¿Qué ocurre si usas las funciones map en vectores que no son listas? ¿Qué hace map(1:5, runif)? ¿Por qué?

  4. ¿Qué hace map(-2:2, rnorm, n = 5)? ¿Por qué? ¿Qué hace map_dbl(-2:2, rnorm, n = 5)? ¿Por qué?

  5. Reescribe map(x, function(df) lm(mpg ~ wt, data = df)) para eliminar todas las funciones anónimas.

21.6 Manejando los errores

Cuando usas las funciones map para repetir muchas operaciones, la probabilidad de que una de las operaciones falle es mucho más alta. Cuando esto ocurre, obtendrás un mensaje de error y no una salida. Esto es molesto: ¿Por qué un error evita que accedas a todo lo que funcionó? ¿Cómo te aseguras de que una manzana podrida no arruine todo el barril?

En esta sección aprenderás a manejar estas situaciones con una nueva función: safely(). safely() es un adverbio: toma una función (un verbo) y entrega una versión modificada. En este caso, la función modificada nunca lanzará un error. En cambio, siempre devolverá una lista de dos elementos:

  1. result es el resultado original. Si hubo un error, aparecerá como NULL,

  2. error es un objeto de error. Si la operación fue exitosa, será NULL.

(Puede que estés familiarizado con la función try() de R base. Es similar, pero dado que a veces entrega el resultado original y a veces un objeto de error, es más díficil para trabajar.)

Veamos esto con un ejemplo simple: log():

Cuando la función es exitosa, el elemento result contiene el resultado y el elemento error es NULL. Cuando la función falla, el elemento result es NULL y el elemento error contiene un objeto de error.

safely() está diseñada para funcionar con map:

Esto sería más fácil de trabajar si tuvieramos dos listas: una con todos los errores y otra con todas las salidas, Esto es fácil de obtener con purrr::transpose():

Queda a tu criterio como manejar los errores, pero típicamente puedes mirar los valores de x donde y es un error, o trabajar con los valores que y que están ok:

Purrr provee otros dos adverbios utiles:

21.7 Mapear sobre múltiples argumentos

Hasta ahora hemos mapeado sobre una única entrada. Pero a menudo tienes multiples entradas relacionadas y necesitas iterar sobre ellas en paralelo. Ese es el trabajo de las funciones map2() and pmap(). Por ejemplo, imagina que debes simular normales aleatorias con distintas medias. Ya sabes hacerlo con map():

¿Qué ocurre si también necesitas cambiar la desviación estándar? Una forma de hacerlo sería iterar sobre los índices e indexar en vectores de medias y desviaciones estándar:

Pero esto oscurece la intención del código. En su lugar podríamos usar map2() que itera sobre dos vectores en paralelo:

map2() genera esta serie de llamadas a funciones:

Observa que los argumentos que varían para cada llamada van antes de la función; argumentos que son los mismos para cada llamada van después.

Al igual que map(), map2() es una envolvente en torno a un loop for:

También te podrás imaginar map3(), map4(), map5(), map6() etc, pero eso se vuelve tedioso rápidamente. En cambio, purrr provee pmap() que toma una lista de argumentos. Puedes usar eso si quieres cambiar la media, desviación estándar y el número de muestras:

Esto se ve así:

Si no quieres nombrar todos los elementos de la lista, pmap() usará una coincidencia posicional al llamar a la función. Esto es un poco frágil y hace el código más difícil de entender, por lo que es mejor nombrar los argumentos:

Esto genera llamadas más largas, pero más seguras:

Dado que los argumentos son todos del mismo largo, tiene sentido almacenarlos en un data frame:

En cuanto el código se vuelva complicado, creemos que un data frame es una aproximación ya que se asegura de que cada columna tenga nombre y el mismo largo que las demás columnas.

21.7.1 Llamando distintas funciones

Existe un paso adicional en complejidad - así como cambias los argumentos de la función también puedes cambiar la función misma:

Para manejar esto, puedes usar invoke_map():

El primer argumento es una lista de funciones o vectores de caracteres de nombres de funciones. El segundo argumento es una lista de listas que entrega los argumentos que cambian en cada función. Los argumentos subsecuentes pasan a cada función.

Nuevamente, puedes usar tribble() para hacer que crear tuplas coincidentes sea un poco más fácil:

21.8 Walk

Walk es una alternativa a map que puedes usar cuando quieres llamar a una función por sus efectos colaterales, por sobre el valor entregado. Típicamente hacemos esto cuando queremos mostrar la salida en pantalla o escribir archivos al disco - lo importante es la acción, no el valor resultante. Aquí hay un ejemplo simple:

walk() no es generalmente útil si se compara con walk2() o pwalk(). Por ejemplo, si tienes una lista de gráficos y un vector con nombres de archivos, puedes usar pwalk() para guardar cada archivo en su ubicación correspondiente en el disco:

walk(), walk2() y pwalk() todas entregan .x invisiblemente, el primer argumento. Esto hace que sea útil para usar con cadenas de pipes.

21.9 Otros patrones para los loops for

Purr entrega algunas funciones que se abstraen de los loops for. Los usarás de manera menos frecuente que las funciones map, pero son útiles de conocer. El objetivo aquí es ilustrar brevemente cada función, afortunadamente las recordarás si ves un problema similar en el futuro. Luego puedes consultar la documentación para más detalles.

21.9.1 Funciones predicativas

Algunas funciones trabajan con funciones predicativas que entregan un valor TRUE o FALSE.

keep() y discard() mantienen los elementos de la entrada donde el predicado es TRUE o FALSE respectivamente:

some() y every() determinan si el predicado es verdadero para todos o para algunos de los elementos.

detect() encuentra el primer elemento donde el predicado es verdadero; detect_index() entrega su posición.

head_while() y tail_while() toman elementos al inicio y final de un vector cuando el predicado es verdadero:

21.9.2 Reduce y accumulate

A veces tendrás una lista compleja que quieres reducir a una lista simple aplicando repetidamente una función que reduce un par a un singleton. Esto es útil si quieres aplicar un verbo dplyr de dos tablas a múltiples tablas. Por ejemplo, si tienes una lista de data frames, y quieres reducirla a un unico data frame uniendo los elementos:

O puedes tener una lista de vectores y quieres encontrar la intersección:

La función reduce() toma una función “binaria” (e.g. una función con dos entradas primarias) y la aplica repetidamente a una lista hasta que resulte un elemento único.

accumulate() es similar pero mantiene todos los resultados intermedios. Podría usarse para implementar una suma acumulativa:

21.9.3 Ejercicios

  1. Implementa tu propia versión de every() usando un loop for. Comparala con purrr::every(). ¿Qué hace la versión de purrr que la tuya no hace?

  2. Crea una mejora de col_sum() que aplique una función de resumen a cada columna numérica en un data frame.

  3. Un posible equivalente de col_sum() es:

    Pero tiene una cantidad de bugs que queda ilustrada con las siguientes entradas:

    ¿Cuál es la causa de esos bugs?