El problema
Las funciones que usamos para hacer el análisis suelen tener unos requisitos bastante estrictos en materia de input y las bases de datos “crudas” que descargamos tienen su propia estructura. Cerrar esa brecha es el trabajo de la manipulación de datos, que muchas veces nos insume más tiempo que el análisis de datos propiamente dicho. Creo que una de las mayores ventajas de R
sobre los paquetes tradicionales de análisis cuantitativo1 es que tiene las herramientas para automatizar muchas partes del proceso de manipulación. Como siempre en R
la curva de aprendizaje tiene una pendiente alta en el primer tramo, pero cuando subimos esa montaña la productividad aumenta muchísimo. “Domar” una base de datos se hace un proceso mucho más rápido.
Iteraradores y copy-pasteadores
La especificación y codificación de las variables en las bases de datos que descargamos suele ser diferente a lo que las funciones de análisis requieren, sin embargo lo que tienen de diferente suele ser lo mismo2. En términos prácticos, una vez que encontramos la manera de adecuar una variable a la forma requerida podemos usar esa solución para todas las variables que tienen el mismo problema. Acá hay dos aproximaciones:
Ctrl - c
Ctrl - v
.
Copiar y pegar la solución y modificar en cada caso los nombres de variables, tanto en el input como en el output. Es la aproximación más simple y también la peor práctica: nos expone a errores de dedo, nos obliga a modificar muchas líneas si queremos cambiar la función, nos obliga a mantener mucho código.
- Iterar
Si lo que queremos es repetir la aplicación de una función a todas o algunas variables de un data frame R
nos ofrece una herramienta muy eficiente para hacerlo, sin repetir código y sin tener que mantener muchas líneas que hacen más o menos lo mismo. Son las funciones “funcionales”, es decir, funciones que no hacen nada por sí mismas, pero automatizan la repetición de la aplicación de otras funciones. En lugar de copiar, pegar y modificar el código lo escribimos una sola vez y le introducimos cierta abstracción: por ejemplo, en lugar de especificar los nombres de columna usamos una variable interna en la función que asuma el nombre de cada columna cuando es necesario. Listo, tenemos código reutilizable y fácil de mantener. Si quiero hacer una modificación al código la hago en la definición de la función, una sola vez.
Funciones funcionales
La librería dplyr
tiene una función funcional “encubierta” par resolver este problema. Se trata de mutate_if
y es la solución más elegante al problema de aplicar condicionalmente una función a las columnas de un data frame. Condicionalmente en este contexto se refiere a hacerlo cuando se cumple una condición, por ejemplo, cuando la columna es numérica, es caractér, su sumatoria es mayor a la media de sumatorias, etc. Cualquier prueba que podamos hacer y de como resultado TRUE
o FALSE
sirve como condición. Para las columnas en las que la prueba resulta TRUE
aplicamos la función que nos interesa.
# Datos de prueba
set.seed(2018)
test_data <- data.frame(
num1 = runif(10, -1, 10),
num2 = runif(10, -1, 1),
cat1 = sample(c(letters, LETTERS), 10),
cat2 = sample(c(LETTERS, letters), 10),
stringsAsFactors = FALSE
)
str(test_data)
## 'data.frame': 10 obs. of 4 variables:
## $ num1: num 2.698 4.101 -0.334 1.172 4.217 ...
## $ num2: num -0.209 0.329 0.964 0.356 0.612 ...
## $ cat1: chr "n" "C" "h" "e" ...
## $ cat2: chr "H" "r" "B" "Y" ...
test_data
es un data frame con cuatro columnas, dos numéricas, y dos cadenas de caracteres. El problema con las numéricas es que están en escalas diferentes y queremos normalizalas, el de cadenas de caracteres que mezclan mayúsculas y minúsculas y queremos solo letras minúsculas. La función scale
hace el primer trabajo, to_lower
hace el segundo. Empecemos por el segundo caso.
library(dplyr)
##
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
##
## filter, lag
## The following objects are masked from 'package:base':
##
## intersect, setdiff, setequal, union
test_data %>%
mutate_if(is.character, tolower)
## num1 num2 cat1 cat2
## 1 2.6976882 -0.2087681 n h
## 2 4.1009560 0.3290772 c r
## 3 -0.3335608 0.9642246 h b
## 4 1.1717697 0.3564308 e y
## 5 4.2174561 0.6120556 k w
## 6 2.3115346 0.2683598 z j
## 7 5.6743474 -0.4585271 p c
## 8 0.4301331 0.1058083 p t
## 9 9.5452019 0.4759114 a z
## 10 5.0153444 0.6568008 c z
¡Listo! No importa si tenemos 1, 2 o 5000 variables a las que pasar a minúsculas, mutate_if
repetir la función tantas veces como variables para las que is.character
regresa TRUE
.
El caso de las numéricas es intencionalmente un poco más complicado. La función scale
no regresa un vector, sino una matriz de números estandarizados. Y la familia de funciones mutate
, incluyendo a mutate_if
, requieren que el output sea un vector. Para hacer peores las cosa no emite error ni advertencia: crea el data frame y hasta nos lo muestra como si todos estuviera bien. Recién cuando queremos hacer una operación posteriormente emerge el error.
test_data %>%
mutate_if(is.numeric, scale) #No hay problema...
## num1 num2 cat1 cat2
## 1 -0.2702056 -1.24130940 n H
## 2 0.2125692 0.04530743 C r
## 3 -1.3130649 1.56468730 h B
## 4 -0.7951768 0.11074178 e Y
## 5 0.2526495 0.72223960 K w
## 6 -0.4030564 -0.09993897 z j
## 7 0.7538728 -1.83877518 p C
## 8 -1.0503266 -0.48878968 P t
## 9 2.0855868 0.39655955 A Z
## 10 0.5271519 0.82927757 c z
test_data %>%
mutate_if(is.numeric, scale) %>%
mutate_if(is.character, tolower) #Sí, hay problema.
## Error: Columns `num1`, `num2` must be 1d atomic vectors or lists
test_data %>%
mutate_if(is.numeric, scale) %>%
sapply(class)
## num1 num2 cat1 cat2
## "matrix" "matrix" "character" "character"
Acá está el problema: el data frame resultante contiene matricez en dos de sus columnas. Esto debería resolverse fácilmente: pasamos a vector las matrices y listo.
test_data %>%
mutate_if(is.numeric, scale) %>%
mutate_if(is.matrix, as.vector)
## Error: Columns `num1`, `num2` must be 1d atomic vectors or lists
Mo funciona. Los datos no tienen la estructura que mutate_if
espera (una lista de vectores) y el código falla. ¿Cómo lo resolvemos? Hay dos maneras: declarando una función anónima en la llamada a scale
que convierta al output de esta función en vector o utilizando a una prima cercana de mutate_if
que, como está preparada para trabajar con lista que no son data frames nos permite hacer la modificación posterior. Se trata de modify_if
y no está en la librería dplyr
, sino en purrr
. Ambas están en el metapaquete tidyverse
.
Nota: en la sintaxis de
purrr::
el símbolo~
antecediendo a una expresión indica que es una función anónima. Nos ahora elfunction(x) {}
.
test_data %>%
mutate_if(is.numeric, ~as.vector(scale(.))) %>% #is.elegant() = FALSE
mutate_if(is.character, tolower)
## num1 num2 cat1 cat2
## 1 -0.2702056 -1.24130940 n h
## 2 0.2125692 0.04530743 c r
## 3 -1.3130649 1.56468730 h b
## 4 -0.7951768 0.11074178 e y
## 5 0.2526495 0.72223960 k w
## 6 -0.4030564 -0.09993897 z j
## 7 0.7538728 -1.83877518 p c
## 8 -1.0503266 -0.48878968 p t
## 9 2.0855868 0.39655955 a z
## 10 0.5271519 0.82927757 c z
test_data %>%
mutate_if(is.numeric, scale) %>%
purrr::modify_if(is.matrix, as.vector) %>% #Una librería más para cargar
mutate_if(is.character, tolower)
## num1 num2 cat1 cat2
## 1 -0.2702056 -1.24130940 n h
## 2 0.2125692 0.04530743 c r
## 3 -1.3130649 1.56468730 h b
## 4 -0.7951768 0.11074178 e y
## 5 0.2526495 0.72223960 k w
## 6 -0.4030564 -0.09993897 z j
## 7 0.7538728 -1.83877518 p c
## 8 -1.0503266 -0.48878968 p t
## 9 2.0855868 0.39655955 a z
## 10 0.5271519 0.82927757 c z
Este ejemplo muestra un caso complicado, y aún así es relativamente fácil resolver el problema. mutate_if
es fácil de manejar y automatiza muchos procesos muy tediosos.
tinyverse
Si mutate_if
(o modify_if
cuando las cosas se ponen más complicadas) funcionan sin problemas ¿por qué buscar una alternativa? Por varios motivos.
Dependencias: al instalar una librería no sólo instalamos ese paquete, también todas las dependencias que tiene ese paquete. Si bien
CRAN
es magnífico manejando dependencias a largo plazo pueden aparecer problemas. EnCRAN
hay más de un paquete huerfano, sin nadie que le de mantenimiento. Si se encuentra una vulnerabilidad grave en un paquete huerfano probablemente salga deCRAN
. Eso sería muy problemático, porque haría imposible que se construyan e instalen todos los paquetes que dependen de él. Reducir el número de librerías que importamos en nuestro código es una garantía de que este va a seguir funcionando en el futuro, aún si se hicieran realidad problemas como el descripto.Cambios en las APIs: Las librerías adicionales de
R
cambian con frecuencias sus funciones, los argumentos que aceptan, el output que producen. El código que escribimos hoy puede depender de una función que mañana se considere obsoleta, o cuya sintaxis cambie. En síntesis: estamos apuntando a un blanco móvil.Evitar la monocultura. El
tidyverse
es excelente y en verdad hace mucho más accesible un uso avanzado de R para quienes están comenzando. Esto es posible porque dentro deltidyverse
hay ciertas expectativas que se cumplen siempre, eso hace que nuestras funciones tengan un comportamiento previsible y que la lógica que aplicamos en un caso sirva para muchos otros. Ahí también radica el problema. Como vimos conscale
al pasar amutate
un output inválido producimos un error silencioso imposible de diagnosticar dentro del mismotidyverse
. Aún cuando usemos eltidyverse
aprender a usarR
base nos va a sacar de problemas cuando algo falla. Y la única de forma de apreder a usarR
base es usándolo.
lapply
condicional
¿Como reemplazamos mutate_if
o modify_if
si no queremos importar las librerías que las contienen? Una buena alternativa es definir nuestra propia función de modificación condicional. Una versión de lapply
que modifique solamente los elementos de la lista en los que se da una condición y deje intactos al resto. La vamos a llamar lapply_if
.
Documentación de lapply_if
lapply_if
transforma los elementos de una lista en los que se cumple cierta condición y no cambia aquellos que en los que no se cumple. Siempre regresa una lista con el mismo largo que datos
o un data.frame con el mismo número de columnas.
Argumentos:
datos
una lista o data.frame.condicion
un vector lógico con los índices de las variables a transformar. Alternativamente una prueba que produzca ese vector.funcion
una función de transformación que produzca u vector o un objeto coercionable a vector....
argumentos adicionales parafuncion
.
lapply_if <- function (datos, condicion, funcion, ...)
{test <- sapply(datos, condicion)
datos[test] <- lapply(datos[test], funcion, ...)
datos
}
# Ejemplo
test_data %>%
lapply_if(is.character, tolower) %>%
lapply_if(is.numeric, scale, center = TRUE) %>%
lapply_if(is.matrix, as.vector)
## num1 num2 cat1 cat2
## 1 -0.2702056 -1.24130940 n h
## 2 0.2125692 0.04530743 c r
## 3 -1.3130649 1.56468730 h b
## 4 -0.7951768 0.11074178 e y
## 5 0.2526495 0.72223960 k w
## 6 -0.4030564 -0.09993897 z j
## 7 0.7538728 -1.83877518 p c
## 8 -1.0503266 -0.48878968 p t
## 9 2.0855868 0.39655955 a z
## 10 0.5271519 0.82927757 c z
Con una función muy simple nos ahorramos cargar una librería completa.
Bonus track
lapply_at <- function(datos, indices, funcion, ...)
{
if (is.numeric(indices)) {test <- indices == seq_along(datos)}
if (is.character(indices)) {test <- indices == names(datos)} else {
stop("El índice no es un vector válido")
}
datos[test] <- lapply(datos[test], funcion, ...)
datos
}