¿Cómo hacemos filtrado y extracción de listas anidadas utilizando `purrr`?


Trabajar con datos JSON y listas anidadas es una tarea común en el análisis de datos moderno. Ya sea consumiendo APIs, procesando respuestas de servicios web, o manejando metadatos complejos, saber cómo filtrar y extraer información de estructuras anidadas es una habilidad esencial.

En este tutorial, exploraremos múltiples métodos para filtrar listas anidadas en R, centrándonos en el poderoso paquete purrr y su integración con el ecosistema tidyverse.

El Problema: Listas Dentro de Listas

Imagina que tienes metadatos de una tabla con información sobre sus campos, y necesitas identificar cuáles son de tipo "string". Esta es una situación típica cuando trabajas con:

  • APIs REST
  • Esquemas de bases de datos
  • Metadatos de archivos
  • Configuraciones JSON

Preparación: Creando Nuestro Ejemplo

Primero, creemos una estructura de lista anidada típica que podrías encontrar al parsear JSON:

# Instalar paquetes si es necesario
install.packages("purrr")
install.packages("dplyr")
install.packages("jsonlite")

# Cargar librerías
library(purrr)
library(dplyr)
library(jsonlite)

# Estructura de lista anidada
lista_anidada <- list(
  fields = list(
    list(
      name = "id_distrito_local",
      long_name = "id_distrito_local", 
      type = "integer",
      format = "default"
    ),
    list(
      name = "cabecera_distrital_local",
      type = "string",
      format = "default"
    ),
    list(
      name = "id_municipio_local",
      type = "integer", 
      format = "default"
    )
  )
)

# Visualizar la estructura
str(lista_anidada, max.level = 3)

Objetivo: Extraer solo los nombres de campos donde type == "string". En este caso, esperamos obtener "cabecera_distrital_local".

Método 1: purrr::keep() + purrr::map_chr() (Recomendado)

Este es el método más elegante y legible cuando trabajas con listas anidadas:

library(purrr)

# Filtrar y extraer en un pipeline
campos_string <- lista_anidada$fields %>%
  purrr::keep(~ .x$type == "string") %>%
  purrr::map_chr("name")

print(campos_string)
# [1] "cabecera_distrital_local"

¿Cómo Funciona?

  1. purrr::keep(): Filtra la lista, manteniendo solo los elementos que cumplen la condición
  2. ~ .x$type == "string": Función lambda que verifica si el tipo es "string"
  3. purrr::map_chr("name"): Extrae el campo "name" de cada elemento filtrado como character

Ventajas:

  • ✅ Código limpio y legible
  • ✅ Pipeline claro (se lee como una historia)
  • ✅ Manejo automático de edge cases
  • ✅ Performance eficiente

Método 2: purrr::map() con Filtro Explícito

Este método ofrece mayor control sobre el proceso de filtrado:

library(purrr)

# Mapear con condición, luego limpiar
campos_string <- lista_anidada$fields %>%
  purrr::map(~ if(.x$type == "string") .x$name else NULL) %>%
  purrr::compact() %>%
  unlist()

print(campos_string)
# [1] "cabecera_distrital_local"

¿Cómo Funciona?

  1. purrr::map(): Transforma cada elemento de la lista
  2. Condicional: Si el tipo es "string", retorna el nombre; si no, retorna NULL
  3. purrr::compact(): Elimina todos los elementos NULL
  4. unlist(): Convierte la lista en un vector

Ventajas:

  • ✅ Lógica explícita y fácil de entender
  • ✅ Fácil agregar condiciones complejas
  • ✅ Útil cuando necesitas transformar antes de filtrar

Desventajas:

  • ⚠️ Más verboso que purrr::keep()
  • ⚠️ Requiere paso adicional con purrr::compact()

Método 3: Convertir a Tibble Primero (Ideal para Análisis Complejos)

Cuando necesitas filtrar por múltiples criterios o realizar análisis más complejos, convertir a data frame primero es la mejor opción:

library(purrr)
library(dplyr)

# Paso 1: Convertir lista anidada a tibble
info_campos <- lista_anidada$fields %>%
  purrr::map_df(~ data.frame(
    name = .x$name,
    type = .x$type,
    long_name = ifelse(!is.null(.x$long_name), .x$long_name, NA),
    format = ifelse(!is.null(.x$format), .x$format, NA),
    stringsAsFactors = FALSE
  ))

# Visualizar el tibble
print(info_campos)
#                      name     type                  long_name format
# 1       id_distrito_local  integer       id_distrito_local default
# 2 cabecera_distrital_local   string                    <NA> default
# 3      id_municipio_local  integer                    <NA> default

# Paso 2: Filtrar con dplyr
campos_string <- info_campos %>%
  dplyr::filter(type == "string") %>%
  dplyr::pull(name)

print(campos_string)
# [1] "cabecera_distrital_local"

Ventajas:

  • Perfecto para análisis exploratorio: Puedes ver todos los datos en formato tabular
  • Filtrado complejo: Fácil agregar múltiples condiciones
  • Integración con dplyr: Todo el poder del tidyverse
  • Exportable: El tibble se puede exportar fácilmente

Cuándo Usarlo:

  • Necesitas filtrar por múltiples columnas
  • Quieres explorar los datos visualmente primero
  • Vas a realizar análisis adicionales
  • Necesitas exportar el resultado

Ejemplo de Filtrado Complejo:

# Filtrado con múltiples condiciones
campos_filtrados <- info_campos %>%
  dplyr::filter(
    type == "string" | type == "integer",
    format == "default",
    !is.na(long_name)
  ) %>%
  dplyr::pull(name)

print(campos_filtrados)
# [1] "id_distrito_local"

Método 4: Usando purrr::map_df() Directamente

Una variante más compacta del método anterior:

library(purrr)
library(dplyr)

# Todo en un pipeline
campos_string <- lista_anidada$fields %>%
  purrr::map_df(~ tibble::tibble(
    name = .x$name,
    type = .x$type
  )) %>%
  dplyr::filter(type == "string") %>%
  dplyr::pull(name)

print(campos_string)
# [1] "cabecera_distrital_local"

Integración con JSON: El Caso Real

En la práctica, probablemente recibirás estos datos como JSON. Aquí está cómo manejarlo:

library(jsonlite)
library(purrr)

# JSON de ejemplo (típico de una API)
json_data <- '{
  "fields": [
    {
      "name": "id_distrito_local",
      "long_name": "id_distrito_local",
      "type": "integer",
      "format": "default"
    },
    {
      "name": "cabecera_distrital_local",
      "type": "string", 
      "format": "default"
    },
    {
      "name": "id_municipio_local",
      "type": "integer",
      "format": "default"
    }
  ]
}'

# CRÍTICO: Parsear SIN simplificar
lista_anidada <- jsonlite::fromJSON(json_data, simplifyVector = FALSE)

# Ahora funciona perfecto con purrr::keep()
campos_string <- lista_anidada$fields %>%
  purrr::keep(~ .x$type == "string") %>%
  purrr::map_chr("name")

print(campos_string)
# [1] "cabecera_distrital_local"

⚠️ La Clave: simplifyVector = FALSE

Esta es la configuración más importante al trabajar con JSON anidado:

# ❌ MAL: Con simplificación (default)
lista_simplificada <- jsonlite::fromJSON(json_data)
# Resultado: data.frame, no lista anidada

# ✅ BIEN: Sin simplificación
lista_anidada <- jsonlite::fromJSON(json_data, simplifyVector = FALSE)
# Resultado: lista anidada que funciona con purrr::keep()

¿Por qué importa?

  • simplifyVector = TRUE (default) convierte arrays JSON en data.frames
  • simplifyVector = FALSE mantiene la estructura de lista pura
  • purrr::keep() funciona con listas, no con data.frames

Comparación de Métodos

Método Legibilidad Performance Flexibilidad Mejor Para
purrr::keep() + map_chr() ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ Filtrado simple y rápido
map() + compact() ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ Lógica condicional compleja
map_df() + dplyr ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ Análisis exploratorio y filtrado complejo

Casos de Uso Avanzados

Caso 1: Filtrado con Múltiples Condiciones

# Buscar campos numéricos con formato específico
campos_numericos_default <- lista_anidada$fields %>%
  purrr::keep(~ .x$type %in% c("integer", "numeric") && 
              !is.null(.x$format) && 
              .x$format == "default") %>%
  purrr::map_chr("name")

print(campos_numericos_default)
# [1] "id_distrito_local"    "id_municipio_local"

Caso 2: Extraer Múltiples Propiedades

# Extraer nombre y tipo de campos string
info_strings <- lista_anidada$fields %>%
  purrr::keep(~ .x$type == "string") %>%
  purrr::map(~ list(
    nombre = .x$name,
    formato = .x$format
  ))

print(info_strings)
# [[1]]
# [[1]]$nombre
# [1] "cabecera_distrital_local"
# 
# [[1]]$formato
# [1] "default"

Caso 3: Contar Tipos de Campos

# Análisis rápido de tipos
resumen_tipos <- lista_anidada$fields %>%
  purrr::map_chr("type") %>%
  table()

print(resumen_tipos)
# integer  string 
#      2       1

Caso 4: Validación de Esquema

# Verificar que todos los campos tengan 'name' y 'type'
campos_validos <- lista_anidada$fields %>%
  purrr::map_lgl(~ !is.null(.x$name) && !is.null(.x$type)) %>%
  all()

print(campos_validos)
# [1] TRUE

Manejo de Errores y Edge Cases

Caso 1: Campos Faltantes

# Lista con información incompleta
lista_incompleta <- list(
  fields = list(
    list(name = "campo1", type = "string"),
    list(name = "campo2"),  # Falta 'type'
    list(type = "integer")  # Falta 'name'
  )
)

# Manejo seguro
campos_string_seguros <- lista_incompleta$fields %>%
  purrr::keep(~ !is.null(.x$type) && .x$type == "string") %>%
  purrr::map_chr(~ ifelse(!is.null(.x$name), .x$name, "sin_nombre"))

print(campos_string_seguros)
# [1] "campo1"

Caso 2: Lista Vacía

# Lista sin campos
lista_vacia <- list(fields = list())

# Manejo seguro que retorna vector vacío
campos_resultado <- lista_vacia$fields %>%
  purrr::keep(~ .x$type == "string") %>%
  purrr::map_chr("name")

print(campos_resultado)
# character(0)

Caso 3: Tipos NULL

# Filtrado con verificación NULL
campos_seguros <- lista_anidada$fields %>%
  purrr::keep(~ !is.null(.x$type) && .x$type == "string") %>%
  purrr::map_chr("name")

Patrones Comunes en el Mundo Real

Patrón 1: Procesar Respuesta de API

# Función reutilizable para procesar metadatos
procesar_metadatos <- function(json_response) {
  datos <- jsonlite::fromJSON(json_response, simplifyVector = FALSE)
  
  metadatos <- datos$fields %>%
    purrr::map_df(~ tibble::tibble(
      nombre = .x$name,
      tipo = .x$type,
      requerido = ifelse(!is.null(.x$required), .x$required, FALSE),
      formato = ifelse(!is.null(.x$format), .x$format, NA)
    ))
  
  return(metadatos)
}

Patrón 2: Validación de Esquema

# Validar que ciertos campos existan y sean del tipo correcto
validar_esquema <- function(lista_campos, campos_requeridos) {
  campos_presentes <- lista_campos$fields %>%
    purrr::map_chr("name")
  
  todos_presentes <- all(campos_requeridos %in% campos_presentes)
  
  if (!todos_presentes) {
    faltantes <- setdiff(campos_requeridos, campos_presentes)
    stop("Campos faltantes: ", paste(faltantes, collapse = ", "))
  }
  
  return(TRUE)
}

# Uso
validar_esquema(lista_anidada, c("id_distrito_local", "cabecera_distrital_local"))

Patrón 3: Transformación de Esquemas

# Convertir esquema de un formato a otro
transformar_esquema <- function(lista_campos) {
  lista_campos$fields %>%
    purrr::map(~ list(
      field_name = .x$name,
      data_type = switch(.x$type,
                        "string" = "TEXT",
                        "integer" = "INTEGER",
                        "numeric" = "REAL",
                        "UNKNOWN"
      ),
      is_nullable = TRUE
    ))
}

Resumen de Mejores Prácticas

✅ Hacer

  1. Usar simplifyVector = FALSE al parsear JSON con jsonlite::fromJSON()
  2. Preferir purrr::keep() para filtrado simple y legible
  3. Convertir a tibble para análisis complejos con múltiples condiciones
  4. Manejar valores NULL explícitamente en tus condiciones
  5. Usar funciones lambda (~) para mayor claridad

❌ Evitar

  1. No asumir que todos los campos existen
  2. No olvidar simplifyVector = FALSE con JSON anidado
  3. No usar loops cuando purrr puede hacerlo más elegante
  4. No ignorar edge cases (listas vacías, NULL values)

Conclusión

Dominar el filtrado de listas anidadas en R es esencial para trabajar eficientemente con datos modernos. Los métodos presentados te permiten elegir la herramienta adecuada según tu caso:

  • purrr::keep() + map_chr(): Tu herramienta principal para filtrado simple
  • map() + compact(): Para lógica condicional más compleja
  • map_df() + dplyr: Para análisis exploratorio y filtrado complejo

La clave está en mantener la estructura de lista anidada (usando simplifyVector = FALSE con JSON) y aprovechar el poder del ecosistema purrr para manipulaciones elegantes y legibles.


Para profundizar más en purrr, consulta la documentación oficial: ?purrr::keep y ?purrr::map

Comentarios

Entradas más populares de este blog

¿Cómo comparar nombres de equipos de distintas fuentes?