Sistema MLOps de Calidad del Aire de Madrid

De 1.5 GB a 16 MB: Pipeline completa de ML con R, Docker y GPU

R
Machine Learning
MLOps
Docker
XGBoost
PostgreSQL
Spatial Analysis
Sistema completo de Machine Learning para predicción de calidad del aire en Madrid: desde la recolección de datos hasta el despliegue automático de predicciones a 40 horas, con optimización extrema de modelos y arquitectura MLOps.
Published

November 1, 2025

Enlaces del Proyecto

🌍 Dashboard Interactivo

Accede al sistema de predicción en tiempo real

💻 Código Fuente

Explora el repositorio completo en GitHub


Resumen del Proyecto

En el mundo del análisis de datos, es común ver dashboards que visualizan datos históricos. Pero, ¿qué se necesita para construir un sistema que no solo visualice, sino que prediga activamente el futuro y se actualice solo, día tras día?

Este proyecto es una pipeline completa de Machine Learning en producción para predicción de calidad del aire en Madrid. A diferencia de dashboards estáticos, este sistema:

  • Recolecta datos históricos (2015-2025) y en tiempo real de múltiples APIs
  • Procesa más de 11 millones de registros con variables meteorológicas y espaciales
  • Entrena modelos predictivos (XGBoost) acelerados por GPU
  • Genera predicciones a 40 horas vista cada día de forma automática
  • Despliega automáticamente actualizaciones en un dashboard web interactivo

Lo extraordinario: El sistema completo está automatizado con GitHub Actions, empaquetado en Docker para reproducibilidad total, y utiliza modelos de solo 16 MB (reducidos desde 1.5 GB) que permiten ejecutar todo en infraestructura gratuita.


El Reto: De 1.5 GB a 16 MB

El Problema Original

La primera versión del sistema (v1.0) utilizaba modelos Random Forest con el paquete ranger. Los resultados eran precisos, pero el sistema era inviable para producción:

  • Tamaño de modelos: ~300 MB por contaminante × 5 contaminantes = 1.5 GB total
  • Memoria en predicción: ~1.5 GB de RAM requerida
  • Consecuencias:
    • GitHub Actions (7 GB RAM): Errores de Out-of-Memory
    • shinyapps.io (límite 1 GB): Imposible desplegar
    • Git LFS: Costes adicionales y complejidad

La Solución: Migración a XGBoost Nativo

La reingeniería completa de la pipeline de ML usando la API nativa de XGBoost (sin CARET) produjo resultados extraordinarios:

Métrica Ranger (v1.0) XGBoost (v2.0) Mejora
Tamaño 5 modelos 1.5 GB 16 MB ↓ 99%
RAM predicción ~1.5 GB ~50 MB ↓ 97%
RAM training 4-9 GB ~200 MB ↓ 95%
Tiempo training (CPU) ~15 min ~5 min ↓ 67%
Tiempo training (GPU) N/A ~2 min -
Viabilidad CI/CD ❌ Imposible ✅ Funcional -

Esta optimización fue el verdadero punto de inflexión que hizo posible todo el flujo de MLOps en infraestructura gratuita.


Arquitectura del Sistema

El sistema funciona como una pipeline de producción completa, dividida en fases claramente diferenciadas:

flowchart TD
    subgraph sources["🌐 FUENTES DE DATOS"]
        A1[API Madrid XML<br/>19 estaciones, 10 contaminantes<br/>Actualización: 20 min]
        A2[AEMET OpenData<br/>Predicciones meteorológicas<br/>Horarias: 40h futuro]
        A3[Portal Open Data Madrid<br/>Históricos 2015-2025<br/>ZIPs/CSV mensuales]
    end

    subgraph db["💾 POSTGRESQL + PostGIS"]
        B1[(dim_estaciones<br/>16 stations + coords)]
        B2[(dim_magnitudes<br/>Pollutants metadata)]
        B3[(fact_mediciones<br/>11M+ rows hourly)]
        B4[(fact_meteo_diaria<br/>Daily weather)]
    end

    subgraph feature["⚙️ FEATURE ENGINEERING<br/>R + data.table"]
        C1[Variables espaciales<br/>UTM coords, distancias]
        C2[Variables temporales<br/>hora, día, mes, trimestre]
        C3[Variables meteorológicas<br/>VPD, temp/hum ratios]
        C4[Baseline estacional<br/>Promedios móviles 30d]
    end

    subgraph training["🎯 ENTRENAMIENTO<br/>XGBoost GPU/CPU"]
        D1[Grid search CV<br/>nrounds, depth, eta]
        D2[5 modelos ICA<br/>NO2, PM10, PM2.5, O3, SO2]
        D3[Output: .model files<br/>16 MB total]
    end

    subgraph cicd["🔄 GITHUB ACTIONS<br/>Daily 4:00 AM"]
        E1[Pull Docker image<br/>GHCR pre-built]
        E2[Download models<br/>GitHub Release 16MB]
        E3[Collect realtime<br/>APIs Madrid + AEMET]
        E4[Generate predictions<br/>16×5×40 = 3,200 rows]
        E5[Deploy Shiny<br/>shinyapps.io auto]
    end

    subgraph app["🖥️ DASHBOARD SHINY"]
        F1[Leaflet maps<br/>Bubbles por concentración]
        F2[Plotly charts<br/>Evolución temporal]
        F3[Animaciones<br/>10 timesteps]
        F4[Export CSV/JSON]
    end

    sources --> db
    db --> feature
    feature --> training
    training --> D3
    D3 --> E2
    E1 --> E3
    E2 --> E3
    E3 --> E4
    E4 --> E5
    E5 --> app

    style sources fill:#e3f2fd
    style db fill:#f3e5f5
    style feature fill:#fff3e0
    style training fill:#e8f5e9
    style cicd fill:#fce4ec
    style app fill:#e0f2f1


Stack Tecnológico: R en Producción

¿Por qué R en 2025?

R se mantiene como líder en estadística y análisis espacial donde Python aún está alcanzándolo. Este proyecto demuestra que R, con el conjunto de paquetes adecuado, es un entorno de producción formidable.

1. data.table - Motor de Procesamiento Ultra-Rápido

Por qué elegido: Operaciones zero-copy y modificación por referencia = 10-100x más rápido que pandas o dplyr.

# Procesar 4.36M registros de NO2 en segundos
datos_finales <- datos_finales[
  !is.na(prediccion) & prediccion >= 0,
  .(prediccion = mean(prediccion, na.rm = TRUE)),
  by = .(id_estacion, fecha_hora, contaminante)
]

Ventajas clave: - Sin copias en memoria (modificación in-place con :=) - Sintaxis concisa para joins y agregaciones complejas - Optimizado para datasets grandes (>1M filas)

2. xgboost - Machine Learning GPU-Accelerated

Por qué elegido: API nativa C++, soporte GPU CUDA, modelos compactos, producción-ready.

# Configuración GPU para entrenamiento acelerado
params <- list(
  tree_method = "hist",      # Histogram-based (GPU optimizado)
  device = "cuda",           # GPU acceleration
  max_bin = 256,             # Precisión alta
  predictor = "gpu_predictor"
)

Rendimiento GPU vs CPU (RTX 4070 Ti): - NO2 (4.36M obs): 2.08 min (GPU) vs 5.5 min (CPU) - Total 5 modelos: 6.26 min (GPU) vs 16.6 min (CPU) → 2.65x speedup

3. sf - Spatial Features (GIS en R)

Por qué elegido: Geometrías espaciales compatibles con PostGIS y estándar OGC.

# Convertir coordenadas a objeto espacial
datos_sf <- st_as_sf(datos,
  coords = c("lon", "lat"),
  crs = 4326  # WGS84
)

Ventajas clave: - Integración directa con PostgreSQL/PostGIS - Operaciones espaciales (distancias, buffers, intersecciones) - Compatible con Leaflet para mapas web interactivos

4. Shiny - Dashboard Interactivo Sin JavaScript

Por qué elegido: Crear aplicaciones web reactivas complejas escribiendo únicamente R.

# Reactivity automática - el mapa se actualiza cuando cambian los datos
output$mapa <- renderLeaflet({
  datos <- datos_predicciones()  # Reactive expression
  leaflet(datos) %>% addCircleMarkers(...)
})

Alternativas descartadas: - Python Dash: Requiere más código boilerplate - React + API REST: Overhead de mantener frontend/backend separados - PowerBI/Tableau: No permiten predicciones custom con ML


Docker: La Capa Invisible de Reproducibilidad

El Problema: Dependencias Espaciales Complejas

R + dependencias espaciales (GDAL, PROJ, GEOS) son notoriamente difíciles de instalar y varían entre sistemas operativos. Compilar sf desde source puede tomar 45 minutos.

La Solución: Docker + RSPM

Docker asegura el mismo entorno en: - Desarrollo local (Windows/Mac/Linux) - GitHub Actions (Ubuntu) - Cualquier servidor cloud - Raspberry Pi (ARM64)

FROM rocker/r-ver:4.5.1

# RSPM: Binarios precompilados (Ubuntu 24.04 Noble)
# Sin esto: compilar sf/gdal toma 45 minutos
# Con esto: instalar binarios toma 3 minutos
RUN echo 'options(repos = c(
  RSPM = "https://packagemanager.posit.co/cran/__linux__/noble/latest",
  CRAN = "https://cloud.r-project.org"
))' >> /usr/local/lib/R/etc/Rprofile.site

RStudio Package Manager (RSPM): - Posit compila todos los paquetes CRAN para Ubuntu - Descarga binarios (.so) en vez de compilar desde source (.tar.gz) - Build time: 55 min → 3-4 min (93% mejora)

Estrategia de Layers para Cache Eficiente

# Layer 1: System dependencies (raramente cambia) → cacheado
RUN apt-get install libgdal-dev libproj-dev libgeos-dev

# Layer 2: R packages (cambia cuando se actualiza install_packages.R) → cacheado
COPY install_packages.R /tmp/
RUN Rscript /tmp/install_packages.R

# Layer 3: Application code (cambia frecuentemente) → no cacheado
COPY . /app

Resultado: Rebuilds posteriores solo recompilan la capa 3 (código de aplicación), reutilizando las capas 1-2 cacheadas.


GitHub Actions: Automatización Diaria

El verdadero poder del sistema está en su autonomía completa. Cada día a las 4:00 AM UTC, sin intervención humana:

sequenceDiagram
    participant GA as GitHub Actions
    participant GHCR as GitHub Container Registry
    participant GHR as GitHub Release
    participant API as APIs (Madrid + AEMET)
    participant Docker as Docker Container
    participant Shiny as shinyapps.io

    Note over GA: 🕓 4:00 AM UTC - Cron trigger
    GA->>GHCR: Pull Docker image (pre-built 2GB)
    GHCR-->>GA: Image cached/downloaded
    GA->>GHR: Download XGBoost models (16 MB)
    GHR-->>GA: 5 .model files
    GA->>Docker: Start container with volume mounts
    Docker->>API: Request realtime data + forecast
    API-->>Docker: XML/JSON responses
    Docker->>Docker: Load models + Generate predictions<br/>16 stations × 5 pollutants × 40 hours
    Docker->>GA: Save predicciones_40h_latest.rds (3,200 rows)
    GA->>Shiny: rsconnect::deployApp() automated
    Shiny-->>Shiny: App updated with new predictions
    Note over Shiny: ✅ Users see latest forecasts

Workflow Detallado

# .github/workflows/daily-predictions.yml
jobs:
  daily-predictions:
    runs-on: ubuntu-latest  # 7 GB RAM, 2 cores - GRATIS

    steps:
      - name: Pull Docker image
        run: docker pull ghcr.io/${{ github.repository }}/air-quality-predictor:latest

      - name: Download trained models
        run: gh release download --pattern "xgboost_nativo_ica_*.model" --dir models/
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Generate predictions (XGBoost Native)
        timeout-minutes: 10
        run: |
          docker run --rm \
            -v $(pwd):/app \
            -e DB_HOST="${{ secrets.DB_HOST }}" \
            -e AEMET_API_KEY="${{ secrets.AEMET_API_KEY }}" \
            ghcr.io/${{ github.repository }}/air-quality-predictor:latest \
            Rscript R/07_predicciones_horarias.R

      - name: Deploy to shinyapps.io
        run: |
          docker run --rm -v $(pwd):/app \
            ghcr.io/${{ github.repository }}/air-quality-predictor:latest \
            Rscript -e "
            library(rsconnect)
            deployApp(
              appDir = 'app',
              appName = 'madrid-air-quality',
              account = '${{ secrets.SHINYAPPS_NAME }}',
              token = '${{ secrets.SHINYAPPS_TOKEN }}',
              secret = '${{ secrets.SHINYAPPS_SECRET }}'
            )"

Por qué funciona ahora (antes fallaba): - Antes (Ranger): Modelos 1.5 GB → GitHub Actions OOM (7 GB RAM insuficiente) - Ahora (XGBoost): Modelos 16 MB + predicciones ~50 MB RAM → Ejecuta sin problemas


Base de Datos: PostgreSQL + PostGIS

Esquema Estrella (Data Warehouse Design)

El sistema no lee archivos CSV directamente. Toda la data histórica y en tiempo real se almacena en PostgreSQL con la extensión PostGIS, permitiendo consultas espaciales complejas.

erDiagram
    dim_estaciones ||--o{ fact_mediciones : "id_estacion"
    dim_magnitudes ||--o{ fact_mediciones : "id_magnitud"
    fact_mediciones }o--|| fact_meteo_diaria : "fecha"

    dim_estaciones {
        int id_estacion PK
        varchar nombre_estacion
        geometry coords "PostGIS Point WGS84"
        varchar tipo_estacion "Urbana/Tráfico/Fondo"
        float utm_x "Coordenada UTM 30N"
        float utm_y "Coordenada UTM 30N"
    }

    dim_magnitudes {
        int id_magnitud PK
        varchar nombre_magnitud "Dióxido de Nitrógeno"
        varchar unidad "µg/m³"
        varchar tipo_ica "ICA/No-ICA"
    }

    fact_mediciones {
        int id_estacion FK
        int id_magnitud FK
        timestamp fecha_hora
        numeric valor_medio
        char valor_validado "V/N"
    }

    fact_meteo_diaria {
        date fecha PK
        numeric temp_media_c
        numeric humedad_media_pct
        numeric precipitacion_mm
        numeric vel_viento_media_ms
        numeric dir_viento_grados
    }

Transformación Wide-to-Long

Datos originales Madrid (formato CSV):

ESTACION,MAGNITUD,ANO,MES,DIA,H01,H02,...,H24,V01,V02,...,V24
28079004,8,2025,01,15,25.3,24.1,...,30.2,V,V,...,V

Problema: 48 columnas (H01-H24 valores + V01-V24 flags) → imposible de consultar eficientemente.

Solución en R (R/utils.R):

procesar_y_cargar_lote <- function(datos_wide, con) {
  # Pivot de H01-H24 a formato long
  datos_long <- melt(datos_wide,
    id.vars = c("id_estacion", "id_magnitud", "ano", "mes", "dia"),
    measure.vars = patterns("^H"),
    variable.name = "hora",
    value.name = "valor_medio"
  )

  # Crear timestamp
  datos_long[, fecha_hora := as.POSIXct(
    paste(ano, mes, dia, gsub("H", "", hora), "00", "00"),
    format = "%Y %m %d %H %M %S",
    tz = "Europe/Madrid"
  )]

  # Bulk insert a PostgreSQL (ultra-rápido)
  dbWriteTable(con, "fact_mediciones", datos_long, append = TRUE)
}

Ventajas Long Format: - Queries SQL simples: WHERE fecha_hora BETWEEN ... AND ... - Joins eficientes con meteorología - Compatible con XGBoost (requiere 1 fila = 1 observación)

PostGIS: Consultas Espaciales

# Calcular distancias entre estaciones (UTM projection para metros)
consulta <- "
  SELECT
    a.id_estacion AS est_origen,
    b.id_estacion AS est_vecina,
    ST_Distance(
      ST_Transform(a.coords, 25830),  -- WGS84 → UTM 30N
      ST_Transform(b.coords, 25830)
    ) AS distancia_metros
  FROM dim_estaciones a
  CROSS JOIN dim_estaciones b
  WHERE a.id_estacion != b.id_estacion
"

EPSG Codes: - 4326: WGS84 (lat/lon, usado en GPS/Leaflet) - 25830: ETRS89 UTM Zone 30N (Madrid, metros, para cálculos de distancia precisos)


Workflow Completo: Del Dato a la Predicción

Fase 1: Setup Inicial (Una Vez)

# 1. Crear tablas dimensionales desde catálogos oficiales
source("R/01_setup_dimension_tables.R")
# Output: dim_estaciones (16 stations), dim_magnitudes (10 pollutants)

# 2. Descargar históricos 2015-2025 (proceso largo: ~2-3 horas)
source("R/02_collect_historical_data.R")
# - Scraping de ~120 archivos ZIP (1 por mes)
# - Descomprimir + parsear CSV
# - Transformar wide → long format
# - Bulk insert a PostgreSQL
# Output: fact_mediciones (~11M filas)

# 3. Añadir predictores espaciales
source("R/03_create_predictors.R")
# - Calcular coordenadas UTM
# - Crear campos: distancia_centro, elevacion, zona_urbana
# Output: Columnas adicionales en dim_estaciones

# 4. Colectar meteorología AEMET (requiere API key gratuita)
source("R/04_collect_meteo_data.R")
# - API requests a AEMET OpenData (límite: 1,000 requests/day)
# - Datos diarios: temp, humedad, viento, presión
# Output: fact_meteo_diaria (~3,650 filas = 10 años)

# 5. Crear baseline estacional
source("R/05_crear_baseline_estacional.R")
# - Calcular promedios móviles 30 días por contaminante/estación
# - Detectar patrones estacionales (verano: O3 ↑, invierno: NO2 ↑)
# Output: Tabla baseline_estacional

Fase 2: Entrenamiento Modelos (GPU Recomendado)

# 6. Entrenar XGBoost (5 contaminantes ICA)
source("R/06_modelo_xgboost_ica.R")

Detalle del proceso de entrenamiento:

library(data.table)
library(xgboost)

# Conectar a BD y cargar datos (ejemplo: NO2)
con <- dbConnect(RPostgres::Postgres(), ...)
query <- "
  SELECT
    m.fecha_hora,
    m.id_estacion,
    m.valor_medio,
    e.utm_x, e.utm_y,
    meteo.temp_media_c,
    meteo.humedad_media_pct,
    baseline.valor_baseline
  FROM fact_mediciones m
  LEFT JOIN dim_estaciones e ON m.id_estacion = e.id_estacion
  LEFT JOIN fact_meteo_diaria meteo ON DATE(m.fecha_hora) = meteo.fecha
  LEFT JOIN baseline_estacional baseline ON ...
  WHERE m.id_magnitud = 8  -- NO2
    AND m.valor_validado = 'V'
    AND m.fecha_hora >= '2015-01-01'
"
datos <- setDT(dbGetQuery(con, query))  # 4.36M rows

# Feature engineering
datos[, `:=`(
  hora_dia = hour(fecha_hora),
  dia_semana = wday(fecha_hora),
  mes = month(fecha_hora),
  trimestre = quarter(fecha_hora),
  vpd = calcular_vpd(temp_media_c, humedad_media_pct),  # Vapor pressure deficit
  temp_hum_ratio = temp_media_c / (humedad_media_pct + 1)
)]

# Preparar matriz XGBoost
features <- c("hora_dia", "dia_semana", "mes", "trimestre",
              "temp_media_c", "humedad_media_pct", "vpd",
              "valor_baseline", "utm_x", "utm_y")
dmatrix <- xgb.DMatrix(
  data = as.matrix(datos[, ..features]),
  label = datos$valor_medio
)

# Grid search con xgb.cv (5-fold cross-validation)
param_grid <- expand.grid(
  nrounds = c(100, 200, 300),
  max_depth = c(6, 8, 10),
  eta = c(0.01, 0.05, 0.10)
)

mejor_rmse <- Inf
for (i in 1:nrow(param_grid)) {
  params <- list(
    tree_method = "hist",
    device = "cuda",  # GPU
    max_depth = param_grid$max_depth[i],
    eta = param_grid$eta[i]
  )

  cv_result <- xgb.cv(
    params = params,
    data = dmatrix,
    nrounds = param_grid$nrounds[i],
    nfold = 5,
    metrics = "rmse",
    verbose = 0
  )

  rmse_mean <- min(cv_result$evaluation_log$test_rmse_mean)
  if (rmse_mean < mejor_rmse) {
    mejor_rmse <- rmse_mean
    mejores_params <- params
    mejores_nrounds <- param_grid$nrounds[i]
  }
}

# Entrenar modelo final con mejores hiperparámetros
modelo_final <- xgb.train(
  params = mejores_params,
  data = dmatrix,
  nrounds = mejores_nrounds
)

# Guardar en formato nativo XGBoost (binario compacto)
xgb.save(modelo_final, "models/xgboost_nativo_ica_Dióxido_de_Nitrógeno.model")
# Tamaño: 3.5 MB (vs 300 MB con Ranger)

Rendimiento GPU vs CPU:

Contaminante Observaciones GPU (RTX 4070 Ti) CPU (16 cores)
NO2 4.36M 2.08 min 5.5 min
PM10 2.28M 1.21 min 3.2 min
PM2.5 1.26M 0.80 min 2.1 min
O3 2.47M 1.35 min 3.6 min
SO2 1.31M 0.82 min 2.2 min
Total 11.68M 6.26 min 16.6 min

Fase 3: Predicciones Operacionales (Automatizado)

# 7. Generar predicciones 40h (ejecutado por GitHub Actions)
source("R/07_predicciones_horarias.R")

Output: 3,200 predicciones con estructura espacial completa:

# predicciones_xgb_nativo_40h_latest.rds
str(predicciones_sf)
# Classes 'sf' and 'data.table'
# 3200 obs. of 15 variables:
#  $ id_estacion      : int  4 4 4 ...
#  $ nombre_estacion  : chr  "Pza. de España" ...
#  $ contaminante     : chr  "Dióxido de Nitrógeno" ...
#  $ fecha_hora       : POSIXct "2025-11-01 13:00:00" ...
#  $ prediccion       : num  28.5 29.1 30.2 ...
#  $ temp_media_c     : num  18.5 19.2 20.1 ...
#  $ humedad_media_pct: num  65 63 61 ...
#  $ geometry         : sfc_POINT (lon, lat WGS84)

Deployment: Producción Sin Servidores

Infraestructura 100% Gratuita

El sistema completo funciona en infraestructura gratuita:

  • GitHub: Repositorio privado/público, Actions (2,000 min/mes gratis)
  • GitHub Container Registry (GHCR): Almacenamiento de Docker images
  • GitHub Releases: Almacenamiento de modelos (16 MB)
  • shinyapps.io: Plan Free (5 apps, 25 horas activas/mes)

Coste mensual real: $0.00

Monitoreo y Observabilidad

flowchart LR
    A[GitHub Actions Logs] -->|Build status| B[✅/❌ Notificaciones]
    C[shinyapps.io Dashboard] -->|Uptime, usuarios| D[📊 Métricas]
    E[Shiny App] -->|Errores runtime| F[🔍 Debug]

    style A fill:#e3f2fd
    style C fill:#f3e5f5
    style E fill:#fff3e0

Monitoreo incluye: - GitHub Actions: Build status, errores de ejecución, duración de workflows - shinyapps.io: Uptime, usuarios activos, crashes de aplicación - Logs de R: Errores de predicción, warnings de modelos


Consideraciones de Hardware

Desarrollo Local

Mínimo requerido: - CPU: 4 cores, 8 GB RAM - Disco: 20 GB (BD + outputs) - OS: Windows/Mac/Linux con Docker

Recomendado: - CPU: 16+ cores, 32 GB RAM - GPU: CUDA-capable (RTX 3060+, 8+ GB VRAM) - Disco: SSD 50 GB

Training de Modelos

Sin GPU (CPU-only): - 16 cores → ~5 min total (5 modelos) - 8 cores → ~12 min total - 4 cores → ~30 min total

Con GPU: - RTX 4070 Ti (12 GB VRAM) → ~2 min total - RTX 3060 (8 GB VRAM) → ~3 min total - Requiere: CUDA 11.x+, cuDNN

Setup GPU en R:

# Instalar XGBoost con GPU support (Windows/Linux)
install.packages("xgboost",
  repos = "https://xgboost-builds.s3.amazonaws.com")

# Verificar GPU disponible
library(xgboost)
xgb.DMatrix.save(xgb.DMatrix(matrix(1)), "test.buffer")
# Si no error → GPU OK

Producción (GitHub Actions)

GitHub-hosted runner: - ubuntu-latest: 2 cores, 7 GB RAM, 14 GB SSD - Sin GPU (predicciones solo CPU) - Suficiente para predicciones (no training)

Workflow actual (timing promedio): - Pull Docker image: ~2 min - Download models: ~5 sec - Predicciones: ~10 sec - Deploy Shiny: ~30 sec - Total: ~3-4 min


Raspberry Pi: Edge Computing (Opcional)

El sistema está diseñado para funcionar también en Raspberry Pi 5:

Specs Raspberry Pi 5: - CPU: ARM Cortex-A76 (4 cores, 2.4 GHz) - RAM: 8 GB LPDDR4 - Sin GPU (solo CPU)

Casos de uso: - ✅ Dashboard local (Shiny en puerto 3838) - ✅ Predicciones (load modelos pre-trained) - ⚠️ Training: Lento pero posible (~30 min)

Ventajas Edge Computing: - Bajo consumo (5W vs 300W PC) - Siempre encendido (servidor local 24/7) - Sin dependencia de cloud - Latencia cero para usuarios locales

Setup Raspberry Pi:

# Instalar Docker en Raspberry Pi OS (ARM64)
curl -sSL https://get.docker.com | sh
sudo usermod -aG docker pi

# Pull imagen multi-arch
docker pull ghcr.io/michal0091/madrid-air-quality-system/air-quality-predictor:latest

# Ejecutar predicciones locales
docker run --rm -v $(pwd):/app \
  -e DB_HOST="192.168.1.100" \
  ghcr.io/.../air-quality-predictor:latest \
  Rscript R/07_predicciones_horarias.R

# Shiny local
docker run -d -p 3838:3838 \
  -v $(pwd)/app:/srv/shiny-server \
  rocker/shiny:latest

Lecciones Aprendidas y Conclusiones

1. R es Viable para ML en Producción

Este proyecto demuestra que R no es solo para análisis exploratorio. Con el stack correcto:

  • data.table supera a pandas en velocidad
  • xgboost GPU es tan rápido como cualquier implementación Python
  • sf + PostGIS ofrecen capacidades GIS de nivel profesional
  • Shiny elimina la necesidad de frontend/backend separados

2. La Infraestructura Invisible Es Lo Que Importa

Las tecnologías que hacen funcionar todo:

  • RSPM: Sin binarios precompilados, Docker builds serían 93% más lentos
  • GHCR: Sin registry gratuito, necesitaríamos Docker Hub paid o AWS ECR
  • GitHub Releases: Sin storage separado, modelos de 16 MB inflarían repo con Git LFS
  • XGBoost nativo: Sin modelos compactos, GitHub Actions fallaría por OOM

3. Optimización ≠ Solo Algoritmos

La reducción de 1.5 GB a 16 MB no fue cambiar hiperparámetros. Fue:

  • Eliminar abstracción innecesaria (CARET)
  • Usar formato binario nativo (.model vs .rds)
  • Arquitectura gradient boosting (árboles secuenciales vs paralelos)
  • Histogram binning de XGBoost

4. MLOps Es Accesible

Todo el stack MLOps (CI/CD, containerización, deployment) funciona en infraestructura 100% gratuita:

  • GitHub Actions: 2,000 min/mes
  • GHCR: Ilimitado para repos públicos
  • shinyapps.io: 25 horas activas/mes
  • Coste mensual: $0

Impacto y Aplicaciones

Contribución Open Source

  • Código abierto: Todo el código disponible en GitHub
  • Documentación completa: Workflow técnico de 10,000+ palabras
  • Reproducible: Cualquiera puede clonar y ejecutar localmente
  • Educativo: Ejemplo real de MLOps end-to-end en R

Aplicabilidad Profesional

Casos de uso transferibles:

  • Análisis de series temporales: Predicción de demanda, forecasting financiero
  • Datos espaciales: GIS, análisis de movilidad, urban planning
  • Procesamiento masivo: ETL de millones de registros en R
  • Deployment automático: CI/CD para aplicaciones R en producción

Sectores aplicables:

  • Medio ambiente: Calidad del aire, agua, suelo
  • Salud pública: Epidemiología espacial, predicción de brotes
  • Smart cities: Tráfico, energía, residuos
  • Finanzas: Predicción de riesgo espacial, fraude geográfico

Recursos y Enlaces

Proyecto

Tecnologías Clave

Datos Abiertos


Este proyecto representa la convergencia de análisis de datos, ingeniería de software y MLOps, demostrando que el ecosistema R es una opción viable, eficiente y cost-effective para sistemas de Machine Learning en producción.

Lo extraordinario no son solo los modelos predictivos, sino la infraestructura automatizada que los soporta día tras día, sin intervención humana, en hardware gratuito.