Explorando los datos de biodiversidad de la SMA

Datos SMA · Parte 1 — Una primera mirada a 15 millones de registros de monitoreo ambiental en Chile

Autor Oscar Inostroza-Michael
Fecha 27 Abril 2026
Biodiversidad Data wrangling R
← Volver al blog

Introducción

En Chile, los titulares de proyectos con Resolución de Calificación Ambiental (RCA) están obligados a reportar datos de biodiversidad a la Superintendencia del Medio Ambiente (SMA). Esto no es voluntario: la Resolución Exenta N°343/2022 establece un formato estandarizado para que esa información se entregue a través del Sistema de Seguimiento Ambiental.

Afortunadamente, la SMA pone estos datos a disposición pública (link descarga). Son miles de registros de monitoreo, líneas base, rescates y relocalizaciones de fauna y flora, recopilados a lo largo del país. En la práctica, es una de las fuentes de información sobre biodiversidad más grandes que recién estamos empezando a analizar.

Los datos

Los archivos se distribuyen como CSV separados por punto y coma, organizados en carpetas por región y año. Cada registro incluye información sobre la campaña de muestreo (dónde, cuándo, con qué protocolo), la ubicación geográfica de las estaciones, y la identificación taxonómica de lo que se encontró: desde el reino hasta el epíteto específico, pasando por nombre común, estado del organismo y cuantificación.

La estructura es bastante completa, pero como los datos provienen de cientos de titulares distintos, es esperable encontrar inconsistencias: errores tipográficos en nombres de especies, formatos de fecha variables, coordenadas con problemas, y ese tipo de cosas que hacen que la limpieza de datos sea siempre el primer paso real de cualquier análisis.

En esta entrada veremos:

Metadatos

La descripción de cada campo está disponible en el documento Descripción datos_biodiversidad.pdf (link descarga).

Manos a la obra!

Carga de datos

El primer desafío es que los datos no vienen en un solo archivo, sino en cientos de CSV repartidos en carpetas por región y año. La estrategia es simple:

Uso de memoria

Cargar todos los archivos de una vez puede ser intensivo en RAM. Si tu equipo tiene recursos limitados, prueba primero con una sola carpeta regional o un subconjunto de archivos para verificar que todo funcione antes de escalar.

Nota sobre el código

Todo el código usa sintaxis y paquetes del ecosistema tidyverse. Las funciones se llaman con notación paquete::función() para que siempre quede claro de dónde viene cada una.

Primero, listamos recursivamente todos los CSV dentro de la carpeta raíz:

csv_files <- fs::dir_ls(path_root, recurse = TRUE, glob = "*.csv")

Luego, cada archivo se lee según las especificaciones de los metadatos (delimitado por ";" con "." como separador decimal), seleccionando solo algunas de las columnas definidas en los metadata. Todas las columnas se leen inicialmente como texto (col_types = cols(.default = "c")) para evitar conflictos de tipo al consolidar archivos donde una misma columna puede venir vacía (inferida como numérica) o con datos (inferida como texto). Las columnas numéricas se convierten explícitamente después. Además, se agrega el nombre del archivo y la carpeta de origen como columnas de trazabilidad:

readr::read_delim(
          f,
          delim     = ";",
          # Según metadata: separador decimal es "." (NO ",")
          # Se lee todo como character para evitar conflictos de tipo
          # entre archivos (e.g. EpitetoInfraespecifico viene como
          # double en archivos vacíos y character en otros)
          col_types = readr::cols(.default = "c"),
          locale    = readr::locale(encoding = "UTF-8"),
          show_col_types = FALSE
        ) %>%
          dplyr::select(dplyr::any_of(cols_keep)) %>%
          # Convertir columnas numéricas (coordenadas, medidas, valores)
          dplyr::mutate(
            dplyr::across(
              dplyr::any_of(cols_numeric),
              as.numeric
            ),
            archivo_origen  = fs::path_file(f),
            carpeta_region  = fs::path_file(fs::path_dir(f))
          )

Finalmente, se itera sobre todos los archivos con purrr::map(), se descartan los que fallaron y se consolida todo en un único data frame:

data <- purrr::map(csv_files, read_single) %>%
  purrr::compact() %>%
  purrr::list_rbind()

La función completa empaqueta estos tres pasos, incluyendo manejo de errores y la opción de paralelizar la lectura con furrr si se dispone de varios núcleos:

Ver código: función completa
read_sma_data <- function(path_root, n_cores = 1L) {
  csv_files <- fs::dir_ls(path_root, recurse = TRUE, glob = "*.csv")
  cli::cli_inform(glue::glue("Encontrados {length(csv_files)} archivos CSV"))
  
  # Columnas útiles según metadata (Descripción datos_biodiversidad.pdf)
  cols_keep <- c(
    "CampanaSmaId", "DocumentoId", "NombreCampana", "AnoInicio",
    "FechaInicio", "FechaTermino", "ObjetivoCampana",
    "NombreEstacion", "TipoMonitoreo", "DescripcionEstacionReplica",
    "LargoM", "AnchoM", "SuperficieM2",
    "LatitudDecimalCentral", "LongitudDecimalCentral",
    "LatitudDecimalInicio", "LongitudDecimalInicio",
    "LatitudDecimalTermino", "LongitudDecimalTermino",
    "LatitudDecimalRegistro", "LongitudDecimalRegistro",
    "Region", "Provincia", "Comuna", "Localidad",
    "EcosistemaNivel1", "EcosistemaNivel2", "Exposicion",
    "FechaEvento", "ProtocoloMuestreo", "ProfundidadM",
    "Reino", "FiloDivision", "Clase", "Orden", "Familia",
    "Genero", "EpitetoEspecifico", "EpitetoInfraespecifico",
    "NombreComun",
    "EstadoOrganismo", "TipoCuantificacion", "Valor", "UnidadValor",
    "TipoRegistro", "InformeId", "FechaExtraccion"
  )
  
  # Columnas que deben ser numéricas después de la lectura
  cols_numeric <- c(
    "LargoM", "AnchoM", "SuperficieM2", "ProfundidadM", "Valor",
    "LatitudDecimalCentral", "LongitudDecimalCentral",
    "LatitudDecimalInicio", "LongitudDecimalInicio",
    "LatitudDecimalTermino", "LongitudDecimalTermino",
    "LatitudDecimalRegistro", "LongitudDecimalRegistro"
  )
  
  read_single <- function(f) {
    tryCatch(
      {
        readr::read_delim(
          f,
          delim     = ";",
          # Según metadata: separador decimal es "." (NO ",")
          # Se lee todo como character para evitar conflictos de tipo
          # entre archivos (e.g. EpitetoInfraespecifico viene como
          # double en archivos vacíos y character en otros)
          col_types = readr::cols(.default = "c"),
          locale    = readr::locale(encoding = "UTF-8"),
          show_col_types = FALSE
        ) %>%
          dplyr::select(dplyr::any_of(cols_keep)) %>%
          # Convertir columnas numéricas (coordenadas, medidas, valores)
          dplyr::mutate(
            dplyr::across(
              dplyr::any_of(cols_numeric),
              as.numeric
            ),
            archivo_origen  = fs::path_file(f),
            carpeta_region  = fs::path_file(fs::path_dir(f))
          )
      },
      error = function(e) {
        cli::cli_warn(glue::glue("Error leyendo {fs::path_file(f)}: {e$message}"))
        NULL
      }
    )
  }
  
  if (n_cores <= 1L) {
    progressr::with_progress({
      p <- progressr::progressor(steps = length(csv_files))
      data <- purrr::map(
        .x = csv_files,
        .f = ~ {
          result <- read_single(.x)
          p()
          result
        }
      )
    })
  } else {
    future::plan(future::multisession, workers = n_cores)
    on.exit(future::plan(future::sequential), add = TRUE)
    cli::cli_inform(glue::glue("Leyendo en paralelo con {n_cores} núcleos..."))
    progressr::with_progress({
      p <- progressr::progressor(steps = length(csv_files))
      data <- furrr::future_map(
        .x = csv_files,
        .f = ~ {
          result <- read_single(.x)
          p()
          result
        },
        .options = furrr::furrr_options(seed = TRUE)
      )
    })
  }
  
  data <- data %>%
    purrr::compact() %>%
    purrr::list_rbind()
  
  cli::cli_inform(glue::glue(
    "✓ {nrow(data)} registros leídos de {length(csv_files)} archivos"
  ))
  data
}

Resumen general

Antes de cualquier análisis, conviene tener una vista general de lo que tenemos. Con un resumen simple podemos dimensionar el volumen de datos y detectar problemas evidentes:

general <- tibble::tibble(
  metrica = c(
    "Total de registros", "Total de archivos",
    "Regiones únicas", "Campañas únicas",
    "Documentos (EIA/DIA) únicos",
    "Años cubiertos", "Rango temporal"
  ),
  valor = c(
    scales::comma(nrow(data)),
    dplyr::n_distinct(data$archivo_origen),
    dplyr::n_distinct(data$Region),
    dplyr::n_distinct(data$CampanaSmaId),
    dplyr::n_distinct(data$DocumentoId),
    dplyr::n_distinct(data$AnoInicio, na.rm = TRUE),
    glue::glue("{min(data$AnoInicio, na.rm = TRUE)} – {max(data$AnoInicio, na.rm = TRUE)}")
  )
)
MétricaValor
Total de registros15.132.208
Total de archivos572
Regiones únicas24
Campañas únicas55.245
Documentos (EIA/DIA) únicos14.943
Años cubiertos39
Rango temporal1989 – 2225

Más de 15 millones de registros es un volumen considerable. Pero la tabla ya delata problemas: Chile tiene 16 regiones, no 24, lo que indica inconsistencias en los nombres (e.g. "Antofagasta" y "ANTOFAGASTA"), y un rango temporal que llega hasta el año 2225 confirma que hay errores de digitación en las fechas que habrá que filtrar.

Cobertura por región

Corrigiendo los nombres de región (solo eran diferencias de mayúsculas), podemos ver cómo se distribuye la cobertura de norte a sur:

Ver código: tabla de cobertura por región
# Orden geográfico norte → sur
orden_ns <- c(
  "Arica Y Parinacota", "Tarapacá", "Antofagasta", "Atacama",
  "Coquimbo", "Valparaíso", "Metropolitana",
  "Libertador General Bernardo O'higgins", "Maule", "Ñuble",
  "Biobío", "Araucanía", "Los Ríos", "Los Lagos",
  "Aysén Del General Carlos Ibáñez Del Campo",
  "Magallanes Y La Antártica Chilena"
)

resumen_region <- df_clean %>%
  dplyr::mutate(
    Region = stringr::str_to_title(Region),
    Region = stringr::str_squish(Region)
  ) %>%
  dplyr::group_by(Region) %>%
  dplyr::summarise(
    n_registros  = dplyr::n(),
    n_estaciones = dplyr::n_distinct(NombreEstacion),
    n_campanas   = dplyr::n_distinct(CampanaSmaId),
    n_documentos = dplyr::n_distinct(DocumentoId),
    .groups = "drop"
  ) %>%
  dplyr::mutate(Region = factor(Region, levels = orden_ns)) %>%
  dplyr::arrange(Region)
Región Registros Estaciones Campañas Documentos
Arica y Parinacota46.3581.422492112
Tarapacá2.191.19617.6545.174894
Antofagasta1.669.08219.6537.0051.545
Atacama2.475.52932.1264.9831.759
Coquimbo2.847.25419.5368.031964
Valparaíso1.129.83811.5024.1081.247
Metropolitana721.73817.6983.5631.109
Libertador Gral. Bernardo O'Higgins412.5546.0383.735710
Maule354.2045.8591.422500
Ñuble492.6762.3041.103319
Biobío1.496.4839.4895.2641.638
Araucanía178.5401.7111.725480
Los Ríos180.2961.8821.372431
Los Lagos587.3954.5434.8201.476
Aysén del Gral. Carlos Ibáñez del Campo82.5971.1961.184790
Magallanes y la Antártica Chilena266.4689.3152.7581.336

Las regiones del norte (Coquimbo, Atacama, Tarapacá) concentran la mayor cantidad de registros, lo que es esperable dado el volumen de proyectos mineros en esas zonas. Regiones como Araucanía, Los Ríos y Arica y Parinacota tienen comparativamente pocos registros.

Esfuerzo de muestreo

Para visualizar las diferencias de cobertura, dos gráficos simples: registros por región y registros por año. En ambos casos se aplicaron solo las correcciones mínimas necesarias para graficar: normalización de mayúsculas en los nombres de región, y filtro de años al rango 1990–2025. Los conteos incluyen todos los registros sin depurar.

Ver código: figuras de esfuerzo de muestreo
# ── Registros por región (barras horizontales, norte a sur) ───────────────────

p1 <- resumen_region %>%
  dplyr::mutate(Region = factor(Region, levels = rev(orden_ns))) %>%
  ggplot2::ggplot(ggplot2::aes(x = Region, y = n_registros)) +
  ggplot2::geom_col(fill = teal, width = 0.7) +
  ggplot2::geom_text(
    ggplot2::aes(label = format(n_registros, big.mark = ".", decimal.mark = ",")),
    hjust = -0.08, color = txt_light, size = 3
  ) +
  ggplot2::scale_y_continuous(
    labels = scales::label_number(scale = 1e-6, suffix = " M"),
    expand = ggplot2::expansion(mult = c(0, 0.22))
  ) +
  ggplot2::coord_flip() +
  ggplot2::labs(
    title    = "Registros de biodiversidad por región",
    subtitle = "Datos SMA · Nombres de región corregidos",
    x        = NULL,
    y        = "Número de registros",
    caption  = "Fuente: SMA · Datos sin depurar"
  ) +
  theme_inferencia()+
  ggplot2::theme(
    panel.grid.major.y = ggplot2::element_blank(),
    axis.text.y        = ggplot2::element_text(size = 9)
  )


# ── Registros por año (1990–2025) ────────────────────────────────────────────

p2 <- df_clean %>%
  dplyr::filter(!is.na(AnoInicio)) %>%
  dplyr::mutate(AnoInicio = as.integer(AnoInicio)) %>%
  dplyr::filter(AnoInicio >= 1990, AnoInicio <= 2025) %>%
  dplyr::count(AnoInicio) %>%
  ggplot2::ggplot(ggplot2::aes(x = AnoInicio, y = n)) +
  ggplot2::geom_col(fill = teal, width = 0.75) +
  ggplot2::scale_x_continuous(breaks = seq(1990, 2025, by = 5)) +
  ggplot2::scale_y_continuous(
    labels = scales::label_number(scale = 1e-6, suffix = " M")
  ) +
  ggplot2::labs(
    title    = "Registros por año (1990–2025)",
    subtitle = "Se excluyeron años fuera de rango para eliminar errores de digitación",
    x        = "Año",
    y        = "Número de registros",
    caption  = "Fuente: SMA · Datos sin depurar"
  ) +
 theme_inferencia() +
  ggplot2::theme(
    panel.grid.major.x = ggplot2::element_blank()
  )
Registros de biodiversidad por región

La distribución temporal muestra el crecimiento del sistema de reporte a lo largo de los años. Se filtraron los años fuera del rango 1990–2025 para excluir errores de digitación:

Registros de biodiversidad por año

Con estos dos panoramas, regional y temporal, ya podemos identificar los primeros problemas en los datos.

Lo que ya se ve a simple vista

Con solo dos correcciones mínimas —normalizar las mayúsculas en los nombres de región y filtrar los años fuera del rango 1990–2025 para los gráficos de esfuerzo de muestreo— el exploratorio ya revela varios problemas que habrá que abordar en profundidad:

Si bien no se exploraron en detalle en esta entrada, una inspección rápida de los campos taxonómicos también revela problemas evidentes:

Nota

Las correcciones aplicadas en esta entrada son mínimas y específicas a los gráficos presentados. Los datos no han sido depurados de forma integral: los conteos incluyen registros nulos, duplicados y posibles errores taxonómicos. Una limpieza completa es el primer paso antes de cualquier análisis formal y será abordada en las próximas entradas.

Lo que viene

Este fue un primer acercamiento general a los datos de biodiversidad de la SMA. En las próximas entradas iremos por componente, profundizando en cada uno:

La próxima entrada se enfoca en taxonomía, un paso necesario antes de cualquier análisis de diversidad o distribución.

← Volver al blog