--- title: "Gasto público por sectores (Eurostat)" # ✎ USUARIO: título del cuadro de mandos output: flexdashboard::flex_dashboard: orientation: columns runtime: shiny --- ```{r global, include=FALSE} # ============================================================================== # CHUNK "global": LO COMÚN A TODAS LAS PÁGINAS, UNA SOLA VEZ # ============================================================================== knitr::opts_chunk$set(warning = FALSE, message = FALSE, echo = FALSE) library(flexdashboard) # Estructura del cuadro de mando en páginas y columnas library(shiny) # Reactividad library(fpp3) # tsibble, feasts y fable: series temporales, STL y ARIMA library(lmtest) # Test de Breusch-Pagan (aedv_tests_residuos, aedv_lm_analisis) library(gt) # Tablas de los tests coloreadas (aedv_tabla_tests, aedv_tabla_lm) library(leaflet) # Mapa coroplético library(sf) # Geometría de los países library(factoextra) # Biplot y gráfico del k-means library(plotly) # Gráficas dinámicas: ggplotly convierte los ggplot library(tidyverse) # Manipulación de datos y ggplot2 (el último, para que # dplyr::filter() no quede tapado por plotly) # Las tablas interactivas son de la librería DT, que se usa con el prefijo DT:: # asociamos funciones a determinadas librerías para evitar posibles errores select <- dplyr::select mutate <- dplyr::mutate filter <- dplyr::filter group_by <- dplyr::group_by summarise <- dplyr::summarise selectInput <- shiny::selectInput # theme por defecto para las gráficas theme_set(theme_bw(base_size = 12)) # Funciones auxiliares del curso source("https://ctim.es/AEDV/utilidades.R") # LOS DATOS, una sola descarga para las cinco páginas GASTO <- readRDS(url("https://ctim.es/AEDV/data/eurostat_GASTO_PUBLICO_sector.rds")) # Rótulo de cada medida, con sus unidades -> nombre de la columna MEDIDAS <- c( "Gasto per cápita (euros por habitante)" = "gasto_per_capita", "% del PIB" = "pct_pib", "% del gasto público total" = "pct_gasto", "Gasto (millones de euros)" = "gasto" ) # GEOMETRÍA DE LOS PAÍSES, recortada a los de los datos. La geometría usa el # código ISO de tres letras y Eurostat el suyo de dos (con EL para Grecia). # Se descarga primero a un fichero temporal porque, en algunos sistemas, # st_read no puede acceder directamente a URLs https. ISO3 <- c(AT = "AUT", BE = "BEL", BG = "BGR", CH = "CHE", CY = "CYP", CZ = "CZE", DE = "DEU", DK = "DNK", EE = "EST", EL = "GRC", ES = "ESP", FI = "FIN", FR = "FRA", HR = "HRV", HU = "HUN", IE = "IRL", IS = "ISL", IT = "ITA", LT = "LTU", LU = "LUX", LV = "LVA", MT = "MLT", NL = "NLD", NO = "NOR", PL = "POL", PT = "PRT", RO = "ROU", SE = "SWE", SI = "SVN", SK = "SVK") tmp <- tempfile(fileext = ".geojson") curl::curl_download("https://ctim.es/AEDV/data/geo_countries.geojson", tmp) GEOMETRIA <- st_read(tmp, quiet = TRUE) |> filter(ISO_A3 %in% ISO3) unlink(tmp) # Listas donde las páginas guardan lo suyo (ver AISLAMIENTO DE LAS PÁGINAS) PAGINAS <- list() # páginas 2 a 5 seleccion <- list() # página 1 ``` ```{css} /* Tablas de la barra lateral (métricas de la predicción, valores del mapa): letra algo más pequeña y celdas con menos relleno (flexdashboard les pone 12 px por lado), para que los números quepan sin desbordar el panel */ .sidebar table { font-size: 90%; } .sidebar table th, .sidebar table td { padding-left: 4px !important; padding-right: 4px !important; } ``` Exploración de series ================================================== ```{r} # PASO 1: LOS DATOS EN FORMATO TIDY: una fila por país, sector, medida y año, # con una sola columna de valores data <- GASTO |> pivot_longer(all_of(unname(MEDIDAS)), names_to = "medida", values_to = "value") |> mutate(medida = names(MEDIDAS)[match(medida, MEDIDAS)], # rótulo con unidades year = as.integer(year(time))) |> filter(!is.na(value)) |> # pct_gasto no existe para el Total select(geo, sector, medida, year, value) ``` ```{r} # PASO 2: VECTORES CON LAS OPCIONES ORDENADAS # PAÍSES, ORDENADOS DE MAYOR A MENOR GASTO PER CÁPITA TOTAL EN SU ÚLTIMO AÑO, # CON EUROPE 27 Y ESPAÑA DELANTE Paises <- GASTO |> filter(sector == "Total") |> group_by(geo) |> filter(time == max(time)) |> ungroup() |> arrange(desc(gasto_per_capita)) |> pull(geo) Paises <- unique(c("Europe 27", "Spain", Paises)) # SECTORES EN EL ORDEN DE LA CLASIFICACIÓN COFOG, CON EL TOTAL AL FINAL Sectores <- unique(GASTO$sector) ``` ```{r} # PASO 3: VARIABLES Y BLOQUES DE ATRIBUTOS # STRING CON EL NOMBRE DEL ATRIBUTO QUE CORRESPONDE A LA FECHA date <- "year" # STRING CON EL NOMBRE DEL ATRIBUTO QUE CORRESPONDE AL VALOR NUMÉRICO value <- "value" # IDENTIFICADOR DE LA PÁGINA: VA DELANTE DEL NOMBRE DE TODOS SUS CONTROLES pageId <- "expl" # checkboxInput DEL ATRIBUTO PRINCIPAL: TODOS LOS PAÍSES O UNA SELECCIÓN input.main.checkboxInput.pageId <- list( list(label = "Todos los países", value = TRUE) ) # ATRIBUTO PRINCIPAL: SEPARA LAS GRÁFICAS DEL facet_wrap input.main.config.pageId <- list( list(col.name = "geo", label = "País", choices = Paises, selected = Paises[1:6], multiple = TRUE) ) # ATRIBUTOS COMBINABLES: CADA OPCIÓN ELEGIDA ES UNA LÍNEA DE COLOR EN CADA GRÁFICA input.multiple.config.pageId <- list( list(col.name = "sector", label = "Sector", choices = Sectores, selected = c("Salud", "Educación", "Protección social"), multiple = TRUE) ) # ATRIBUTOS CON UN ÚNICO VALOR SELECCIONABLE input.single.config.pageId <- list( list(col.name = "medida", label = "Medida", choices = names(MEDIDAS), selected = "% del PIB", multiple = FALSE) ) ``` ```{r} # COMPROBACIÓN DE LA CONFIGURACIÓN. UN NOMBRE DE COLUMNA MAL ESCRITO FALLA AQUÍ, # AL ARRANCAR Y DICIENDO CUÁL ES, EN VEZ DE FALLAR DENTRO DE UN render columnas.configuradas <- c( date, value, vapply(c(input.main.config.pageId, input.multiple.config.pageId, input.single.config.pageId), function(x) x$col.name, character(1)) ) faltan <- setdiff(columnas.configuradas, names(data)) if (length(faltan) > 0) stop("La configuración nombra columnas que no están en los datos: ", paste(faltan, collapse = ", "), ". Las columnas disponibles son: ", paste(names(data), collapse = ", ")) ``` Column {.sidebar data-width=220} -------------------------------------------------- ```{r} # IDENTIFICADORES DE LOS CONTROLES: EL DE LA PÁGINA DELANTE id_control <- function(nombre) paste0(pageId, ".", nombre) # selectInput DE ATRIBUTOS COMBINABLES Y FIJOS crear_selectInput_pageId <- function(config) { lapply(config, function(x) { selectInput( inputId = id_control(x$col.name), label = x$label, choices = x$choices, selected = x$selected, multiple = x$multiple ) }) } # checkboxInput DEL ATRIBUTO PRINCIPAL crear_checkboxInput_pageId <- function(config) { lapply(config, function(x) { checkboxInput( inputId = id_control("checkboxInput"), label = x$label, value = x$value ) }) } # selectInput DEL ATRIBUTO PRINCIPAL: SOLO SI NO SE ELIGEN TODOS crear_selectInput_pageId_main <- function(config) { val <- input[[id_control("checkboxInput")]] if (isFALSE(val)) { lapply(config, function(x) { selectInput( inputId = id_control(x$col.name), label = x$label, choices = x$choices, selected = x$selected, multiple = x$multiple ) }) } else { return(NULL) } } ``` ```{r} # BLOQUE 1. GESTIÓN DEL ATRIBUTO PRINCIPAL output$main.checkboxInput_pageId_ui <- renderUI({crear_checkboxInput_pageId(input.main.checkboxInput.pageId)}) output$main_pageId_ui <- renderUI({crear_selectInput_pageId_main(input.main.config.pageId)}) htmltools::div( style = " background-color: rgb(208, 216, 225); padding: 0px 3px 0px 3px; /* ↑ → ↓ ← */ margin-bottom: 5px; border: 2px solid rgb(0,0,0)", uiOutput("main.checkboxInput_pageId_ui"), uiOutput("main_pageId_ui") ) ``` ```{r} # BLOQUE 2. ATRIBUTOS COMBINABLES CON SELECCIÓN MÚLTIPLE if (length(input.multiple.config.pageId) > 0) { output$multiple_pageId_ui <- renderUI({crear_selectInput_pageId(input.multiple.config.pageId)}) htmltools::div( style = " background-color: rgb(208, 216, 225); padding: 0px 3px 0px 3px; /* ↑ → ↓ ← */ margin-bottom: 5px; border: 2px solid rgb(0,0,0)", uiOutput("multiple_pageId_ui") ) } ``` ```{r} # BLOQUE 3. ATRIBUTOS CON UN SOLO VALOR SELECCIONABLE if (length(input.single.config.pageId) > 0) { output$single_pageId_ui <- renderUI({crear_selectInput_pageId(input.single.config.pageId)}) htmltools::div( style = " background-color: rgb(208, 216, 225); padding: 0px 3px 0px 3px; /* ↑ → ↓ ← */ margin-bottom: 5px; border: 2px solid rgb(0,0,0)", uiOutput("single_pageId_ui") ) } ``` ```{r} # BLOQUE 4. FORMATEO DEL GRÁFICO output$formateo_grafico_pageId_ui <- renderUI({ htmltools::div( style = " background-color: rgb(208, 216, 225); padding: 0px 3px 0px 3px; /* ↑ → ↓ ← */ margin-bottom: 5px; border: 2px solid rgb(0,0,0)", "FORMATEO GRÁFICOS:", div( style = "display: flex; align-items: center; gap: 5px;", tags$label("Nº columnas:", style = "margin: 0"), numericInput(id_control("ncol"), label = NULL, value = 4, min = 1, max = 6, step = 1, width = "80px") ), div( style = "display: flex; align-items: center; gap: 40px;", tags$label("Nº filas:", style = "margin: 0"), numericInput(id_control("nrow"), label = NULL, value = 6, min = 1, max = 8, step = 1, width = "80px") ), # OPCIÓN PARA DEJAR LIBRE O FIJA LA ESCALA DEL EJE Y checkboxInput(id_control("scale_free_y"), "ESCALA EJE Y LIBRE", value = TRUE), # PERMITE VISUALIZAR EN DIFERENTES PÁGINAS numericInput(id_control("page"), label = "PÁGINA DATOS MOSTRADA", value = 1, min = 1, max = 100, step = 1) ) }) uiOutput("formateo_grafico_pageId_ui") ``` ```{r} # BOTÓN QUE DISPARA EL DIBUJADO: HASTA PULSARLO NO SE DIBUJA NADA shiny::actionButton(id_control("dibujar"), "Dibujar gráfica") ``` Column -------------------------------------------------- ```{r} # RESET DE LA PÁGINA DE DATOS AL CAMBIAR LA SELECCIÓN DEL ATRIBUTO PRINCIPAL # O EL Nº DE FILAS O COLUMNAS observeEvent(input[[id_control("checkboxInput")]], { updateNumericInput(session, id_control("page"), value = 1) }) observeEvent(input[[id_control("ncol")]], { updateNumericInput(session, id_control("page"), value = 1) }) observeEvent(input[[id_control("nrow")]], { updateNumericInput(session, id_control("page"), value = 1) }) ``` ```{r} # REACTIVA QUE EMPAQUETA TODOS LOS INPUTS DE ESTA PÁGINA Y SOLO SE RECALCULA # AL PULSAR EL BOTÓN "dibujar". SE LEE MÁS ABAJO COMO seleccion[[pageId]]() seleccion[[pageId]] <- shiny::eventReactive(input[[id_control("dibujar")]], { # LAS CASILLAS SE COMPRUEBAN CON !is.null(): DESMARCADAS VALEN FALSE, Y PARA # req() UN FALSE ES "TODAVÍA NO DISPONIBLE" req(!is.null(input[[id_control("checkboxInput")]]), !is.null(input[[id_control("scale_free_y")]]), input[[id_control("ncol")]], input[[id_control("nrow")]], input[[id_control("page")]]) list( checkboxInput = input[[id_control("checkboxInput")]], scale_free_y = input[[id_control("scale_free_y")]], ncol = input[[id_control("ncol")]], nrow = input[[id_control("nrow")]], page = input[[id_control("page")]], main = input[[id_control(input.main.config.pageId[[1]]$col.name)]], multiple = lapply(input.multiple.config.pageId, function(x) input[[id_control(x$col.name)]]), single = lapply(input.single.config.pageId, function(x) input[[id_control(x$col.name)]]) ) }) ``` ### ```{r} renderPlotly({ # SELECCIÓN EMPAQUETADA AL PULSAR EL BOTÓN datos <- seleccion[[pageId]]() tb <- data # FILTRAMOS POR LOS ATRIBUTOS COMBINABLES DE SELECCIÓN MÚLTIPLE for (k in seq_along(input.multiple.config.pageId)) { tb <- tb |> filter(!!sym(input.multiple.config.pageId[[k]]$col.name) %in% datos$multiple[[k]]) } # FILTRAMOS POR LOS ATRIBUTOS QUE SOLO ADMITEN UNA SELECCIÓN for (k in seq_along(input.single.config.pageId)) { tb <- tb |> filter(!!sym(input.single.config.pageId[[k]]$col.name) == datos$single[[k]]) } # FILTRAMOS POR EL ATRIBUTO PRINCIPAL (SI NO SE ELIGEN TODOS) main.col <- input.main.config.pageId[[1]]$col.name if (isFALSE(datos$checkboxInput)) { tb <- tb |> filter(!!sym(main.col) %in% datos$main) } # Nº DE GRÁFICAS POR PÁGINA NPlotsPerPage <- datos$ncol * datos$nrow PageInit <- (datos$page - 1) * NPlotsPerPage + 1 PageEnd <- datos$page * NPlotsPerPage # FILTRAMOS EL ATRIBUTO PRINCIPAL SEGÚN LA PÁGINA MOSTRADA Y LO PASAMOS A # factor PARA CONSERVAR EL ORDEN EN LAS GRÁFICAS opciones <- if (isFALSE(datos$checkboxInput)) datos$main else input.main.config.pageId[[1]]$choices tb <- tb |> filter(!!sym(main.col) %in% opciones[PageInit:PageEnd]) |> mutate(!!sym(main.col) := factor(!!sym(main.col), levels = unique(input.main.config.pageId[[1]]$choices))) req(nrow(tb) > 0) # ATRIBUTO COMBINADO: LOS ATRIBUTOS COMBINABLES CON MÁS DE UNA OPCIÓN ELEGIDA atributo.combinado.name <- "" tb <- tb |> mutate(atributo.combinado = "") for (k in seq_along(input.multiple.config.pageId)) { if (length(datos$multiple[[k]]) > 1) { atributo.combinado.name <- paste0(atributo.combinado.name, "/", input.multiple.config.pageId[[k]]$col.name) tb <- tb |> mutate(atributo.combinado = paste0(atributo.combinado, "/", !!sym(input.multiple.config.pageId[[k]]$col.name))) } } if (atributo.combinado.name != "") { atributo.combinado.name <- substr(atributo.combinado.name, 2, nchar(atributo.combinado.name)) tb <- tb |> mutate(atributo.combinado = substr(atributo.combinado, 2, nchar(atributo.combinado))) } scale_y <- ifelse(datos$scale_free_y == TRUE, "free_y", "fixed") if (atributo.combinado.name == "") { p <- tb |> ggplot(aes(!!sym(date), !!sym(value))) } else { p <- tb |> ggplot(aes(!!sym(date), !!sym(value), colour = atributo.combinado)) } p <- p + geom_line() # facet_wrap: Nº DE COLUMNAS FIJO SOLO SI LA PÁGINA ESTÁ COMPLETA if (length(unique(tb[[main.col]])) < NPlotsPerPage) { p <- p + facet_wrap(vars(!!sym(main.col)), scales = scale_y) } else { p <- p + facet_wrap(vars(!!sym(main.col)), scales = scale_y, ncol = datos$ncol) } p <- p + labs(x = "", y = "", colour = atributo.combinado.name) ggplotly(p) }) ``` Predicción ================================================== ```{r, include=FALSE} # ✎ PASO 3: IDENTIFICADOR DE LA PÁGINA PAGINA_ID <- "pred" # ✎ # ✎ PASO 4: LOS DATOS. Un tsibble LARGO con un índice temporal, una o varias # columnas key y UNA de valores. Cada columna key es un atributo con su propio # desplegable, y la serie es la combinación de un valor de cada uno: aquí el # país, el sector y la medida. Las combinaciones sin datos (el % del gasto # público total del Total) no forman serie. DATOS <- GASTO |> # ✎ pivot_longer(all_of(unname(MEDIDAS)), names_to = "medida", values_to = "valor") |> filter(!is.na(valor)) |> transmute(fecha = as.integer(year(time)), geo, sector, medida = names(MEDIDAS)[match(medida, MEDIDAS)], # rótulo con unidades valor) |> as_tsibble(index = fecha, key = c(geo, sector, medida)) # ✎ PASO 5: RÓTULOS Y OPCIONES DE ARRANQUE ETIQUETAS_ATRIBUTOS <- c(geo = "País:", sector = "Sector:", medida = "Medida:") # ✎ rótulo de cada key ATRIBUTO_UNIDAD <- "medida" # ✎ key cuyo valor rotula el eje vertical (NULL: el nombre de la columna de valores) SERIE_INICIAL <- c(geo = "Spain", sector = "Total", medida = "% del PIB") # ✎ METODO_INICIAL <- "ARIMA" # ✎ "ARIMA" o "STL + ARIMA" # AISLAMIENTO DE LA PÁGINA: lo que define el usuario se guarda AHORA en # PAGINAS[[PAGINA_ID]], y el resto de la página lo lee solo de ahí PAGINAS[[PAGINA_ID]] <- list( datos = DATOS, etiquetas_atributos = ETIQUETAS_ATRIBUTOS, atributo_unidad = ATRIBUTO_UNIDAD, serie_inicial = SERIE_INICIAL, metodo_inicial = METODO_INICIAL ) ``` ```{r, include=FALSE} # ============================================================================== # FUNCIONES DE LA PLANTILLA (no dependen de la página) # ============================================================================== # OPCIONES DE LOS CONTROLES, DEDUCIDAS DE CADA SERIE: del número de # observaciones y del periodo estacional (1 en una serie anual) opciones_de <- function(ts) { n <- nrow(ts) periodo <- tsibble::guess_frequency(ts[[tsibble::index_var(ts)]]) # Horizonte: unos pocos ciclos, y nunca más de la quinta parte de la serie h_max <- max(2, min(max(3 * periodo, 10), n %/% 5)) h_def <- max(min(2 * periodo, h_max), min(5, h_max)) # Periodos observados con los que se ajusta el modelo (siempre los ÚLTIMOS) paso <- if (periodo > 1) periodo else 5 minimo <- min(n, max(10, 3 * periodo)) tramo_def <- min(n, max(6 * periodo, 30)) tramos <- sort(unique(c(seq(minimo, n, by = paso), tramo_def, n))) list(periodo = periodo, h = 2:h_max, h_def = h_def, tramos = tramos, tramo_def = tramo_def) } # Las ventanas de STL han de ser impares impar <- function(x) { x <- floor(x) ifelse(x %% 2 == 0, x + 1, x) } # Ventanas de la tendencia para un tramo de n observaciones, sin pasar de la # mitad del tramo ventanas_tendencia <- function(n, periodo) { vt <- impar(c(0.6, 1, 1.5, 2.5, 3.5, 5) * max(periodo, 5)) vt <- sort(unique(vt[vt > 3 & vt < n / 2])) if (length(vt) == 0) vt <- 5 as.character(vt) } # Ventana de la tendencia con la que se arranca: la del medio de las ofrecidas ventana_tendencia_def <- function(vt) vt[ceiling(length(vt) / 2)] # Ventanas de la estacionalidad, en ciclos (solo si la serie tiene periodo > 1) VENTANAS_ESTACIONALIDAD <- c("periodic", "7", "11", "13", "21") VENTANA_ESTACIONALIDAD_DEF <- "13" # "periodic" se pasa a STL tal cual, como texto, y el resto como número ventana_estacional_stl <- function(ve) if (ve == "periodic") "periodic" else as.numeric(ve) # EL ERROR DEL AJUSTE: observado − prediccion en el tramo ajustado. `descartar` # quita las primeras observaciones, las que el modelo no ha podido predecir error_de <- function(resultado, descartar = 0) { idx <- tsibble::index_var(resultado) val <- setdiff(names(resultado), c(idx, "prediccion", "lower", "upper"))[1] resultado |> as_tibble() |> filter(!is.na(.data[[val]]), !is.na(prediccion)) |> transmute(fecha = .data[[idx]], error = .data[[val]] - prediccion) |> # (no slice(-seq_len(descartar)): con descartar = 0 dejaría la tabla vacía) filter(row_number() > descartar) } # OBSERVACIONES QUE EL ARIMA NO PUEDE PREDECIR: las d + D·m primeras del tramo, # que se gastan en la diferenciación. Se leen de la descripción del modelo sin_ajuste_arima <- function(resultado) { desc <- aedv_modelo(resultado)$descripcion if (is.null(desc) || is.na(desc)) return(0) d <- str_match(desc, "^ARIMA\\(\\d+,(\\d+),\\d+\\)")[, 2] est <- str_match(desc, "\\)\\(\\d+,(\\d+),\\d+\\)\\[(\\d+)\\]")[, 2:3] if (is.na(d)) return(0) as.numeric(d) + if (is.na(est[1])) 0 else as.numeric(est[1]) * as.numeric(est[2]) } # ============================================================================== # PREPARACIÓN DE LA PÁGINA # ============================================================================== local({ pg <- PAGINAS[[PAGINA_ID]] ts <- pg$datos # COMPROBACIÓN DE LOS DATOS aviso <- paste0("Página '", PAGINA_ID, "': ") if (!tsibble::is_tsibble(ts)) { stop(aviso, "DATOS no es un tsibble: conviértelo con as_tsibble() en el PASO 4.") } if (length(tsibble::key_vars(ts)) == 0) { stop(aviso, "DATOS no tiene columnas key: hace falta al menos una, ", "la que separa las series.") } if (length(tsibble::measured_vars(ts)) != 1) { stop(aviso, "DATOS tiene ", length(tsibble::measured_vars(ts)), " columnas de valores. Debe tener exactamente una.") } col_keys <- tsibble::key_vars(ts) col_fecha <- tsibble::index_var(ts) col_valor <- tsibble::measured_vars(ts)[1] # LONGITUD DE CADA SERIE (cada combinación de las keys), todas de una vez tabla <- as_tibble(ts) |> mutate(across(all_of(col_keys), as.character)) longitud <- tabla |> count(across(all_of(col_keys))) cortas <- longitud |> filter(n < 12) if (nrow(cortas) > 0) { stop(aviso, nrow(cortas), " serie(s) tienen menos de 12 observaciones (la ", "primera: ", paste(unlist(cortas[1, col_keys]), collapse = " · "), "): son demasiado pocas para ajustar un modelo.") } # OPCIONES DE CADA DESPLEGABLE: los valores de cada key, en el orden de los datos valores <- lapply(col_keys, function(k) unique(tabla[[k]])) names(valores) <- col_keys # Rótulo de cada desplegable: el del PASO 5 o, si falta, el nombre de la columna etiquetas <- setNames(as.list(col_keys), col_keys) for (k in intersect(names(pg$etiquetas_atributos), col_keys)) { etiquetas[[k]] <- pg$etiquetas_atributos[[k]] } # ¿EXISTE LA SERIE? Una selección es un vector con nombre: un valor por key existe <- function(sel) { if (!all(col_keys %in% names(sel))) return(FALSE) fila <- longitud for (k in col_keys) fila <- fila |> filter(.data[[k]] == sel[[k]]) nrow(fila) > 0 } # SERIES BAJO DEMANDA. En vez de separar todas las series al arrancar (con # cientos de series, más de un segundo en cada carga), se guarda la tabla y # cada serie se extrae cuando se elige: un tsibble de UNA sola serie, sin # key, que es lo que esperan las funciones de predicción del curso serie_de <- function(sel) { tb <- tabla for (k in col_keys) tb <- tb |> filter(.data[[k]] == sel[[k]]) tb |> select(all_of(c(col_fecha, col_valor))) |> as_tsibble(index = all_of(col_fecha)) } # Serie de arranque: la del PASO 5 o, si no existe, la primera if (!existe(pg$serie_inicial)) pg$serie_inicial <- unlist(longitud[1, col_keys]) unidad <- pg$atributo_unidad pg$col_keys <- col_keys pg$col_fecha <- col_fecha pg$valores <- valores pg$etiquetas <- etiquetas pg$existe <- existe pg$serie_de <- serie_de pg$opciones_de <- function(sel) opciones_de(serie_de(sel)) pg$nombre_de <- function(sel) paste(unlist(sel[col_keys]), collapse = " · ") pg$unidad_de <- function(sel) if (is.null(unidad)) col_valor else sel[[unidad]] pg$datos <- NULL # ya está en la tabla de serie_de(): no se guarda dos veces # IDENTIFICADORES DE LOS CONTROLES Y SALIDAS: el de la página delante p <- PAGINA_ID pg$id <- function(nombre) paste0(p, "_", nombre) PAGINAS[[PAGINA_ID]] <<- pg }) ``` Menú {.sidebar data-width=200} -------------------------------------------------- ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] id <- pg$id # LA SERIE ELEGIDA: el valor de cada desplegable de atributo, como vector con # nombre (NULL mientras falte alguno) id_atributo <- function(k) id(paste0("atr_", k)) seleccion_de <- function() { sel <- lapply(pg$col_keys, function(k) input[[id_atributo(k)]]) if (any(vapply(sel, is.null, logical(1)))) return(NULL) setNames(unlist(sel), pg$col_keys) } # Al cambiar de serie, el horizonte y los periodos observados se adaptan a # ella (si la combinación elegida no tiene datos, se dejan como están) observeEvent(lapply(pg$col_keys, function(k) input[[id_atributo(k)]]), { sel <- seleccion_de() req(sel, pg$existe(sel)) o <- pg$opciones_de(sel) h <- as.numeric(input[[id("h")]]) updateSelectInput(session, id("h"), choices = o$h, selected = min(h, max(o$h))) updateSelectInput(session, id("tramo"), choices = o$tramos, selected = o$tramo_def) }) # Ventanas de STL: solo con STL + ARIMA, y dependen del tramo output[[id("controles_stl")]] <- renderUI({ sel <- seleccion_de() req(sel, pg$existe(sel), input[[id("tramo")]], input[[id("metodo")]]) if (input[[id("metodo")]] != "STL + ARIMA") return(NULL) o <- pg$opciones_de(sel) vt <- ventanas_tendencia(as.numeric(input[[id("tramo")]]), o$periodo) tagList( selectInput(id("ventana_tendencia"), "STL: suavizado tendencia (observaciones):", choices = vt, selected = ventana_tendencia_def(vt)), if (o$periodo > 1) { selectInput(id("ventana_estacionalidad"), "STL: suavizado estacionalidad (ciclos):", choices = VENTANAS_ESTACIONALIDAD, selected = VENTANA_ESTACIONALIDAD_DEF) } ) }) # UN SOLO CÁLCULO PARA TODOS LOS PANELES DE LA PÁGINA. Es un eventReactive(): # solo se recalcula al pulsar «Calcular», y al arrancar no calcula nada pg$calculo <- eventReactive(input[[id("calcular")]], { sel <- seleccion_de() req(sel, input[[id("h")]], input[[id("tramo")]], input[[id("metodo")]]) nombre <- pg$nombre_de(sel) metodo <- input[[id("metodo")]] # COMBINACIÓN SIN DATOS: se explica en vez de dejar los paneles en blanco if (!pg$existe(sel)) { return(list( serie = nombre, metodo = metodo, diagnostico = paste0("No hay datos de la combinación elegida (", nombre, "). Elige otra.") )) } ts <- pg$serie_de(sel) o <- opciones_de(ts) h <- as.numeric(input[[id("h")]]) # (si se pulsa antes de que se actualice el desplegable, el tramo puede # pasar de la longitud de la serie) tramo <- min(as.numeric(input[[id("tramo")]]), nrow(ts)) # TRAMO CON HUECOS: ninguno de los dos métodos funciona, y ninguno lo dice # claramente, así que se comprueba aquí y se explica el motivo faltan <- tsibble::scan_gaps(tail(ts, tramo)) if (nrow(faltan) > 0) { return(list( serie = nombre, metodo = metodo, diagnostico = paste0( "No se puede ajustar el modelo: en los últimos ", tramo, " periodos ", "observados faltan ", nrow(faltan), " por medio (", paste(format(faltan[[pg$col_fecha]]), collapse = ", "), "). Basta con reducir los periodos observados para que el tramo ", "empiece después del último hueco.") )) } if (metodo == "ARIMA") { resultado <- aedv_prediccion_arima(ts, h = h, periodos_atras = tramo) metricas <- aedv_arima_accuracy(resultado) # RMSE, MAE y MASE del ajuste tendencia <- NULL # Ventanas con las que se descompone en la pestaña de STL vt <- ventana_tendencia_def(ventanas_tendencia(tramo, o$periodo)) ve <- VENTANA_ESTACIONALIDAD_DEF } else { # Si se pulsa antes de que existan los controles de STL, req() detiene el # cálculo y basta con volver a pulsar vt <- input[[id("ventana_tendencia")]] req(vt) if (o$periodo > 1) { ve <- input[[id("ventana_estacionalidad")]] req(ve) } else { ve <- VENTANA_ESTACIONALIDAD_DEF # no interviene: STL no estima estacionalidad } resultado <- aedv_prediccion_stl_arima( ts, h = h, periodos_atras = tramo, ventana_tendencia = as.numeric(vt), ventana_estacionalidad = ventana_estacional_stl(ve)) metricas <- NULL # aedv_arima_accuracy() solo admite ARIMA tendencia <- aedv_tendencia(resultado) # atributo: leerlo antes de recortar } error <- error_de(resultado, descartar = if (metodo == "ARIMA") sin_ajuste_arima(resultado) else 0) list( serie = nombre, unidad = pg$unidad_de(sel), # rótulo del eje vertical metodo = metodo, periodo = o$periodo, tramo = tramo, ventanas = list(tendencia = vt, estacionalidad = ve), diagnostico = NULL, datos = ts, # la serie, para la descomposición STL resultado = resultado, tendencia = tendencia, metricas = metricas, error = error, tests = aedv_tests_residuos(error$error, error$fecha) ) }) PAGINAS[[PAGINA_ID]] <<- pg # LOS CONTROLES o_ini <- pg$opciones_de(pg$serie_inicial) tagList( # Un desplegable por atributo: la serie es la combinación de los elegidos lapply(pg$col_keys, function(k) { selectInput(id_atributo(k), pg$etiquetas[[k]], choices = pg$valores[[k]], selected = pg$serie_inicial[[k]]) }), selectInput(id("h"), "Horizonte de predicción (periodos):", choices = o_ini$h, selected = o_ini$h_def), selectInput(id("tramo"), "Periodos observados para ajustar el modelo:", choices = o_ini$tramos, selected = o_ini$tramo_def), radioButtons(id("metodo"), "Método de predicción:", choices = c("ARIMA", "STL + ARIMA"), selected = pg$metodo_inicial), uiOutput(id("controles_stl")), # Lanza el cálculo: hasta pulsarlo no se calcula nada actionButton(id("calcular"), "Calcular") ) }) ``` ```{r} # MÉTRICAS DEL AJUSTE, solo con ARIMA local({ pg <- PAGINAS[[PAGINA_ID]] renderUI({ resultado <- pg$calculo() req(resultado, is.null(resultado$diagnostico), resultado$metodo == "ARIMA") tags$strong("Métricas de ajuste ARIMA") }) }) ``` ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] renderTable({ resultado <- pg$calculo() req(resultado, is.null(resultado$diagnostico), resultado$metodo == "ARIMA") resultado$metricas }, digits = 2) }) ``` Column {.tabset} -------------------------------------------------- ### Predicción ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] renderPlotly({ resultado <- pg$calculo() req(resultado) validate(need(is.null(resultado$diagnostico), resultado$diagnostico)) # Se dibuja el tramo ajustado y la predicción tramo <- resultado$resultado |> filter(!is.na(prediccion)) titulo <- paste0(resultado$serie, " · ", resultado$metodo) if (resultado$metodo == "ARIMA") { return(aedv_dibujar_arima_dinamico(tramo, titulo = titulo, y_lab = resultado$unidad)) } # Con STL + ARIMA se superpone la tendencia (como argumento, no con un # add_lines() posterior, que descuadraría el eje de fechas). Sin # estacionalidad, la tendencia predicha coincide con la predicción y solo # se dibuja la observada tendencia <- resultado$tendencia if (resultado$periodo == 1) { tendencia <- tendencia |> filter(!predicha) } aedv_dibujar_stl_arima_dinamico(tramo, titulo = titulo, y_lab = resultado$unidad, tendencia = tendencia) }) }) ``` ### Error del ajuste y tests de ruido blanco ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] id <- pg$id output[[id("grafico_error")]] <- renderPlotly({ resultado <- pg$calculo() req(resultado) validate(need(is.null(resultado$diagnostico), resultado$diagnostico)) p <- resultado$error |> ggplot(aes(x = fecha, y = error)) + geom_hline(yintercept = 0, linetype = "dashed", colour = "steelblue") + geom_line(colour = "grey45") + labs(x = NULL, y = "error") ggplotly(p) }) output[[id("tabla_tests")]] <- renderUI({ resultado <- pg$calculo() req(resultado) validate(need(is.null(resultado$diagnostico), resultado$diagnostico)) aedv_tabla_tests(resultado$tests, titulo = paste0("Error del ajuste de ", resultado$metodo, " (", resultado$serie, ")")) }) # La gráfica arriba, ocupando el alto libre, y la tabla debajo fillCol(flex = c(1, NA), plotlyOutput(id("grafico_error"), height = "100%"), uiOutput(id("tabla_tests"))) }) ``` ### Descomposición STL ```{r} # DESCOMPOSICIÓN STL DEL TRAMO DE AJUSTE, con la misma función interna que usa # aedv_prediccion_stl_arima(). En una serie anual no hay estacionalidad: salen # la serie, la tendencia y el resto local({ pg <- PAGINAS[[PAGINA_ID]] renderPlotly({ resultado <- pg$calculo() req(resultado) validate(need(is.null(resultado$diagnostico), resultado$diagnostico)) vt <- resultado$ventanas$tendencia ve <- resultado$ventanas$estacionalidad comp <- .aedv_componentes_stl(tail(resultado$datos, resultado$tramo), ventana_tendencia = as.numeric(vt), ventana_estacionalidad = ventana_estacional_stl(ve)) |> as_tibble() col_fecha <- pg$col_fecha col_valor <- setdiff(names(comp), c(".model", col_fecha, "trend", "remainder", "season_adjust", grep("^season", names(comp), value = TRUE)))[1] col_season <- setdiff(grep("^season", names(comp), value = TRUE), "season_adjust") componentes <- tibble( fecha = comp[[col_fecha]], observado = comp[[col_valor]], tendencia = comp$trend, estacionalidad = if (length(col_season) == 1) comp[[col_season]], resto = comp$remainder ) |> pivot_longer(-fecha, names_to = "componente", values_to = "valor") |> mutate(componente = factor(componente, levels = unique(componente))) titulo <- paste0(resultado$serie, " · STL con ventana de tendencia ", vt, if (resultado$periodo > 1) paste0(" y de estacionalidad ", ve)) p <- componentes |> ggplot(aes(x = fecha, y = valor)) + geom_line(colour = "grey30") + facet_grid(componente ~ ., scales = "free_y") + labs(x = NULL, y = NULL, title = titulo) ggplotly(p) }) }) ``` Mapa coroplético ================================================== ```{r, include=FALSE} PAGINA_ID <- "mapa" # Una fila por país, función del gasto y año, con el código ISO de tres letras # de la geometría. Europe 27 no es un país: no sale en el mapa, pero sí en la # tabla, como referencia DATOS <- GASTO |> mutate(iso3 = unname(ISO3[code]), anio = as.integer(year(time))) # Año con el que arranca: el último que tienen todos los países ANIO_INICIAL <- DATOS |> group_by(anio) |> filter(n_distinct(code) == n_distinct(DATOS$code)) |> pull(anio) |> max() PAGINAS[[PAGINA_ID]] <- list( datos = DATOS, sectores = unique(DATOS$sector), anios = sort(unique(DATOS$anio), decreasing = TRUE), anio_inicial = ANIO_INICIAL, id = function(nombre) paste0("mapa_", nombre) ) ``` Column {.sidebar data-width=250} -------------------------------------------------- ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] id <- pg$id # Procesado de los datos al pulsar el botón «Calcular» (eventReactive): al # arrancar no se calcula nada, y la tabla y el mapa quedan vacíos hasta el # primer clic pg$calculo <- eventReactive(input[[id("calcular")]], { req(input[[id("medida")]], input[[id("sector")]], input[[id("anio")]]) medida <- input[[id("medida")]] tb <- pg$datos |> filter(sector == input[[id("sector")]], anio == as.integer(input[[id("anio")]])) |> select(iso3, pais = geo, value = all_of(medida)) |> filter(!is.na(value)) |> arrange(desc(value)) # El % del gasto público total no existe para el Total, y en los primeros # años hay pocos países: con menos de 3 no hay nada que colorear validate(need(nrow(tb) >= 3, paste0( "Hay datos de ", nrow(tb), " países para esa medida, sector y ", "año: son demasiado pocos para el mapa. Elige otra combinación."))) list(tb = tb, rotulo = names(MEDIDAS)[MEDIDAS == medida], titulo = paste0(input[[id("sector")]], ", ", input[[id("anio")]])) }) PAGINAS[[PAGINA_ID]] <<- pg tagList( selectInput(id("medida"), "Medida:", choices = MEDIDAS, selected = "gasto_per_capita"), selectInput(id("sector"), "Sector:", choices = pg$sectores, selected = "Total"), selectInput(id("anio"), "Año:", choices = pg$anios, selected = pg$anio_inicial), # Lanza el cálculo: hasta pulsarlo no se calcula nada actionButton(id("calcular"), "Calcular") ) }) ``` ### ```{r} # Tabla con los valores, de mayor a menor local({ pg <- PAGINAS[[PAGINA_ID]] renderTable({ resultado <- pg$calculo() resultado$tb |> transmute(País = pais, valor = format(round(value, 2), big.mark = ".", decimal.mark = ",", nsmall = 2)) }) }) ``` Column -------------------------------------------------- ### ```{r} # Mapa coroplético sobre la geometría ya recortada a los países de los datos local({ pg <- PAGINAS[[PAGINA_ID]] leaflet::renderLeaflet({ resultado <- pg$calculo() join <- GEOMETRIA |> left_join(resultado$tb, join_by(ISO_A3 == iso3)) etiquetas <- paste0( "", join$ADMIN, "
", resultado$titulo, "
", resultado$rotulo, ": ", format(round(join$value, 2), big.mark = ".", decimal.mark = ",") ) |> lapply(htmltools::HTML) # Centrado en Europa aedv_mapa_coropletico(join, join$value, etiquetas, resultado$rotulo, lng = 12, lat = 55, zoom = 3) }) }) ``` Comparación de atributos ================================================== ```{r, include=FALSE} PAGINA_ID <- "comp" # PASO 1: LOS DATOS en formato LARGO: una fila por país (y Europe 27), año, # sector y medida, con una sola columna de valores. Cada punto del diagrama es # un país; los atributos se eligen con desplegables, sin combinarlos en # columnas nuevas DATOS <- GASTO |> pivot_longer(all_of(unname(MEDIDAS)), names_to = "medida", values_to = "valor") |> filter(!is.na(valor)) |> transmute(geo, anio = as.integer(year(time)), sector, medida = names(MEDIDAS)[match(medida, MEDIDAS)], # rótulo con unidades valor) # Año con el que arranca: el último que tienen todos los países ANIO_INICIAL <- DATOS |> group_by(anio) |> filter(n_distinct(geo) == n_distinct(DATOS$geo)) |> pull(anio) |> max() PAGINAS[[PAGINA_ID]] <- list( datos = DATOS, etiqueta = "geo", # PASO 2: columna que identifica cada punto valor = "valor", # y columna de los valores # PASO 3: ATRIBUTOS (columna = rótulo). Los del INDICADOR se eligen por # separado para x y para y; los COMUNES, una sola vez para los dos atributos_indicador = c(sector = "Sector", medida = "Medida"), atributos_comunes = c(anio = "Año"), # Valores de arranque x_inicial = c(sector = "Total", medida = "Gasto per cápita (euros por habitante)"), y_inicial = c(sector = "Salud", medida = "Gasto per cápita (euros por habitante)"), comunes_inicial = c(anio = ANIO_INICIAL), alfa = 0.05, # nivel de significación de los tests id = function(nombre) paste0("comp_", nombre) ) ``` ```{r, include=FALSE} # FUNCIONES DE LA PLANTILLA (no dependen de la página) # Transformaciones, conservando el signo del dato. Yeo-Johnson solo en y: su # lambda se busca con el mejor ajuste lineal con la x ya transformada TRANSFORMACIONES_X <- c("none", "sqrt", "log") TRANSFORMACIONES_Y <- c("none", "sqrt", "log", "YeoJohnson") transforma <- function(v, tipo) { switch(tipo, none = v, sqrt = sign(v) * sqrt(abs(v)), log = sign(v) * log(abs(v)), v ) } # Rótulo de un eje: el indicador y, entre paréntesis, lo que se le ha hecho rotulo <- function(indicador, tipo, normalizado = FALSE) { partes <- c(if (normalizado) "normalizado", if (tipo != "none") tipo) if (length(partes) == 0) indicador else paste0(indicador, " (", paste(partes, collapse = ", "), ")") } # OPCIONES DE CADA DESPLEGABLE: los valores de cada atributo, en el orden de # los datos (los numéricos, como el año, de mayor a menor) local({ pg <- PAGINAS[[PAGINA_ID]] atributos <- c(names(pg$atributos_indicador), names(pg$atributos_comunes)) faltan <- setdiff(c(pg$etiqueta, pg$valor, atributos), names(pg$datos)) if (length(faltan) > 0) { stop("Página '", PAGINA_ID, "': a DATOS le faltan las columnas ", paste(faltan, collapse = ", "), ".") } pg$valores <- lapply(atributos, function(k) { v <- unique(pg$datos[[k]]) if (is.numeric(v)) sort(v, decreasing = TRUE) else v }) names(pg$valores) <- atributos # Un valor de arranque que no existe se cambia por el primero inicial <- function(ini, k) if (isTRUE(ini[[k]] %in% pg$valores[[k]])) ini[[k]] else pg$valores[[k]][1] for (k in names(pg$atributos_indicador)) { pg$x_inicial[k] <- inicial(pg$x_inicial, k) pg$y_inicial[k] <- inicial(pg$y_inicial, k) } for (k in names(pg$atributos_comunes)) { pg$comunes_inicial[k] <- inicial(pg$comunes_inicial, k) } PAGINAS[[PAGINA_ID]] <<- pg }) ``` Column {.sidebar data-width=320} -------------------------------------------------- ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] id <- pg$id # CÁLCULO CENTRAL, del que beben los tres paneles. Es un eventReactive(): solo # se recalcula al pulsar «Calcular», y al arrancar no calcula nada # VALORES ELEGIDOS de un grupo de atributos ("x_", "y_" o "c_" delante del # nombre de la columna), como vector con nombre elegidos <- function(prefijo, atributos) { v <- lapply(names(atributos), function(k) input[[id(paste0(prefijo, k))]]) req(!any(vapply(v, is.null, logical(1)))) setNames(unlist(v), names(atributos)) } # Filtra los datos por unos valores elegidos (vector con nombre) filtra <- function(tb, valores) { for (k in names(valores)) tb <- tb |> filter(as.character(.data[[k]]) == valores[[k]]) tb } # CÁLCULO CENTRAL, del que beben los tres paneles. Es un eventReactive(): solo # se recalcula al pulsar «Calcular», y al arrancar no calcula nada pg$calculo <- eventReactive(input[[id("calcular")]], { req(input[[id("x_scale")]], input[[id("y_scale")]]) x_valores <- elegidos("x_", pg$atributos_indicador) y_valores <- elegidos("y_", pg$atributos_indicador) comunes <- elegidos("c_", pg$atributos_comunes) x_nombre <- paste(x_valores, collapse = ", ") y_nombre <- paste(y_valores, collapse = ", ") x_scale <- input[[id("x_scale")]] y_scale <- input[[id("y_scale")]] normalizar <- isTRUE(input[[id("normalizar")]]) # Una fila por punto con el valor de x y el de y: los datos de los atributos # comunes, filtrados por separado con los de x y con los de y base <- filtra(pg$datos, comunes) eje <- function(valores, nombre) { filtra(base, valores) |> select(etiqueta = all_of(pg$etiqueta), !!nombre := all_of(pg$valor)) } tb <- inner_join(eje(x_valores, "x"), eje(y_valores, "y"), by = "etiqueta") |> na.omit() # NORMALIZACIÓN PREVIA OPCIONAL: cada variable por separado, con el lambda # que más la aproxima a una normal lambda_norm_x <- NA_real_ lambda_norm_y <- NA_real_ if (normalizar) { lambda_norm_x <- aedv_yeojohnson_lambda(tb$x) lambda_norm_y <- aedv_yeojohnson_lambda(tb$y) tb$x <- aedv_yeo_johnson(tb$x, lambda_norm_x) tb$y <- aedv_yeo_johnson(tb$y, lambda_norm_y) } tb$x <- transforma(tb$x, x_scale) lambda <- NA_real_ if (y_scale == "YeoJohnson") { lambda <- aedv_lambda_optimo(tb$x, tb$y) tb$y <- aedv_yeo_johnson(tb$y, lambda) } else { tb$y <- transforma(tb$y, y_scale) } # El logaritmo de un cero deja valores no finitos, que romperían la regresión tb <- tb |> filter(is.finite(x), is.finite(y)) validate(need(nrow(tb) >= 3, paste0( "Hay datos de ", nrow(tb), " países para esa combinación de atributos: ", "son demasiado pocos para la regresión. Elige otra combinación."))) fit <- lm(y ~ x, data = tb) tb$residuo <- residuals(fit) valores <- if (nrow(tb) <= 5000) aedv_lm_analisis(tb$x, tb$y) else NULL # Los controles elegidos viajan dentro del resultado: los rótulos no # cambian hasta que se vuelve a pulsar el botón list(tb = tb, fit = fit, lambda = lambda, valores = valores, lambda_norm_x = lambda_norm_x, lambda_norm_y = lambda_norm_y, x_nombre = x_nombre, y_nombre = y_nombre, y_scale = y_scale, comunes = comunes, normalizar = normalizar, rotulo_x = rotulo(x_nombre, x_scale, normalizar), rotulo_y = rotulo(y_nombre, y_scale, normalizar)) }) PAGINAS[[PAGINA_ID]] <<- pg # Un desplegable por atributo de un grupo desplegables <- function(prefijo, atributos, iniciales, delante = "") { lapply(names(atributos), function(k) { selectInput(id(paste0(prefijo, k)), label = paste0(delante, atributos[[k]]), choices = pg$valores[[k]], selected = iniciales[[k]]) }) } tagList( # NORMALIZACIÓN PREVIA: un criterio DISTINTO del de la opción "YeoJohnson" # del desplegable de y, que busca el mejor ajuste lineal entre las dos checkboxInput(id("normalizar"), label = "Normalizar antes cada variable (Yeo-Johnson)", value = FALSE), desplegables("c_", pg$atributos_comunes, pg$comunes_inicial), desplegables("x_", pg$atributos_indicador, pg$x_inicial, delante = "x: "), selectInput(id("x_scale"), label = "Transformación del indicador x", choices = TRANSFORMACIONES_X, selected = "none"), desplegables("y_", pg$atributos_indicador, pg$y_inicial, delante = "y: "), selectInput(id("y_scale"), label = "Transformación del indicador y", choices = TRANSFORMACIONES_Y, selected = "none"), # Lanza el cálculo: hasta pulsarlo no se calcula nada actionButton(id("calcular"), "Calcular") ) }) ``` ### ```{r} # Resumen del análisis. La recta, R2, sigma y los tests van en la tabla de la # pestaña del error local({ pg <- PAGINAS[[PAGINA_ID]] renderUI({ resultado <- pg$calculo() tb <- resultado$tb # Los atributos comunes, uno por línea (p. ej. el año) comunes <- paste0("", tolower(pg$atributos_comunes), ": ", resultado$comunes[names(pg$atributos_comunes)], "
", collapse = "") texto <- paste( "ANÁLISIS DE REGRESIÓN LINEAL
", comunes, "x:", resultado$rotulo_x, "
", "y:", resultado$rotulo_y, "
", "observaciones:", nrow(tb), "
correlación:", round(cor(tb$x, tb$y), digits = 4) ) if (resultado$normalizar) { texto <- paste(texto, "
lambda normalidad (x):", formatC(resultado$lambda_norm_x, format = "e", digits = 3), "
lambda normalidad (y):", formatC(resultado$lambda_norm_y, format = "e", digits = 3)) } if (resultado$y_scale == "YeoJohnson") { texto <- paste(texto, "
lambda ajuste lineal (y):", formatC(resultado$lambda, format = "e", digits = 3)) } HTML(texto) }) }) ``` Column {.tabset} -------------------------------------------------- ### Diagrama de dispersión ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] renderPlotly({ resultado <- pg$calculo() p <- resultado$tb |> ggplot(aes(x, y, label = etiqueta)) + geom_point() + geom_smooth(method = lm, se = FALSE, formula = y ~ x) + # recta de regresión labs(x = resultado$rotulo_x, y = resultado$rotulo_y) ggplotly(p) }) }) ``` ### Error del ajuste y tests ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] id <- pg$id # Residuos frente a x: una nube sin forma y de anchura constante alrededor # del cero es lo que se espera de un buen ajuste output[[id("grafico_error")]] <- renderPlotly({ resultado <- pg$calculo() p <- resultado$tb |> ggplot(aes(x, residuo, label = etiqueta)) + geom_hline(yintercept = 0, linetype = "dashed", colour = "steelblue") + geom_point(alpha = 0.6) + labs(x = resultado$rotulo_x, y = "residuo") ggplotly(p) }) output[[id("tabla_lm")]] <- renderUI({ resultado <- pg$calculo() validate(need(!is.null(resultado$valores), "Tests no disponibles: el test de Shapiro-Wilk admite como mucho 5.000 observaciones.")) aedv_tabla_lm(resultado$valores, titulo = "Análisis del ajuste de la recta", alpha = pg$alfa) }) fillCol(flex = c(1, NA), plotlyOutput(id("grafico_error"), height = "100%"), uiOutput(id("tabla_lm"))) }) ``` Column {data-width=300} -------------------------------------------------- ### Valor de los indicadores transformados ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] renderTable({ resultado <- pg$calculo() tabla <- resultado$tb |> arrange(desc(x)) |> transmute( etiqueta, x = prettyNum(round(x, digits = 2), big.mark = ","), y = prettyNum(round(y, digits = 2), big.mark = ",") ) names(tabla) <- c("País", resultado$x_nombre, resultado$y_nombre) tabla }) }) ``` Análisis de atributos ================================================== ```{r, include=FALSE} # ✎ PASO 3: IDENTIFICADOR DE LA PÁGINA PAGINA_ID <- "atr" # ✎ # ✎ PASO 4: LOS DATOS, en formato LARGO: ambito, area, atributo, anio y las # variables numéricas. Los atributos son los 10 sectores (sin el Total, que es # su suma). Dos conjuntos de áreas: todos los países, y los de la Unión # Europea (sin Suiza, Islandia ni Noruega) DATOS <- bind_rows( # ✎ GASTO |> mutate(ambito = "Todos los países"), GASTO |> filter(!code %in% c("CH", "IS", "NO")) |> mutate(ambito = "Unión Europea") ) |> filter(sector != "Total") |> transmute(ambito, area = geo, atributo = sector, anio = as.integer(year(time)), gasto_per_capita, pct_pib, pct_gasto, gasto) # ✎ PASO 5: RÓTULOS Y OPCIONES DE ARRANQUE ETIQUETA_AMBITO <- "Países:" # ✎ ETIQUETA_ANIO <- "Año:" # ✎ ETIQUETA_VARIABLE <- "Variable:" # ✎ ETIQUETAS_VARIABLES <- setNames(names(MEDIDAS), MEDIDAS) # ✎ columna -> rótulo RESALTAR <- c("Europe 27", "Spain") # ✎ áreas resaltadas en el biplot y el k-means AMBITO_INICIAL <- "Todos los países" # ✎ ANIO_INICIAL <- "Último año de cada área" # ✎ o un año concreto VARIABLE_INICIAL <- "pct_gasto" # ✎ la composición del gasto K_INICIAL <- 3 # ✎ YEO_JOHNSON_INICIAL <- TRUE # ✎ PAGINAS[[PAGINA_ID]] <- list( datos = DATOS, etiqueta_ambito = ETIQUETA_AMBITO, etiqueta_anio = ETIQUETA_ANIO, etiqueta_variable = ETIQUETA_VARIABLE, etiquetas_variables = ETIQUETAS_VARIABLES, resaltar = RESALTAR, ambito_inicial = AMBITO_INICIAL, anio_inicial = ANIO_INICIAL, variable_inicial = VARIABLE_INICIAL, k_inicial = K_INICIAL, yeo_johnson_inicial = YEO_JOHNSON_INICIAL ) ``` ```{r, include=FALSE} # FUNCIONES DE LA PLANTILLA (no dependen de la página) # Opción del desplegable de años que toma, en cada área, su último año completo ULTIMO_ANIO <- "Último año de cada área" # TABLA DE ATRIBUTOS de un ámbito, un año y una variable: una fila por área # (como nombre de fila) y una columna por atributo. `completos` solo tiene las # áreas y años con todos los atributos; con ULTIMO_ANIO, cada área entra con el # último de sus años completos tabla_atributos <- function(completos, ambito_elegido, anio_elegido, variable) { d <- completos |> filter(ambito == ambito_elegido) if (anio_elegido == ULTIMO_ANIO) { d <- d |> group_by(area) |> filter(anio == max(anio)) |> ungroup() } else { d <- d |> filter(anio == as.integer(anio_elegido)) } d |> select(area, atributo, all_of(variable)) |> pivot_wider(names_from = atributo, values_from = all_of(variable)) |> column_to_rownames(var = "area") } # Decimales de los valores originales, según el percentil 10 de su valor absoluto decimales_de <- function(tabla) { p10 <- quantile(abs(as.matrix(tabla)), 0.1) if (p10 >= 100) 0 else if (p10 >= 10) 1 else 2 } # Tabla interactiva (DT) que ocupa toda la pestaña, con formato español tabla_dt <- function(tabla, decimales) { tabla <- as.data.frame(tabla) DT::datatable(tabla, fillContainer = TRUE, options = list(paging = FALSE, scrollX = TRUE, dom = "ft", # solo el buscador (f) y la tabla (t) language = list(search = "Buscar:"))) |> DT::formatRound(columns = names(tabla), digits = decimales, mark = ".", dec.mark = ",") } # PREPARACIÓN DE LA PÁGINA: comprobación de los datos y tablas de atributos local({ pg <- PAGINAS[[PAGINA_ID]] datos <- pg$datos aviso <- paste0("Página '", PAGINA_ID, "': ") claves <- c("ambito", "area", "atributo", "anio") faltan <- setdiff(claves, names(datos)) if (length(faltan) > 0) { stop(aviso, "a DATOS le faltan las columnas ", paste(faltan, collapse = ", "), ".") } variables <- setdiff(names(datos)[sapply(datos, is.numeric)], claves) if (length(variables) == 0) { stop(aviso, "DATOS no tiene ninguna columna numérica con la que llenar la tabla.") } repetidos <- datos |> count(ambito, area, atributo, anio) |> filter(n > 1) if (nrow(repetidos) > 0) { stop(aviso, "en DATOS hay ", nrow(repetidos), " combinaciones de ámbito, área, ", "atributo y año con más de un valor (la primera: ", repetidos$ambito[1], ", ", repetidos$area[1], ", ", repetidos$atributo[1], ", ", repetidos$anio[1], "). Debe haber uno solo.") } # ÁREAS Y AÑOS COMPLETOS: los que tienen todos los atributos con todas las # variables finitas (así un área es la del mismo año al cambiar de variable) n_atributos <- n_distinct(datos$atributo) completos <- datos |> filter(if_all(all_of(variables), is.finite)) |> group_by(ambito, area, anio) |> filter(n() == n_atributos) |> ungroup() ambitos <- unique(datos$ambito) anios <- c(ULTIMO_ANIO, sort(unique(completos$anio), decreasing = TRUE)) # Áreas del ámbito más pequeño con su último año: fijan el máximo de grupos n_min <- min(sapply(ambitos, function(a) nrow(tabla_atributos(completos, a, ULTIMO_ANIO, variables[1])))) if (n_min < 3) { stop(aviso, "algún ámbito tiene menos de 3 áreas: son demasiado pocas para agruparlas.") } etiquetas <- ifelse(variables %in% names(pg$etiquetas_variables), pg$etiquetas_variables[variables], variables) if (!pg$ambito_inicial %in% ambitos) pg$ambito_inicial <- ambitos[1] if (!pg$anio_inicial %in% anios) pg$anio_inicial <- anios[1] if (!pg$variable_inicial %in% variables) pg$variable_inicial <- variables[1] pg$ambitos <- ambitos pg$anios <- anios pg$variables <- setNames(variables, etiquetas) # rótulo -> nombre de la columna pg$completos <- completos pg$k <- 2:min(8, n_min - 1) pg$k_inicial <- min(pg$k_inicial, max(pg$k)) pg$datos <- NULL # ya está en completos: no se guarda dos veces p <- PAGINA_ID pg$id <- function(nombre) paste0(p, "_", nombre) PAGINAS[[PAGINA_ID]] <<- pg }) ``` Menú {.sidebar data-width=200} -------------------------------------------------- ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] id <- pg$id # UN SOLO CÁLCULO PARA LAS OCHO PESTAÑAS: Yeo-Johnson (si se pide), # tipificar, PCA y k-means. Es un eventReactive(): solo al pulsar «Calcular» pg$calculo <- eventReactive(input[[id("calcular")]], { # La casilla no va dentro de req(): sin marcar vale FALSE req(input[[id("ambito")]], input[[id("anio")]], input[[id("variable")]], input[[id("k")]], !is.null(input[[id("yeo_johnson")]])) ambito <- input[[id("ambito")]] anio <- input[[id("anio")]] variable <- input[[id("variable")]] k <- as.numeric(input[[id("k")]]) yj <- input[[id("yeo_johnson")]] etiqueta <- names(pg$variables)[pg$variables == variable] original <- tabla_atributos(pg$completos, ambito, anio, variable) # En los primeros años hay pocas áreas con todos los atributos validate(need(nrow(original) >= 3 && k < nrow(original), paste0( if (anio == ULTIMO_ANIO) "Con el último año de cada área" else paste("En", anio), " hay ", nrow(original), " áreas de '", ambito, "' con todos los atributos: son demasiado pocas para ", k, " grupos. ", "Elige otro año o menos grupos."))) constantes <- names(original)[sapply(original, sd) == 0] validate(need(length(constantes) == 0, paste0( "La variable vale lo mismo en todas las áreas para ", paste(constantes, collapse = ", "), ": no se puede tipificar. ", "Elige otra combinación."))) # 1. YEO-JOHNSON: cada atributo con SU lambda normalizada <- original if (yj) { normalizada <- original |> mutate(across(everything(), \(v) aedv_yeo_johnson(v, aedv_yeojohnson_lambda(v)))) } # 2. TIPIFICAR tipificada <- scale(normalizada) # 3. PCA pca <- prcomp(normalizada, scale = TRUE) # SIGNO DE LAS COMPONENTES: positivo el loading de mayor valor absoluto signo <- apply(pca$rotation, 2, function(u) sign(u[which.max(abs(u))])) pca$rotation <- sweep(pca$rotation, 2, signo, `*`) pca$x <- sweep(pca$x, 2, signo, `*`) # PC2 SIN VARIANZA: las pestañas que la necesitan lo explican varianza <- pca$sdev^2 / sum(pca$sdev^2) diagnostico <- NULL if (varianza[2] < 1e-10) { diagnostico <- paste0( "PC2 no tiene varianza: todos los atributos tienen los mismos valores ", "tipificados, así que PC1 recoge el 100 % de la varianza. Sin una segunda ", "componente no tienen sentido ni los loadings de PC2, ni el biplot, ni el k-means.") } # 4. K-MEANS sobre las componentes que acumulan el 85 % de la varianza clusters <- if (is.null(diagnostico)) aedv_kmeans(pca, k = k) list( ambito = ambito, etiqueta = etiqueta, yj = yj, k = k, descripcion = paste0(etiqueta, " · ", ambito, " · ", tolower(anio), if (yj) " · con Yeo-Johnson"), original = original, tipificada = tipificada, pca = pca, clusters = clusters, diagnostico = diagnostico ) }) PAGINAS[[PAGINA_ID]] <<- pg tagList( selectInput(id("ambito"), pg$etiqueta_ambito, choices = pg$ambitos, selected = pg$ambito_inicial), selectInput(id("anio"), pg$etiqueta_anio, choices = pg$anios, selected = pg$anio_inicial), selectInput(id("variable"), pg$etiqueta_variable, choices = pg$variables, selected = pg$variable_inicial), selectInput(id("k"), "Nº de grupos del k-means:", choices = pg$k, selected = pg$k_inicial), checkboxInput(id("yeo_johnson"), "Normalizar con Yeo-Johnson antes de tipificar", value = pg$yeo_johnson_inicial), # Lanza el cálculo: hasta pulsarlo no se calcula nada actionButton(id("calcular"), "Calcular") ) }) ``` Column {.tabset} -------------------------------------------------- ### Originales ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] DT::renderDataTable({ resultado <- pg$calculo() tabla_dt(resultado$original, decimales = decimales_de(resultado$original)) }) }) ``` ### Tipificados ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] DT::renderDataTable({ resultado <- pg$calculo() tabla_dt(resultado$tipificada, decimales = 2) }) }) ``` ### Correlación ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] renderPlotly({ resultado <- pg$calculo() resultado$tipificada |> cor() |> aedv_matriz_correlacion(show_values = TRUE, title = paste0("Matriz de correlación · ", resultado$descripcion)) }) }) ``` ### PC's ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] DT::renderDataTable({ resultado <- pg$calculo() tabla_dt(resultado$pca$x, decimales = 2) }) }) ``` ### Loadings ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] id <- pg$id output[[id("loadings_pc1")]] <- renderPlotly({ resultado <- pg$calculo() aedv_pca_loadings_grafico(resultado$pca, 1) |> ggplotly() }) output[[id("loadings_pc2")]] <- renderPlotly({ resultado <- pg$calculo() validate(need(is.null(resultado$diagnostico), resultado$diagnostico)) aedv_pca_loadings_grafico(resultado$pca, 2) |> ggplotly() }) fillCol(plotlyOutput(id("loadings_pc1"), height = "100%"), plotlyOutput(id("loadings_pc2"), height = "100%")) }) ``` ### Varianza ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] renderPlotly({ resultado <- pg$calculo() aedv_pca_varianza_grafico(resultado$pca, titulo = paste0("Varianza explicada · ", resultado$descripcion)) |> ggplotly() }) }) ``` ### Biplot ```{r} # Estático: aedv_pca_biplot() usa factoextra, que con ggplotly() pierde los rótulos local({ pg <- PAGINAS[[PAGINA_ID]] renderPlot({ resultado <- pg$calculo() validate(need(is.null(resultado$diagnostico), resultado$diagnostico)) aedv_pca_biplot(resultado$pca, titulo = paste0("Biplot PCA · ", resultado$descripcion), resaltar = intersect(pg$resaltar, rownames(resultado$pca$x))) }, res = 96) }) ``` ### k-means ```{r} local({ pg <- PAGINAS[[PAGINA_ID]] renderPlot({ resultado <- pg$calculo() validate(need(is.null(resultado$diagnostico), resultado$diagnostico)) aedv_kmeans_grafico(resultado$clusters, titulo = paste0("k-means (k = ", resultado$k, ") sobre ", ncol(attr(resultado$clusters, "datos")), " componentes · ", resultado$descripcion), resaltar = intersect(pg$resaltar, rownames(resultado$pca$x))) }, res = 96) }) ```