1 Introducción

Las pruebas Saber 11, aplicadas por el Instituto Colombiano para la Evaluación de la Educación (ICFES), son el principal instrumento con el que el país mide las competencias de los estudiantes al terminar la educación media. Sus resultados tienen consecuencias concretas: influyen en el acceso a la educación superior, en la asignación de becas y créditos educativos, y en la forma en que se evalúa a los colegios. Por esa razón, el puntaje que obtiene un estudiante no es solo un indicador de su aprendizaje, sino también un factor que condiciona sus oportunidades futuras.

Sin embargo, ese resultado no depende únicamente del esfuerzo o las capacidades individuales. Las condiciones del hogar y del entorno escolar marcan diferencias desde antes de presentar la prueba. Un estudiante de estrato bajo, sin computador en casa o matriculado en un colegio rural enfrenta barreras que no tiene quien cuenta con recursos y estudia en una institución urbana. Estas diferencias se hicieron especialmente visibles en 2020, cuando la pandemia de COVID-19 obligó a trasladar las clases a la virtualidad y el acceso a internet y a un equipo de cómputo pasó a ser una condición para seguir estudiando. En el Valle del Cauca, el contraste territorial es marcado: conviven Cali y ciudades intermedias con amplia oferta educativa con municipios de ladera de vocación rural y con Buenaventura, donde persisten rezagos sociales históricos en el Pacífico.

En este contexto, el presente trabajo plantea la siguiente pregunta: ¿en qué medida el género, la edad, el municipio de residencia, el estrato socioeconómico, el acceso a internet, la disponibilidad de computador y la ubicación del colegio permiten clasificar a los estudiantes del Valle del Cauca según si superan o no el percentil 75 nacional en las pruebas Saber 11 de 2020? Se eligió este umbral porque identifica a los estudiantes que se ubican en el 25% más alto del país, el grupo con mayores posibilidades de acceder a programas de becas y a la educación superior de calidad. Responder esta pregunta permite saber qué tanto del alto desempeño puede anticiparse a partir de condiciones que el estudiante no elige, y cuáles de ellas pesan más.

El objetivo del estudio es desarrollar y comparar dos modelos de clasificación supervisada, la regresión logística (Logit) y los K vecinos más cercanos (KNN), aplicados a una base de 33.054 estudiantes del Valle del Cauca. Los dos modelos representan enfoques distintos: el primero es paramétrico y permite cuantificar el efecto de cada variable, y el segundo es no paramétrico y clasifica a partir de la similitud entre estudiantes. Su desempeño se evalúa con la exactitud, la sensibilidad, la especificidad y el área bajo la curva ROC, complementadas con pruebas estadísticas que permiten determinar si las diferencias entre ellos son significativas. Cabe aclarar que el análisis identifica asociaciones y no relaciones causales.

El documento se organiza de la siguiente manera. La metodología describe la base de datos, el proceso de depuración, las variables y los modelos utilizados. Luego, el análisis descriptivo examina la relación de cada característica con el desempeño. Posteriormente se presentan la estimación, la evaluación y la comparación de los modelos. Por último, las conclusiones discuten si los resultados responden a la pregunta planteada, junto con las limitaciones del estudio y las recomendaciones.

2 Metodología

El presente estudio adoptó un enfoque de aprendizaje supervisado orientado a la clasificación binaria del desempeño en las pruebas Saber 11 (Supera / No supera el percentil 75 nacional), implementado mediante dos familias de algoritmos: regresión logística (Logit) y K-Vecinos Más Cercanos (KNN). La metodología se estructuró en etapas secuenciales que garantizaron reproducibilidad, validez y comparabilidad de los resultados.

2.1 Preparación y construcción de la base de datos

Se utilizó la base de resultados de las pruebas Saber 11 del ICFES para estudiantes residentes en el Valle del Cauca durante 2020, que contenía 35.443 registros. El conjunto original fue sometido a un proceso de limpieza que incluyó la selección de las variables relevantes (género, fecha de nacimiento, municipio de residencia, estrato de la vivienda, acceso a internet, disponibilidad de computador y ubicación del colegio) y del percentil global, a partir del cual se construyó la variable dependiente. Con la fecha de nacimiento se calculó la edad de cada estudiante en años cumplidos a 2020. Se eliminaron los registros con datos faltantes, se excluyeron 27 registros con edades no válidas (por fuera del rango de 14 a 60 años, producto de errores de digitación en la fecha de nacimiento) y se corrigió la codificación de caracteres en los nombres de municipios (TULUÃ / TULUÁ). Al final, la base depurada quedó con 33.054 estudiantes. La variable dependiente se construyó a partir del percentil global nacional y toma el valor 1 (“Supera P75”) cuando el estudiante supera el percentil 75 y 0 (“No supera P75”) en caso contrario. En la base depurada solo 7.118 estudiantes (21,5%) superan el P75, frente a 25.936 que no lo superan, lo que revela un desbalance marcado entre clases. Para abordarlo se aplicó una estrategia de submuestreo aleatorio (undersampling), que redujo la clase mayoritaria hasta igualar a la minoritaria, con lo que la muestra final quedó en 14.236 registros distribuidos en partes iguales. Esta decisión evitó que los modelos favorecieran la clase mayoritaria y permitió evaluar su desempeño de forma equilibrada. Las variables explicativas categóricas se transformaron en variables dummy (0/1) y la edad se incluyó como variable numérica, en años. En total se generaron 52 predictores: 41 del municipio, 6 del estrato, uno para cada una de las variables de género, internet, computador y ubicación del colegio, y la edad.

2.2 Partición de datos

Con una semilla aleatoria fija (set.seed(123)) para garantizar reproducibilidad, se realizó una partición estratificada 70/30 de la base balanceada: el 70% de las observaciones (9.966 registros) se destinó al conjunto de entrenamiento y el 30% restante (4.270 registros) se reservó como conjunto de prueba independiente. Esta proporción sigue las recomendaciones estándar del aprendizaje supervisado, pues deja suficientes datos para estimar los modelos y a la vez para validarlos de forma robusta.

2.3 Preprocesamiento de variables

Las variables categóricas se codificaron como dummies (0/1), por lo que comparten la misma escala. La edad, en cambio, se mide en años y tiene un rango mucho más amplio, de modo que en el KNN se estandarizó (se le restó la media y se dividió por la desviación estándar del conjunto de entrenamiento) para que no dominara el cálculo de las distancias. En el Logit se mantuvo en años para que su coeficiente se pueda interpretar directamente. La transformación a dummies se definió sobre la base completa, para que los niveles de las variables fueran los mismos en entrenamiento y prueba. Aun con la edad, muchos estudiantes comparten exactamente el mismo perfil: la base contiene cerca de 5.600 combinaciones distintas de características. Esto generó empates en las distancias del KNN, que se resolvieron añadiendo un ruido aleatorio de magnitud despreciable (jitter, máximo ±0,001) a las variables, el cual desempata perfiles idénticos sin alterar la estructura de los datos.

2.4 Modelado y selección de hiperparámetros

2.4.1 Regresión logística (Logit)

El modelo se estimó por máxima verosimilitud mediante la función glm y familia binomial con enlace logit. Este enfoque paramétrico modela el logaritmo de los odds de superar el P75 como combinación lineal de las variables predictoras. El exponencial de cada coeficiente es el odds ratio (OR) frente a la categoría de referencia, manteniendo constantes las demás variables, en donde valores mayores que 1 indican que la característica aumenta la probabilidad de superar el P75 y menores que 1 que la reduce. Las probabilidades predichas se obtienen con la transformación logística inversa y se convierten en clase mediante un umbral de corte. Como la base de entrenamiento está balanceada, el umbral natural es 0,5. Para el análisis de robustez se calculó el índice de Youden, que maximiza la suma de sensibilidad y especificidad, a partir de las predicciones de validación cruzada, sin usar la base de prueba. Se mantuvo este umbral debido a que el valor obtenido fue muy cercano a 0,5.

El Logit se evaluó mediante validación cruzada estratificada de 10 pliegues sobre el conjunto de entrenamiento.

2.4.2 Modelo K-Vecinos Más Cercanos (KNN)

El algoritmo clasifica a cada estudiante según la clase mayoritaria entre sus k observaciones más cercanas en el espacio de las variables explicativas, usando distancia euclidiana. Es un método no paramétrico, este no estima coeficientes ni supone una forma funcional, por lo que puede captar patrones locales y posibles interacciones entre variables, a cambio de no ofrecer una interpretación directa del efecto de cada característica.

El número de vecinos k se seleccionó en una búsqueda de hiperparámetros de 5 a 205 con paso de 20, mediante la misma validación cruzada de 10 pliegues del Logit, eligiendo el valor que maximiza el AUC. Pues valores pequeños de k producen modelos muy flexibles y propensos al sobreajuste, mientras que valores grandes suavizan en exceso la frontera de decisión. Además, para que la comparación fuera justa, el Logit se evaluó con los mismos pliegues.

2.4.3 Evaluación y comparación de modelos

Ambos modelos se evaluaron sobre el conjunto de prueba, tomando “Supera P75” como clase positiva, con la exactitud, la sensibilidad, la especificidad, el AUC-ROC y la matriz de confusión. Se priorizo el AUC por no depender del umbral de clasificación. Para determinar si las diferencias entre modelos eran estadísticamente significativas se emplearon la prueba de DeLong (comparación de AUC) y la prueba de McNemar (comparación de aciertos), y se revisó la estabilidad del desempeño en los 10 pliegues de validación cruzada.

2.5 Definición de las variables

Las variables utilizadas en el estudio se dividen en tres grupos: dependiente e independientes y las medidas de desempeño. La variable dependiente (SUPERA_P75) se construyó a partir del percentil global nacional y toma el valor 1 si es superior a 75. Esta variable refleja la posición relativa del estudiante frente a todo el país y no solo frente a sus compañeros del departamento, lo que la hace una medida adecuada del alto desempeño.

Las variables independientes se seleccionaron e incorporaron por su relación con las condiciones de aprendizaje y las desigualdades educativas.

El estrato socioeconómico de la vivienda funciona como aproximación al nivel de ingresos y a la calidad del entorno del hogar. Se espera que estratos más altos se asocien con mayor probabilidad de superar el P75, porque implican mejores condiciones materiales, más acceso a recursos educativos y mayor capital cultural. Aunque al ser un dato reportado por el estudiante, puede contener error de medición.

El acceso a internet y la disponibilidad de computador capturan los recursos tecnológicos del hogar. Como el estudio se realiza para el año 2020 son de especial relevancia, pues la pandemia llevó la educación a la virtualidad y estudiar dependía de estas herramientas. Se espera que ambas variables aumenten la probabilidad de superar el P75, y se incluyen por separado porque el computador sin conexión, o la conexión sin dispositivo adecuado, implican condiciones de estudio distintas.

La ubicación del colegio (urbano o rural) refleja diferencias en infraestructura, conectividad, disponibilidad de docentes y oferta educativa. Esta variable se refiere al establecimiento educativo y no al lugar de residencia del estudiante. Se utiliza, por que se espera que los colegios urbanos presenten mayor probabilidad de alto desempeño que los rurales.

El municipio de residencia (42 categorías) se incorpora porque este se controla por factores del contexto territorial no observados, como el desarrollo económico local y la oferta educativa. Su inclusión evita atribuir a las demás variables diferencias que en realidad son regionales, aunque tiene una limitación, pues los municipios con pocas observaciones producen estimaciones menos estables.

El género captura diferencias sistemáticas entre hombres y mujeres que la literatura ha documentado en las pruebas estandarizadas del país, y se incluye como control sociodemográfico.

La edad, calculada a partir de la fecha de nacimiento, es la única variable explicativa continua del modelo. La edad esperada para presentar la prueba al terminar el grado once es de 16 a 17 años, de modo que una edad mayor suele reflejar repitencia, interrupciones en la escolaridad o la validación del bachillerato en la edad adulta. Por eso se espera que la probabilidad de superar el P75 disminuya a medida que aumenta la edad. Se considera que un estudiante está en extraedad cuando tiene 19 años o más.

El percentil global (de 1 a 100) es la medida de desempeño de la que se deriva la variable dependiente. No se incluyó como predictor porque habría introducido fuga de información, y por ello se utilizó únicamente en el análisis descriptivo.

library(knitr)
library(kableExtra)

tabla_variables <- data.frame(

  Variable = c(
    "SUPERA_P75 (Y)",
    "Género",
    "Edad",
    "Municipio de residencia",
    "Estrato socioeconómico",
    "Acceso a internet",
    "Disponibilidad de computador",
    "Ubicación del colegio"
  ),

  Descripción = c(
    "Indica si el estudiante obtuvo un percentil global superior a 75 en las pruebas Saber 11 de 2020.",
    "Género registrado del estudiante.",
    "Edad del estudiante en años cumplidos a 2020, calculada con su fecha de nacimiento.",
    "Municipio donde reside el estudiante.",
    "Estrato socioeconómico de la vivienda del estudiante.",
    "Indica si el hogar dispone de conexión a internet.",
    "Indica si el hogar cuenta con computador.",
    "Identifica si el colegio se encuentra en una zona urbana o rural."
  ),

  `Tipo de variable` = c(
    "Categórica binaria",
    "Categórica nominal",
    "Cuantitativa discreta",
    "Categórica nominal",
    "Categórica ordinal*",
    "Categórica binaria",
    "Categórica binaria",
    "Categórica binaria"
  ),

  `Ejemplos / Notas` = c(
    "0 = No supera P75; 1 = Supera P75.",
    "Femenino, Masculino.",
    "De 14 a 60 años; 19 años o más = extraedad.",
    "Cali, Palmira, Tuluá, Buenaventura, entre otros.",
    "Estratos 1 a 6; Sin estrato.",
    "Sí, No.",
    "Sí, No.",
    "Urbano, Rural."
  ),

  check.names = FALSE
)

tabla_variables %>%
  kbl(
    format = "html",
    caption = "Tabla 1. Variables utilizadas en el modelo de clasificación del desempeño académico",
    align = c("l", "l", "l", "l")
  ) %>%
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width = FALSE,
    position = "center",
    font_size = 13
  ) %>%
  row_spec(0, bold = TRUE, background = "#003189", color = "white") %>%
  row_spec(1, background = "#FFF9DB") %>%
  column_spec(1, bold = TRUE, color = "#003189") %>%
  footnote(
    general = paste(
      "Fuente: Elaboración propia a partir de los resultados",
      "de las pruebas Saber 11 del ICFES, Valle del Cauca (2020).",
      "La variable dependiente SUPERA_P75 toma el valor de 1",
      "cuando el percentil global es superior a 75 y de 0",
      "cuando es igual o inferior a 75.",
      "* Los estratos 1 a 6 tienen un orden natural;",
      "'Sin estrato' es una categoría adicional sin orden."
    ),
    general_title = ""
  )
Tabla 1. Variables utilizadas en el modelo de clasificación del desempeño académico
Variable Descripción Tipo de variable Ejemplos / Notas
SUPERA_P75 (Y) Indica si el estudiante obtuvo un percentil global superior a 75 en las pruebas Saber 11 de 2020. Categórica binaria 0 = No supera P75; 1 = Supera P75.
Género Género registrado del estudiante. Categórica nominal Femenino, Masculino.
Edad Edad del estudiante en años cumplidos a 2020, calculada con su fecha de nacimiento. Cuantitativa discreta De 14 a 60 años; 19 años o más = extraedad.
Municipio de residencia Municipio donde reside el estudiante. Categórica nominal Cali, Palmira, Tuluá, Buenaventura, entre otros.
Estrato socioeconómico Estrato socioeconómico de la vivienda del estudiante. Categórica ordinal* Estratos 1 a 6; Sin estrato.
Acceso a internet Indica si el hogar dispone de conexión a internet. Categórica binaria Sí, No. 
Disponibilidad de computador Indica si el hogar cuenta con computador. Categórica binaria Sí, No. 
Ubicación del colegio Identifica si el colegio se encuentra en una zona urbana o rural. Categórica binaria Urbano, Rural.
Fuente: Elaboración propia a partir de los resultados de las pruebas Saber 11 del ICFES, Valle del Cauca (2020). La variable dependiente SUPERA_P75 toma el valor de 1 cuando el percentil global es superior a 75 y de 0 cuando es igual o inferior a 75. * Los estratos 1 a 6 tienen un orden natural; ‘Sin estrato’ es una categoría adicional sin orden.

2.6 Base de datos

library(readxl)
library(dplyr)
library(tidyr)
library(DT)
library(htmltools)

corregir_tildes <- function(x) {
  x <- as.character(x)
  reparar <- function(s, desde) {
    r <- iconv(s, from = "UTF-8", to = desde)
    if (is.na(r)) return(NA_character_)
    Encoding(r) <- "UTF-8"
    if (validUTF8(r)) r else NA_character_
  }
  valores <- unique(x)
  corregidos <- vapply(valores, function(s) {
    if (is.na(s) || !grepl("Ã", s, fixed = TRUE)) return(s)
    r <- reparar(s, "latin1")
    if (is.na(r)) r <- reparar(s, "CP1252")
    if (is.na(r)) s else r
  }, character(1), USE.NAMES = FALSE)
  corregidos[match(x, valores)]
}

icfes_original <- read_excel("Base Datos M.xlsx", range = "A1:J35444")

icfes_previa <- icfes_original %>%
  select(
    ESTU_GENERO, ESTU_FECHANACIMIENTO, ESTU_MCPIO_RESIDE, FAMI_ESTRATOVIVIENDA,
    FAMI_TIENEINTERNET, FAMI_TIENECOMPUTADOR, COLE_AREA_UBICACION, PERCENTIL_GLOBAL
  ) %>%
  mutate(EDAD = 2020 - as.numeric(substr(ESTU_FECHANACIMIENTO, 1, 4))) %>%
  drop_na() %>%
  filter(
    ESTU_GENERO %in% c("F", "M"),
    FAMI_TIENEINTERNET %in% c("Si", "No"),
    FAMI_TIENECOMPUTADOR %in% c("Si", "No"),
    COLE_AREA_UBICACION %in% c("URBANO", "RURAL"),
    PERCENTIL_GLOBAL >= 1, PERCENTIL_GLOBAL <= 100
  )

icfes_base <- icfes_previa %>%
  filter(EDAD >= 14, EDAD <= 60) %>%
  select(-ESTU_FECHANACIMIENTO)

n_original      <- nrow(icfes_original)
n_edad_invalida <- nrow(icfes_previa) - nrow(icfes_base)

base_interactiva <- icfes_base %>%
  transmute(
    Género = factor(ifelse(ESTU_GENERO == "F", "Femenino", "Masculino")),
    Municipio = factor(gsub(" De ", " de ", tools::toTitleCase(tolower(corregir_tildes(ESTU_MCPIO_RESIDE))))),
    Estrato = factor(
      ifelse(FAMI_ESTRATOVIVIENDA == "Sin Estrato", "Sin estrato", FAMI_ESTRATOVIVIENDA),
      levels = c("Sin estrato", paste("Estrato", 1:6))
    ),
    Internet = factor(ifelse(FAMI_TIENEINTERNET == "Si", "Sí", "No"), levels = c("Sí", "No")),
    Computador = factor(ifelse(FAMI_TIENECOMPUTADOR == "Si", "Sí", "No"), levels = c("Sí", "No")),
    Zona = factor(ifelse(COLE_AREA_UBICACION == "URBANO", "Urbano", "Rural")),
    Edad = EDAD,
    Percentil = PERCENTIL_GLOBAL,
    Clasificación = factor(
      ifelse(PERCENTIL_GLOBAL > 75, "Supera P75", "No supera P75"),
      levels = c("Supera P75", "No supera P75")
    )
  )

n_est      <- nrow(base_interactiva)
n_supera   <- sum(base_interactiva$Clasificación == "Supera P75")
n_mun      <- n_distinct(base_interactiva$Municipio)
edad_media <- mean(base_interactiva$Edad)

tarjeta <- function(valor, etiqueta) {
  tags$div(
    style = paste(
      "flex: 1; min-width: 150px; background: #FFFFFF; border: 1px solid #E8E8E8;",
      "border-top: 4px solid #FFD800; border-radius: 8px; padding: 12px 16px;",
      "text-align: center; box-shadow: 0 2px 6px rgba(0,0,0,0.06);"
    ),
    tags$div(valor, style = "font-size: 1.6rem; font-weight: 700; color: #003189;"),
    tags$div(etiqueta, style = "font-size: 0.85rem; color: #495057;")
  )
}

indicadores <- tags$div(
  style = "display: flex; flex-wrap: wrap; gap: 12px; margin: 10px 0 18px 0;",
  tarjeta(format(n_est, big.mark = "."), "Estudiantes"),
  tarjeta(n_mun, "Municipios"),
  tarjeta(
    paste0(format(n_supera, big.mark = "."), " (",
           formatC(100 * n_supera / n_est, format = "f", digits = 1, decimal.mark = ","), "%)"),
    "Superan el percentil 75"
  ),
  tarjeta(paste(formatC(edad_media, format = "f", digits = 1, decimal.mark = ","), "años"), "Edad promedio")
)

idioma <- list(
  search = "Buscar:",
  lengthMenu = "Mostrar _MENU_ registros",
  info = "Mostrando _START_ a _END_ de _TOTAL_ estudiantes",
  infoEmpty = "Sin registros",
  infoFiltered = "(filtrado de _MAX_ estudiantes)",
  zeroRecords = "No se encontraron resultados",
  paginate = list(first = "Primero", last = "Último", `next` = "Siguiente", previous = "Anterior"),
  buttons = list(copy = "Copiar", colvis = "Columnas"),
  thousands = "."
)

tabla_base <- datatable(
  base_interactiva,
  rownames = FALSE,
  filter = list(position = "top", clear = TRUE),
  extensions = c("Buttons", "Scroller"),
  colnames = c(
    "Género", "Municipio", "Estrato", "Internet", "Computador",
    "Zona del colegio", "Edad (años)", "Percentil", "Clasificación"
  ),
  caption = tags$caption(
    style = paste(
      "caption-side: top; text-align: center; color: #333333;",
      "font-size: 1.15rem; font-weight: bold; padding-bottom: 10px;"
    ),
    "Base de datos de estudiantes — Pruebas Saber 11, Valle del Cauca (2020)"
  ),
  class = "row-border stripe hover compact",
  width = "100%",
  options = list(
    dom = "Bfrtip",
    buttons = list(
      list(extend = "copy", text = "Copiar"),
      list(extend = "csv", text = "CSV", filename = "Saber11_Valle_2020"),
      list(extend = "excel", text = "Excel", filename = "Saber11_Valle_2020"),
      list(extend = "colvis", text = "Columnas")
    ),
    deferRender = TRUE,
    scrollY = 430,
    scrollX = TRUE,
    scroller = TRUE,
    order = list(list(7, "desc")),
    columnDefs = list(list(className = "dt-center", targets = 2:8)),
    language = idioma,
    initComplete = JS(
      "function(settings, json) {",
      "  $(this.api().table().header()).css({",
      "    'background-color': '#003189', 'color': 'white'",
      "  });",
      "}"
    )
  )
) %>%
  formatStyle(
    "Percentil",
    background = styleColorBar(c(0, 100), "#C9D7F0"),
    backgroundSize = "95% 70%",
    backgroundRepeat = "no-repeat",
    backgroundPosition = "center"
  ) %>%
  formatStyle(
    "Edad",
    color = styleInterval(18, c("#333333", "#D80025")),
    fontWeight = styleInterval(18, c("normal", "bold"))
  ) %>%
  formatStyle(
    c("Internet", "Computador"),
    color = styleEqual(c("No", "Sí"), c("#D80025", "#333333")),
    fontWeight = styleEqual(c("No", "Sí"), c("bold", "normal"))
  ) %>%
  formatStyle(
    "Clasificación",
    backgroundColor = styleEqual(c("Supera P75", "No supera P75"), c("#003189", "#EEEEEE")),
    color = styleEqual(c("Supera P75", "No supera P75"), c("white", "#495057")),
    fontWeight = "bold",
    borderRadius = "4px"
  ) %>%
  formatStyle(
    "Clasificación",
    target = "row",
    backgroundColor = styleEqual("Supera P75", "#E7EEF9")
  )

browsable(tagList(
  indicadores,
  tabla_base,
  tags$p(
    style = "font-size: 0.85rem; color: #6C757D; margin-top: 8px;",
    paste(
      "Fuente: Elaboración propia con base en los resultados de las pruebas Saber 11",
      "del ICFES, Valle del Cauca (2020). Edad en años cumplidos a 2020; percentil frente a",
      "la distribución nacional. En rojo, estudiantes en extraedad (19 años o más) y",
      "hogares sin internet o sin computador."
    )
  )
))
33.054
Estudiantes
42
Municipios
7.118 (21,5%)
Superan el percentil 75
17,6 años
Edad promedio

Fuente: Elaboración propia con base en los resultados de las pruebas Saber 11 del ICFES, Valle del Cauca (2020). Edad en años cumplidos a 2020; percentil frente a la distribución nacional. En rojo, estudiantes en extraedad (19 años o más) y hogares sin internet o sin computador.

2.7 Área de estudio

El estudio se desarrolla en el Valle del Cauca, departamento del suroccidente colombiano conformado por 42 municipios, que en 2020 reunió a 33.054 estudiantes con información completa en las pruebas Saber 11. Su territorio combina realidades muy distintas: una franja costera sobre el Pacífico, en la que Buenaventura concentra la mayor extensión del departamento; el valle geográfico del río Cauca, donde se ubican Cali y las ciudades intermedias con mayor dinámica económica; y las zonas de ladera de las cordilleras Occidental y Central, con municipios pequeños y de vocación rural. El Mapa 1 permite explorar cómo se distribuyen los estudiantes y su desempeño en este territorio. Con los botones de la esquina superior derecha se puede cambiar la variable representada, y al pasar el cursor sobre cada municipio se muestran sus indicadores.

library(sf)
library(leaflet)
library(dplyr)
library(htmltools)
library(htmlwidgets)

normalizar <- function(x) {
  x <- chartr("ÁÉÍÓÚÜÑáéíóúüñ", "AEIOUUNaeiouun", as.character(x))
  gsub("[^A-Z]", "", toupper(x))
}

resumen_mapa <- base_interactiva %>%
  group_by(Municipio) %>%
  summarise(
    n = n(),
    tasa = mean(Clasificación == "Supera P75"),
    edad = mean(Edad),
    extraedad = mean(Edad >= 19),
    internet = mean(Internet == "Sí"),
    computador = mean(Computador == "Sí"),
    rural = mean(Zona == "Rural"),
    .groups = "drop"
  ) %>%
  mutate(clave = normalizar(Municipio))

tasa_valle    <- mean(base_interactiva$Clasificación == "Supera P75")
extra_valle   <- mean(base_interactiva$Edad >= 19)

mapa_valle <- readRDS("mapa_valle.rds") %>%
  mutate(
    clave = normalizar(municipio_gadm),
    clave = ifelse(clave == "SANTIAGODECALI", "CALI", clave)
  ) %>%
  left_join(resumen_mapa, by = "clave")

stopifnot(!anyNA(mapa_valle$n))

pct_txt <- function(x) paste0(formatC(100 * x, format = "f", digits = 1, decimal.mark = ","), "%")
num_txt <- function(x) formatC(x, format = "f", digits = 1, decimal.mark = ",")

etiquetas <- lapply(seq_len(nrow(mapa_valle)), function(i) {
  m <- mapa_valle[i, ]
  HTML(paste0(
    "<div style='font-family: Work Sans, sans-serif; font-size: 13px; line-height: 1.5;'>",
    "<b style='font-size: 15px; color: #003189;'>", m$Municipio, "</b><br>",
    "<span style='color: #6C757D;'>Estudiantes:</span> <b>", format(m$n, big.mark = "."), "</b><br>",
    "<span style='color: #6C757D;'>Supera el P75:</span> <b style='color:",
    ifelse(m$tasa > tasa_valle, "#003189", "#D80025"), ";'>", pct_txt(m$tasa), "</b><br>",
    "<span style='color: #6C757D;'>Edad promedio:</span> <b>", num_txt(m$edad), " años</b><br>",
    "<span style='color: #6C757D;'>En extraedad (19+):</span> <b>", pct_txt(m$extraedad), "</b><br>",
    "<hr style='margin: 4px 0; border-color: #FFD800;'>",
    "<span style='color: #6C757D;'>Con internet:</span> ", pct_txt(m$internet), "<br>",
    "<span style='color: #6C757D;'>Con computador:</span> ", pct_txt(m$computador), "<br>",
    "<span style='color: #6C757D;'>Colegio rural:</span> ", pct_txt(m$rural),
    "</div>"
  ))
})

colores_divergentes <- c("#D80025", "#F4A6B3", "#FFFFFF", "#9DB4E3", "#003189")

rango_tasa <- max(abs(mapa_valle$tasa - tasa_valle))
pal_tasa <- colorNumeric(colores_divergentes, domain = tasa_valle + c(-1, 1) * rango_tasa)

pal_extra <- colorBin(
  c("#003189", "#9DB4E3", "#E7EEF9", "#FBE3E7", "#F4A6B3", "#D80025"),
  domain = c(0, 1),
  bins = c(0, 0.07, 0.10, extra_valle, 0.17, 0.22, 1)
)

pal_n <- colorBin(
  c("#E7EEF9", "#9DB4E3", "#4A73C4", "#003189"),
  domain = mapa_valle$n,
  bins = c(0, 250, 1000, 3000, 15000)
)

ciudades <- mapa_valle %>%
  filter(Municipio %in% c("Cali", "Buenaventura", "Palmira", "Tuluá",
                          "Cartago", "Guadalajara de Buga", "Jamundí", "Yumbo")) %>%
  mutate(punto = st_point_on_surface(geometry))
coordenadas <- st_coordinates(ciudades$punto)

capas <- c("Supera el P75 (%)", "Estudiantes en extraedad (%)", "Número de estudiantes")

borde <- list(color = "white", weight = 1.2, opacity = 1, fillOpacity = 0.85,
              highlightOptions = highlightOptions(weight = 3.5, color = "#FFD800",
                                                  fillOpacity = 0.95, bringToFront = TRUE))

agregar_capa <- function(mapa, colores, grupo) {
  addPolygons(
    mapa, fillColor = colores, group = grupo,
    color = borde$color, weight = borde$weight, opacity = borde$opacity,
    fillOpacity = borde$fillOpacity, highlightOptions = borde$highlightOptions,
    label = etiquetas,
    labelOptions = labelOptions(
      direction = "auto", textsize = "13px",
      style = list("border" = "2px solid #003189", "border-radius" = "8px",
                   "box-shadow" = "0 4px 14px rgba(0,49,137,0.25)", "padding" = "8px 10px")
    )
  )
}

mapa_1 <- leaflet(mapa_valle, width = "100%", height = 620,
                  options = leafletOptions(zoomSnap = 0.25, minZoom = 7)) %>%
  addProviderTiles(providers$Esri.WorldGrayCanvas) %>%
  agregar_capa(~pal_tasa(tasa), capas[1]) %>%
  agregar_capa(~pal_extra(extraedad), capas[2]) %>%
  agregar_capa(~pal_n(n), capas[3]) %>%
  addLabelOnlyMarkers(
    lng = coordenadas[, 1], lat = coordenadas[, 2],
    label = ciudades$Municipio,
    labelOptions = labelOptions(
      noHide = TRUE, direction = "center", textOnly = TRUE,
      style = list("font-weight" = "700", "font-size" = "11px", "color" = "#212529",
                   "text-shadow" = "0 0 3px white, 0 0 3px white, 0 0 3px white")
    )
  ) %>%
  addLegend(
    pal = pal_tasa, values = tasa_valle + c(-1, 1) * rango_tasa, group = capas[1],
    title = paste0("Supera el P75<br><span style='font-weight:400;font-size:11px;'>Promedio del Valle: ",
                   pct_txt(tasa_valle), "</span>"),
    labFormat = labelFormat(suffix = "%", transform = function(x) 100 * x, digits = 0),
    position = "bottomright", opacity = 0.9
  ) %>%
  addLegend(
    pal = pal_extra, values = ~extraedad, group = capas[2],
    title = paste0("Extraedad (19 años o más)<br><span style='font-weight:400;font-size:11px;'>Promedio del Valle: ",
                   pct_txt(extra_valle), "</span>"),
    labFormat = labelFormat(suffix = "%", transform = function(x) 100 * x, digits = 0),
    position = "bottomright", opacity = 0.9
  ) %>%
  addLegend(
    pal = pal_n, values = ~n, group = capas[3], title = "Estudiantes",
    labFormat = labelFormat(big.mark = "."), position = "bottomright", opacity = 0.9
  ) %>%
  addLayersControl(baseGroups = capas, options = layersControlOptions(collapsed = FALSE)) %>%
  addScaleBar(position = "bottomleft", options = scaleBarOptions(imperial = FALSE)) %>%
  onRender("
    function(el, x) {
      var map = this;
      var claves = {
        'Supera el P75 (%)': 'Supera el P75',
        'Estudiantes en extraedad (%)': 'Extraedad',
        'Número de estudiantes': 'Estudiantes'
      };
      function mostrar(nombre) {
        el.querySelectorAll('.legend').forEach(function(l) {
          var titulo = l.textContent.trim();
          l.style.display = titulo.indexOf(claves[nombre]) === 0 ? 'block' : 'none';
        });
      }
      mostrar('Supera el P75 (%)');
      map.on('baselayerchange', function(e) { mostrar(e.name); });
    }
  ")

browsable(tagList(
  tags$div(
    style = "text-align:center; color:#003189; font-weight:700; font-size:1.1rem; margin: 1.2rem 0 0.6rem;",
    "Mapa 1. Distribución de los estudiantes y su desempeño por municipio, Valle del Cauca (2020)"
  ),
  tags$div(
    style = "border-radius: 12px; overflow: hidden; box-shadow: 0 8px 30px rgba(0,49,137,0.15); border: 1px solid #DEE2E6;",
    mapa_1
  ),
  tags$p(
    style = "font-size: 0.85rem; color: #6C757D; margin-top: 8px; font-style: italic;",
    paste(
      "Fuente: Elaboración propia con base en los resultados de las pruebas Saber 11 del ICFES (2020)",
      "y los límites municipales de GADM 4.1. En las capas de porcentajes, el blanco corresponde al promedio",
      "del departamento. En la capa del P75, el azul indica municipios por encima del promedio y el rojo, por debajo;",
      "en la de extraedad, el rojo indica una mayor proporción de estudiantes de 19 años o más."
    )
  )
))
Mapa 1. Distribución de los estudiantes y su desempeño por municipio, Valle del Cauca (2020)

Fuente: Elaboración propia con base en los resultados de las pruebas Saber 11 del ICFES (2020) y los límites municipales de GADM 4.1. En las capas de porcentajes, el blanco corresponde al promedio del departamento. En la capa del P75, el azul indica municipios por encima del promedio y el rojo, por debajo; en la de extraedad, el rojo indica una mayor proporción de estudiantes de 19 años o más.

El Mapa 1 muestra que el desempeño no se distribuye al azar en el territorio. Los municipios con mayor proporción de estudiantes que superan el percentil 75 se concentran en el valle geográfico del río Cauca, en particular Palmira (34,1%), Guadalajara de Buga (31,3%) y Tuluá (30,9%). Son ciudades intermedias con una oferta educativa consolidada y mejores condiciones de conectividad. En total, solo 8 de los 42 municipios superan el promedio departamental de 21,5%, lo que indica que el resultado del Valle está muy influido por unos pocos municipios grandes.

El contraste más fuerte aparece en el Pacífico. Buenaventura, el segundo municipio con más estudiantes evaluados (2.888), registra solo un 8,3% de estudiantes por encima del percentil 75, y apenas el 47,9% de sus hogares cuenta con computador, frente al 72,5% en Cali. Además, el 19,9% de sus estudiantes está en extraedad, por encima del 13,4% del departamento, como puede verse en la segunda capa del mapa. Algo similar ocurre en los municipios de ladera del norte y el occidente del departamento, donde los colegios rurales tienen mayor peso. Esta geografía del desempeño anticipa uno de los hallazgos centrales del estudio: las diferencias entre municipios coinciden con diferencias en el acceso a recursos tecnológicos y en la ubicación urbana o rural de los colegios.

2.8 Alcances y limitaciones metodológicas

Se determinó que la base no incluye variables relevantes, lo que probablemente limita la capacidad predictiva del modelo (pseudo R² de 0,074) y puede hacer que los efectos del computador, la zona y el estrato aparezcan mayores de lo que son. Además, el diseño es observacional, por lo que los odds ratios indican asociación y no causalidad. Como la base de prueba está balanceada (50%) y en la población real solo el 21,5% supera el P75, la exactitud y los valores predictivos no pueden extrapolarse directamente a la población. Asimismo, los estratos 4 a 6 y varios municipios tienen pocas observaciones, lo que reduce la precisión de sus estimaciones.

En cuanto al alcance, los datos corresponden a un solo departamento y a un solo año (2020), un año atípico por la pandemia, y solo incluyen a quienes presentaron la prueba, de modo que los resultados no son generalizables sin cautela y, dado su alcance predictivo moderado, sirven para caracterizar grupos y orientar recursos, no para decidir sobre estudiantes individuales.

3 Análisis descriptivo

Antes de construir los modelos de clasificación es necesario conocer la composición de la población estudiada y explorar cómo se relaciona cada característica con la variable dependiente. Este capítulo se organiza en cinco partes: (i) las variables cuantitativas (edad y percentil global), (ii) la variable dependiente, (iii) las características sociodemográficas, socioeconómicas y educativas de los estudiantes, (iv) el municipio de residencia y (v) las relaciones entre variables. El análisis se realiza sobre la base depurada de 33.054 estudiantes del Valle del Cauca que presentaron las pruebas Saber 11 en 2020.

library(readxl)
library(dplyr)
library(tidyr)
library(ggplot2)
library(plotly)
library(knitr)
library(kableExtra)

col_rojo   <- "#003189"
col_gris   <- "#495057"
col_claro  <- "#ADB5BD"
col_acento <- "#FFD800"

colores_clase <- c("No supera P75" = col_claro, "Supera P75" = col_rojo)

fuente <- paste(
  "Fuente: Elaboración propia con base en los resultados de las",
  "pruebas Saber 11 del ICFES, Valle del Cauca (2020)."
)

num <- function(x, d = 1) formatC(x, format = "f", digits = d, decimal.mark = ",", big.mark = ".")
pct <- function(x, d = 1) paste0(num(100 * x, d), "%")
mil <- function(x) format(x, big.mark = ".", decimal.mark = ",", scientific = FALSE, trim = TRUE)

estilo_tabla <- function(tabla, nota = fuente) {
  tabla %>%
    kable_styling(
      bootstrap_options = c("striped", "hover", "condensed"),
      full_width = FALSE, position = "center", font_size = 13
    ) %>%
    row_spec(0, bold = TRUE, background = "#003189", color = "white") %>%
    footnote(general = nota, general_title = "Nota: ", footnote_as_chunk = TRUE)
}

tema_caso <- function() {
  theme_minimal(base_size = 12) +
    theme(
      plot.title = element_text(face = "bold", hjust = 0.5, color = "#003189", size = 14),
      axis.title = element_text(color = "#333333"),
      axis.text = element_text(color = "#495057"),
      panel.grid.minor = element_blank(),
      panel.grid.major = element_line(color = "#EEEEEE"),
      legend.position = "bottom",
      legend.title = element_blank(),
      strip.background = element_rect(fill = "#003189", color = NA),
      strip.text = element_text(face = "bold", color = "white")
    )
}

interactivo <- function(g, alto = 500, tooltip = "text", nota_y = -0.22, b = 120, izq = NULL, leyenda_y = -0.12) {
  p <- ggplotly(g, tooltip = tooltip, height = alto) %>%
    layout(
      legend = list(orientation = "h", x = 0.5, xanchor = "center", y = leyenda_y, title = list(text = "")),
      hoverlabel = list(bgcolor = "white", font = list(color = "#333333"))
    ) %>%
    config(displaylogo = FALSE, modeBarButtonsToRemove = c("lasso2d", "select2d", "autoScale2d"))
  p <- plotly_build(p)
  p$x$layout$margin$b <- b
  p$x$layout$margin$t <- 80
  if (!is.null(izq)) p$x$layout$margin$l <- izq
  p$x$layout$annotations <- c(
    p$x$layout$annotations,
    list(list(
      text = sub(" pruebas", "<br>pruebas", fuente), x = 0, y = nota_y,
      xref = "paper", yref = "paper", showarrow = FALSE, xanchor = "left",
      font = list(size = 10, color = "#6C757D")
    ))
  )
  p
}

arreglar_tildes <- function(x) {
  x <- as.character(x)
  reparar <- function(s, desde) {
    r <- iconv(s, from = "UTF-8", to = desde)
    if (is.na(r)) return(NA_character_)
    Encoding(r) <- "UTF-8"
    if (validUTF8(r)) r else NA_character_
  }
  valores <- unique(x)
  corregidos <- vapply(valores, function(s) {
    if (is.na(s) || !grepl("Ã", s, fixed = TRUE)) return(s)
    r <- reparar(s, "latin1")
    if (is.na(r)) r <- reparar(s, "CP1252")
    if (is.na(r)) s else r
  }, character(1), USE.NAMES = FALSE)
  corregidos[match(x, valores)]
}

orden_estratos <- c("Sin estrato", paste("Estrato", 1:6))

icfes_desc <- icfes_base %>%
  transmute(
    Clase = factor(
      ifelse(PERCENTIL_GLOBAL > 75, "Supera P75", "No supera P75"),
      levels = c("No supera P75", "Supera P75")
    ),
    Supera = as.numeric(PERCENTIL_GLOBAL > 75),
    Género = factor(ifelse(ESTU_GENERO == "F", "Femenino", "Masculino")),
    Municipio = gsub(" De ", " de ", tools::toTitleCase(tolower(arreglar_tildes(ESTU_MCPIO_RESIDE)))),
    Estrato = factor(
      ifelse(FAMI_ESTRATOVIVIENDA == "Sin Estrato", "Sin estrato", FAMI_ESTRATOVIVIENDA),
      levels = orden_estratos
    ),
    Internet = factor(ifelse(FAMI_TIENEINTERNET == "Si", "Sí tiene internet", "No tiene internet"),
                      levels = c("No tiene internet", "Sí tiene internet")),
    Computador = factor(ifelse(FAMI_TIENECOMPUTADOR == "Si", "Sí tiene computador", "No tiene computador"),
                        levels = c("No tiene computador", "Sí tiene computador")),
    Zona = factor(ifelse(COLE_AREA_UBICACION == "URBANO", "Urbano", "Rural"),
                  levels = c("Rural", "Urbano")),
    Edad = EDAD,
    Percentil = PERCENTIL_GLOBAL
  )

n_total   <- nrow(icfes_desc)
tasa_gral <- mean(icfes_desc$Supera)

vars_cat <- c(
  "Género" = "Género",
  "Estrato" = "Estrato socioeconómico",
  "Internet" = "Acceso a internet",
  "Computador" = "Disponibilidad de computador",
  "Zona" = "Ubicación del colegio"
)

3.1 Variables cuantitativas: edad y percentil

La base incluye dos variables cuantitativas. La edad es la única variable explicativa continua del estudio y se mide en años cumplidos a 2020. El percentil global, que ubica a cada estudiante frente a la distribución nacional (de 1 a 100), es la medida de desempeño de la que se deriva la variable dependiente; aunque los modelos utilizan su versión binaria, conviene describir primero su distribución.

asimetria <- function(x) mean((x - mean(x))^3) / sd(x)^3

resumen_cuant <- function(x, nombre) {
  x <- x[!is.na(x)]
  data.frame(
    Variable = nombre,
    n = mil(length(x)),
    Media = num(mean(x)),
    `Desv. est.` = num(sd(x)),
    `CV (%)` = num(100 * sd(x) / mean(x)),
    Mínimo = num(min(x), 0),
    Q1 = num(quantile(x, 0.25), 0),
    Mediana = num(median(x), 0),
    Q3 = num(quantile(x, 0.75), 0),
    Máximo = num(max(x), 0),
    Asimetría = num(asimetria(x), 2),
    check.names = FALSE,
    row.names = NULL
  )
}

bind_rows(
  resumen_cuant(icfes_desc$Edad, "Edad (años)"),
  resumen_cuant(icfes_desc$Percentil, "Percentil global")
) %>%
  kbl(
    format = "html",
    caption = "Tabla 2. Estadísticas descriptivas de las variables cuantitativas",
    align = c("l", rep("c", 10))
  ) %>%
  estilo_tabla(nota = paste(
    fuente, "CV = coeficiente de variación. Q1 y Q3 = primer y tercer cuartil.",
    "Asimetría > 0 indica una cola más larga hacia los valores altos."
  )) %>%
  column_spec(1, bold = TRUE)
Tabla 2. Estadísticas descriptivas de las variables cuantitativas
Variable n Media Desv. est. CV (%) Mínimo Q1 Mediana Q3 Máximo Asimetría
Edad (años) 33.054 17,6 2,7 15,2 14 17 17 18 60 8,25
Percentil global 33.054 48,9 27,7 56,7 1 25 49 72 100 0,04
Nota: Fuente: Elaboración propia con base en los resultados de las pruebas Saber 11 del ICFES, Valle del Cauca (2020). CV = coeficiente de variación. Q1 y Q3 = primer y tercer cuartil. Asimetría > 0 indica una cola más larga hacia los valores altos.
media_edad   <- mean(icfes_desc$Edad)
mediana_edad <- median(icfes_desc$Edad)

datos_fig1 <- icfes_desc %>%
  mutate(Grupo = pmin(Edad, 25)) %>%
  group_by(Grupo) %>%
  mutate(Total = n(), Tasa = mean(Supera)) %>%
  ungroup() %>%
  count(Grupo, Clase, Total, Tasa, name = "n") %>%
  mutate(
    Etiqueta = ifelse(Grupo == 25, "25 o más", as.character(Grupo)),
    texto = paste0(
      "<b>Edad: ", Etiqueta, " años</b>",
      "<br>", Clase, ": ", mil(n), " estudiantes",
      "<br>Total de la edad: ", mil(Total),
      "<br>Supera el P75: ", pct(Tasa)
    )
  )

g1 <- ggplot(datos_fig1, aes(x = Grupo, y = n, fill = Clase, text = texto)) +
  geom_col(width = 0.85, color = "white", linewidth = 0.2) +
  geom_vline(xintercept = mediana_edad, color = col_acento, linetype = "dashed", linewidth = 0.8) +
  annotate("text", x = mediana_edad + 1.6, y = max(datos_fig1$Total) * 1.04, size = 3.5,
           color = "#495057", label = paste0("Mediana: ", num(mediana_edad, 0), " años")) +
  scale_fill_manual(values = colores_clase) +
  scale_x_continuous(breaks = 14:25, labels = c(14:24, "25+")) +
  scale_y_continuous(labels = function(x) mil(x)) +
  labs(
    title = "Figura 1. Distribución de la edad según la clasificación P75",
    x = "Edad (años cumplidos a 2020)", y = "Número de estudiantes"
  ) +
  tema_caso()

interactivo(g1, leyenda_y = -0.2, nota_y = -0.44, b = 190)
datos_fig2 <- icfes_desc %>%
  mutate(Intervalo = pmin(floor((Percentil - 1) / 5) * 5 + 1, 96)) %>%
  count(Intervalo, Clase, name = "n") %>%
  mutate(
    Porcentaje = n / n_total,
    texto = paste0(
      "<b>Percentil ", Intervalo, " – ", Intervalo + 4, "</b>",
      "<br>", mil(n), " estudiantes (", pct(Porcentaje), ")"
    )
  )

g2 <- ggplot(datos_fig2, aes(x = Intervalo + 2, y = Porcentaje, fill = Clase, text = texto)) +
  geom_col(width = 4.7, color = "white", linewidth = 0.2) +
  geom_hline(yintercept = 0.05, linetype = "dashed", color = "#333333", linewidth = 0.7) +
  geom_vline(xintercept = 75.5, color = col_rojo, linewidth = 0.9) +
  annotate("text", x = 68, y = max(datos_fig2$Porcentaje) * 1.12, size = 3.5,
           color = col_rojo, label = "Umbral P75") +
  annotate("text", x = 22, y = 0.0545, size = 3.3,
           label = "Distribución nacional esperada (5% por intervalo)") +
  scale_fill_manual(values = colores_clase) +
  scale_x_continuous(breaks = seq(0, 100, 10)) +
  scale_y_continuous(labels = function(x) pct(x, 0)) +
  labs(
    title = "Figura 2. Distribución del percentil global nacional",
    x = "Percentil global", y = "Porcentaje de estudiantes"
  ) +
  tema_caso()

interactivo(g2, leyenda_y = -0.2, nota_y = -0.44, b = 190)

La edad promedio de los estudiantes es de 17,6 años, con una mediana de 17 y una desviación estándar de 2,7 (Tabla 2). El 63,4% tiene 16 o 17 años, la edad esperada al terminar el grado once, pero la distribución presenta una asimetría positiva muy marcada: hay una cola larga de estudiantes mayores, y el 13,4% está en extraedad (19 años o más). La Figura 1 muestra además que la proporción de estudiantes que supera el percentil 75 cae a medida que aumenta la edad: pasa de 31,5% entre los estudiantes de 16 años a 8,8% entre los de 19 y apenas 1,8% entre los de 22 años o más.

La Figura 2 compara la distribución del percentil del Valle con la que se esperaría si su población tuviera el mismo desempeño que el promedio nacional: en ese caso, cada intervalo de cinco percentiles reuniría cerca del 5% de los estudiantes. Hasta el percentil 75 la distribución del departamento se mantiene muy cerca de esa referencia (el 25,3% de los estudiantes se ubica en el cuartil más bajo del país, prácticamente el 25% esperado). La diferencia se concentra en la parte alta: los intervalos superiores al percentil 75 quedan sistemáticamente por debajo del 5%, y en el último (percentiles 96 a 100) solo se ubica el 3,3% de los estudiantes. Como resultado, el 21,5% del Valle supera el percentil 75, frente al 25% esperado. Esto indica que el departamento tiene una menor proporción de estudiantes de alto desempeño que el promedio nacional.

3.2 Variable dependiente: supera el percentil 75

La variable dependiente toma el valor 1 cuando el estudiante obtiene un percentil global superior a 75, es decir, cuando su desempeño lo ubica en el 25% más alto del país, y 0 en caso contrario.

tabla_3 <- icfes_desc %>%
  count(Clase, name = "n") %>%
  mutate(
    Porcentaje = n / sum(n),
    Referencia = c(0.75, 0.25)
  )

tabla_3 %>%
  bind_rows(data.frame(Clase = "Total", n = sum(tabla_3$n), Porcentaje = 1, Referencia = 1)) %>%
  mutate(
    Diferencia = ifelse(Clase == "Total", "—", paste0(ifelse(Porcentaje > Referencia, "+", ""), num(100 * (Porcentaje - Referencia)), " p.p.")),
    n = mil(n), Porcentaje = pct(Porcentaje), Referencia = pct(Referencia, 0)
  ) %>%
  kbl(
    format = "html",
    caption = "Tabla 3. Distribución de la variable dependiente",
    col.names = c("Clasificación", "Frecuencia (n)", "Valle del Cauca (%)", "Referencia nacional (%)", "Diferencia"),
    align = c("l", "c", "c", "c", "c")
  ) %>%
  estilo_tabla(nota = paste(
    fuente, "Por definición del percentil, a nivel nacional el 25% de los estudiantes",
    "supera el percentil 75. p.p. = puntos porcentuales."
  )) %>%
  row_spec(3, bold = TRUE, background = "#FFF9DB")
Tabla 3. Distribución de la variable dependiente
Clasificación Frecuencia (n) Valle del Cauca (%) Referencia nacional (%) Diferencia
No supera P75 25.936 78,5% 75% +3,5 p.p.
Supera P75 7.118 21,5% 25% -3,5 p.p.
Total 33.054 100,0% 100% —
Nota: Fuente: Elaboración propia con base en los resultados de las pruebas Saber 11 del ICFES, Valle del Cauca (2020). Por definición del percentil, a nivel nacional el 25% de los estudiantes supera el percentil 75. p.p. = puntos porcentuales.
datos_fig3 <- tabla_3 %>%
  select(Clase, `Valle del Cauca` = Porcentaje, `Referencia nacional` = Referencia) %>%
  pivot_longer(-Clase, names_to = "Población", values_to = "Porcentaje") %>%
  left_join(tabla_3 %>% select(Clase, n), by = "Clase") %>%
  mutate(
    Población = factor(Población, levels = c("Valle del Cauca", "Referencia nacional")),
    texto = paste0(
      "<b>", Clase, "</b><br>", Población, ": ", pct(Porcentaje),
      ifelse(Población == "Valle del Cauca", paste0("<br>Estudiantes: ", mil(n)), "")
    )
  )

g3 <- ggplot(datos_fig3, aes(x = Clase, y = Porcentaje, fill = Población, text = texto)) +
  geom_col(position = position_dodge(width = 0.75), width = 0.7) +
  geom_text(aes(label = pct(Porcentaje)), position = position_dodge(width = 0.75),
            vjust = -0.5, size = 4) +
  scale_fill_manual(values = c("Valle del Cauca" = col_rojo, "Referencia nacional" = col_claro)) +
  scale_y_continuous(labels = function(x) pct(x, 0), limits = c(0, 0.88)) +
  labs(
    title = "Figura 3. Estudiantes que superan el percentil 75: Valle vs. referencia nacional",
    x = "", y = "Porcentaje de estudiantes"
  ) +
  tema_caso()

interactivo(g3, alto = 460, leyenda_y = -0.12, nota_y = -0.26, b = 140)

Del total de 33.054 estudiantes, 7.118 (21,5%) superan el percentil 75 nacional, mientras que 25.936 (78,5%) no lo superan (Tabla 3 y Figura 3). La proporción de estudiantes de alto desempeño en el Valle es 3,5 puntos porcentuales inferior a la referencia nacional. Además, la variable presenta un desbalance de clases (aproximadamente 4 a 1), aspecto que se debe tratar en la etapa de modelación para evitar que los modelos favorezcan sistemáticamente la clase mayoritaria.

3.3 Características de los estudiantes

La Tabla 4 resume la distribución de las variables explicativas categóricas. Además de la frecuencia de cada categoría, se presentan dos indicadores del desempeño: el porcentaje de estudiantes de la categoría que supera el percentil 75 y la edad promedio. Esto permite identificar desde ahora qué características se asocian con un mejor desempeño.

resumen_categorias <- lapply(names(vars_cat), function(v) {
  icfes_desc %>%
    group_by(Categoría = .data[[v]]) %>%
    summarise(
      n = n(),
      Tasa = mean(Supera),
      Edad = mean(Edad),
      .groups = "drop"
    ) %>%
    mutate(
      Variable = vars_cat[[v]],
      Porcentaje = n / sum(n),
      Categoría = as.character(Categoría)
    )
}) %>%
  bind_rows()

resumen_categorias %>%
  transmute(
    Categoría,
    `Frecuencia (n)` = mil(n),
    `Porcentaje (%)` = pct(Porcentaje),
    `Supera P75 (%)` = cell_spec(
      pct(Tasa),
      color = ifelse(Tasa > tasa_gral, col_rojo, col_gris),
      bold = Tasa > tasa_gral
    ),
    `Edad promedio` = num(Edad)
  ) %>%
  kbl(
    format = "html", escape = FALSE,
    caption = "Tabla 4. Características de los estudiantes y desempeño por categoría",
    align = c("l", "c", "c", "c", "c")
  ) %>%
  estilo_tabla(nota = paste(
    fuente, paste0("Supera P75 (%) = porcentaje de estudiantes de la categoría que supera el percentil 75; ",
                   "en rojo, las categorías por encima del promedio general (", pct(tasa_gral), ").")
  )) %>%
pack_rows(index = table(factor(resumen_categorias$Variable, levels = unname(vars_cat)))) %>%
  column_spec(1, bold = TRUE)
Tabla 4. Características de los estudiantes y desempeño por categoría
Categoría Frecuencia (n) Porcentaje (%) Supera P75 (%) Edad promedio
Género
Femenino 18.816 56,9% 18,9% 17,7
Masculino 14.238 43,1% 25,0% 17,6
Estrato socioeconómico
Sin estrato 671 2,0% 7,7% 18,1
Estrato 1 9.887 29,9% 15,2% 17,8
Estrato 2 13.976 42,3% 23,1% 17,6
Estrato 3 6.967 21,1% 28,0% 17,5
Estrato 4 1.083 3,3% 26,0% 17,7
Estrato 5 344 1,0% 25,9% 17,9
Estrato 6 126 0,4% 13,5% 18,1
Acceso a internet
No tiene internet 6.569 19,9% 10,1% 18,2
Sí tiene internet 26.485 80,1% 24,4% 17,5
Disponibilidad de computador
No tiene computador 11.229 34,0% 11,9% 18,0
Sí tiene computador 21.825 66,0% 26,5% 17,4
Ubicación del colegio
Rural 4.780 14,5% 12,0% 18,0
Urbano 28.274 85,5% 23,2% 17,6
Nota: Fuente: Elaboración propia con base en los resultados de las pruebas Saber 11 del ICFES, Valle del Cauca (2020). Supera P75 (%) = porcentaje de estudiantes de la categoría que supera el percentil 75; en rojo, las categorías por encima del promedio general (21,5%).
datos_fig4 <- resumen_categorias %>%
  mutate(
    Variable = factor(Variable, levels = unname(vars_cat)),
    texto = paste0("<b>", Categoría, "</b><br>", mil(n), " estudiantes (", pct(Porcentaje), ")")
  ) %>%
  group_by(Variable) %>%
  mutate(Categoría = factor(Categoría, levels = unique(Categoría))) %>%
  ungroup()

g4 <- ggplot(datos_fig4, aes(x = Porcentaje, y = Categoría, text = texto)) +
  geom_col(fill = col_gris, width = 0.7) +
  geom_text(aes(label = pct(Porcentaje)), hjust = -0.15, size = 3.2) +
  facet_wrap(~Variable, scales = "free_y", ncol = 2) +
  scale_x_continuous(labels = function(x) pct(x, 0), limits = c(0, 1.05),
                     breaks = seq(0, 1, 0.25)) +
  labs(
    title = "Figura 4. Composición de la población según sus características",
    x = "Porcentaje de estudiantes", y = ""
  ) +
  tema_caso()

interactivo(g4, alto = 720, nota_y = -0.10, b = 90, izq = 160)

La población está compuesta mayoritariamente por mujeres (56,9%), por estudiantes de estratos 1 y 2 (72,2% en conjunto) y por estudiantes de colegios urbanos (85,5%). En cuanto a los recursos tecnológicos, el 80,1% de los hogares cuenta con internet y el 66,0% con computador; es decir, uno de cada tres estudiantes no disponía de computador en un año en el que buena parte de la educación se trasladó a la virtualidad. Los estratos 4, 5 y 6, así como la categoría “Sin estrato”, tienen una participación muy baja, por lo que sus resultados deben interpretarse con cautela.

datos_fig5 <- datos_fig4 %>%
  mutate(
    Posición = ifelse(Tasa > tasa_gral, "Por encima del promedio", "Por debajo del promedio"),
    texto = paste0(
      "<b>", Categoría, "</b><br>Supera P75: ", pct(Tasa),
      "<br>Diferencia con el promedio: ", ifelse(Tasa > tasa_gral, "+", ""),
      num(100 * (Tasa - tasa_gral)), " p.p.",
      "<br>Edad promedio: ", num(Edad), " años",
      "<br>Estudiantes: ", mil(n)
    )
  )

g5 <- ggplot(datos_fig5, aes(x = Tasa, y = Categoría, fill = Posición, text = texto)) +
  geom_col(width = 0.7) +
  geom_vline(xintercept = tasa_gral, linetype = "dashed", color = "#333333") +
  geom_text(aes(label = pct(Tasa)), hjust = -0.15, size = 3.2) +
  facet_wrap(~Variable, scales = "free_y", ncol = 2) +
  scale_fill_manual(values = c("Por encima del promedio" = col_rojo, "Por debajo del promedio" = col_claro)) +
  scale_x_continuous(labels = function(x) pct(x, 0), limits = c(0, 0.36)) +
  labs(
    title = paste0("Figura 5. Porcentaje que supera el P75 por categoría (promedio: ", pct(tasa_gral), ")"),
    x = "Porcentaje que supera el percentil 75", y = ""
  ) +
  tema_caso()

interactivo(g5, alto = 760, nota_y = -0.12, b = 110, izq = 160)

La Figura 5 es central para el problema de clasificación, ya que muestra que todas las características analizadas se relacionan con la probabilidad de superar el percentil 75. Las brechas más marcadas corresponden a los recursos tecnológicos: el 26,5% de los estudiantes con computador supera el P75, frente a solo el 11,9% de quienes no lo tienen, y en el caso del internet la diferencia es de 24,4% frente a 10,1%. La ubicación del colegio muestra un patrón similar, pues los estudiantes de colegios urbanos superan el P75 en una proporción (23,2%) que casi duplica la de los colegios rurales (12,0%). En el estrato socioeconómico se observa un gradiente creciente entre el estrato 1 (15,2%) y el estrato 3 (28,0%), que luego se estabiliza en los estratos 4 y 5; el estrato 6 (13,5%) rompe este patrón, aunque reúne solo 126 estudiantes, por lo que su estimación es poco precisa, y los estudiantes sin estrato asignado presentan el menor desempeño (7,7%). Por último, los hombres superan el P75 con mayor frecuencia (25,0%) que las mujeres (18,9%), en línea con la brecha de género documentada en las pruebas estandarizadas del país.

Para verificar si estas diferencias son estadísticamente significativas se aplicó la prueba chi-cuadrado de independencia entre cada característica y la variable dependiente, y se midió la intensidad de la asociación con la V de Cramér, cuyo valor va de 0 (sin asociación) a 1 (asociación perfecta).

v_cramer <- function(x, y) {
  tab <- table(x, y)
  chi <- suppressWarnings(chisq.test(tab, correct = FALSE))
  list(
    chi2 = unname(chi$statistic),
    gl = unname(chi$parameter),
    p = chi$p.value,
    V = sqrt(unname(chi$statistic) / (sum(tab) * (min(dim(tab)) - 1)))
  )
}

fuerza_v <- function(v) {
  case_when(v < 0.1 ~ "Muy débil", v < 0.2 ~ "Débil", v < 0.3 ~ "Moderada", TRUE ~ "Fuerte")
}

asociacion <- lapply(c(names(vars_cat), "Municipio"), function(v) {
  r <- v_cramer(icfes_desc[[v]], icfes_desc$Clase)
  data.frame(
    Variable = ifelse(v == "Municipio", "Municipio de residencia", vars_cat[v]),
    chi2 = r$chi2, gl = r$gl, p = r$p, V = r$V
  )
}) %>%
  bind_rows() %>%
  arrange(desc(V))
asociacion %>%
  transmute(
    Variable,
    `Estadístico χ²` = num(chi2),
    `Grados de libertad` = gl,
    `Valor p` = ifelse(p < 0.001, "< 0,001", num(p, 3)),
    `V de Cramér` = num(V, 3),
    Asociación = fuerza_v(V)
  ) %>%
  kbl(
    format = "html",
    caption = "Tabla 5. Asociación entre las características y la variable dependiente",
    align = c("l", "c", "c", "c", "c", "c")
  ) %>%
  estilo_tabla(nota = paste(
    fuente, "Prueba chi-cuadrado de independencia (H0: la característica y la clasificación P75 son independientes).",
    "Criterio de intensidad de la V de Cramér: < 0,1 muy débil; 0,1 – 0,2 débil; 0,2 – 0,3 moderada; > 0,3 fuerte."
  )) %>%
  column_spec(1, bold = TRUE)
Tabla 5. Asociación entre las características y la variable dependiente
Variable Estadístico χ² Grados de libertad Valor p V de Cramér Asociación
Disponibilidad de computador 938,1 1 < 0,001 0,168 Débil
Municipio de residencia 897,8 41 < 0,001 0,165 Débil
Acceso a internet 633,5 1 < 0,001 0,138 Débil
Estrato socioeconómico 525,8 6 < 0,001 0,126 Débil
Ubicación del colegio 302,8 1 < 0,001 0,096 Muy débil
Género 181,8 1 < 0,001 0,074 Muy débil
Nota: Fuente: Elaboración propia con base en los resultados de las pruebas Saber 11 del ICFES, Valle del Cauca (2020). Prueba chi-cuadrado de independencia (H0: la característica y la clasificación P75 son independientes). Criterio de intensidad de la V de Cramér: < 0,1 muy débil; 0,1 – 0,2 débil; 0,2 – 0,3 moderada; > 0,3 fuerte.

En todos los casos se rechaza la hipótesis de independencia (p < 0,001), lo que confirma que las seis características se relacionan significativamente con el desempeño. Sin embargo, la V de Cramér muestra que la intensidad de cada asociación por separado es débil o menor: la variable más asociada es disponibilidad de computador (V = 0,168). Con una muestra tan grande, incluso diferencias pequeñas resultan estadísticamente significativas. Por esa razón, anticipa que ninguna variable por sí sola permite clasificar con precisión a los estudiantes y que los modelos tendrán que combinar la información de todas ellas. En el caso de la edad, que es continua, se aplicó la prueba de Mann-Whitney: la edad de quienes superan el P75 (promedio de 17,0 años) es significativamente menor que la de quienes no lo superan (promedio de 17,8 años; p < 0,001).

3.4 Municipio de residencia

resumen_municipios <- icfes_desc %>%
  group_by(Municipio) %>%
  summarise(
    n = n(),
    Tasa = mean(Supera),
    Edad = mean(Edad),
    .groups = "drop"
  ) %>%
  mutate(Porcentaje = n / sum(n)) %>%
  arrange(desc(n))

top_10 <- resumen_municipios %>% slice_head(n = 10)
otros  <- resumen_municipios %>% slice(-(1:10))
fila_otros <- icfes_desc %>%
  filter(Municipio %in% otros$Municipio) %>%
  summarise(
    Municipio = paste0("Otros (", nrow(otros), " municipios)"),
    n = n(), Tasa = mean(Supera), Edad = mean(Edad)
  ) %>%
  mutate(Porcentaje = n / n_total)

bind_rows(top_10, fila_otros) %>%
  transmute(
    Municipio,
    `Frecuencia (n)` = mil(n),
    `Porcentaje (%)` = pct(Porcentaje),
    `Supera P75 (%)` = cell_spec(pct(Tasa), color = ifelse(Tasa > tasa_gral, col_rojo, col_gris), bold = Tasa > tasa_gral),
    `Edad promedio` = num(Edad)
  ) %>%
  kbl(
    format = "html", escape = FALSE,
    caption = "Tabla 6. Distribución de estudiantes y desempeño según municipio de residencia",
    align = c("l", "c", "c", "c", "c")
  ) %>%
  estilo_tabla(nota = paste(
    fuente, "Se presentan los diez municipios con más estudiantes; los restantes se agrupan en Otros.",
    paste0("En rojo, los municipios por encima del promedio general (", pct(tasa_gral), ").")
  )) %>%
  column_spec(1, bold = TRUE) %>%
  row_spec(11, italic = TRUE, background = "#FFF9DB")
Tabla 6. Distribución de estudiantes y desempeño según municipio de residencia
Municipio Frecuencia (n) Porcentaje (%) Supera P75 (%) Edad promedio
Cali 13.770 41,7% 22,8% 17,5
Buenaventura 2.888 8,7% 8,3% 17,6
Palmira 2.409 7,3% 34,1% 18,0
Tuluá 2.100 6,4% 30,9% 17,3
Cartago 1.232 3,7% 22,0% 18,0
Jamundí 1.030 3,1% 22,3% 17,3
Guadalajara de Buga 942 2,8% 31,3% 17,8
Yumbo 930 2,8% 16,3% 17,8
Candelaria 814 2,5% 17,1% 17,5
Florida 559 1,7% 21,1% 17,3
Otros (32 municipios) 6.380 19,3% 16,6% 17,8
Nota: Fuente: Elaboración propia con base en los resultados de las pruebas Saber 11 del ICFES, Valle del Cauca (2020). Se presentan los diez municipios con más estudiantes; los restantes se agrupan en Otros. En rojo, los municipios por encima del promedio general (21,5%).
datos_fig6 <- resumen_municipios %>%
  mutate(
    Municipio = reorder(Municipio, Tasa),
    Posición = ifelse(Tasa > tasa_gral, "Por encima del promedio", "Por debajo del promedio"),
    texto = paste0(
      "<b>", Municipio, "</b><br>Supera P75: ", pct(Tasa),
      "<br>Edad promedio: ", num(Edad), " años",
      "<br>Estudiantes: ", mil(n), " (", pct(Porcentaje), " del total)"
    )
  )

g6 <- ggplot(datos_fig6, aes(x = Tasa, y = Municipio, text = texto)) +
  geom_segment(aes(x = tasa_gral, xend = Tasa, y = Municipio, yend = Municipio, color = Posición),
               linewidth = 0.8) +
  geom_point(aes(size = n, color = Posición), alpha = 0.9) +
  geom_vline(xintercept = tasa_gral, linetype = "dashed", color = "#333333") +
  scale_color_manual(values = c("Por encima del promedio" = col_rojo, "Por debajo del promedio" = col_gris)) +
  scale_size_continuous(range = c(2, 9), guide = "none") +
  scale_x_continuous(labels = function(x) pct(x, 0)) +
  labs(
    title = "Figura 6. Porcentaje que supera el P75 por municipio de residencia",
    x = "Porcentaje que supera el percentil 75 (tamaño del punto = número de estudiantes)", y = ""
  ) +
  tema_caso() +
  theme(axis.text.y = element_text(size = 9))

interactivo(g6, alto = 900, nota_y = -0.08, b = 100)

La población estudiantil está muy concentrada en Cali, que reúne el 41,7% de los estudiantes, seguida de Buenaventura, Palmira y Tuluá (Tabla 6). La Figura 6 revela una marcada heterogeneidad territorial: Palmira (34,1%), Guadalajara de Buga y Tuluá superan con amplitud el promedio departamental. En contraste, Buenaventura, el segundo municipio con más estudiantes, presenta uno de los porcentajes más bajos (8,3%), un reflejo de las brechas sociales y educativas que afectan históricamente al Pacífico colombiano. Los municipios pequeños del norte del departamento, con pocos estudiantes, también se ubican por debajo del promedio, aunque sus estimaciones son menos precisas por el reducido tamaño de muestra.

3.5 Relaciones entre variables

Las características analizadas no actúan de forma aislada. Por ejemplo, el acceso a la tecnología está relacionado con el estrato socioeconómico. Por ello, a continuación se examina su comportamiento conjunto.

datos_fig7 <- icfes_desc %>%
  mutate(
    Recursos = case_when(
      Internet == "Sí tiene internet" & Computador == "Sí tiene computador" ~ "Internet y computador",
      Internet == "Sí tiene internet" ~ "Solo internet",
      Computador == "Sí tiene computador" ~ "Solo computador",
      TRUE ~ "Sin internet ni computador"
    ),
    Recursos = factor(Recursos, levels = c(
      "Sin internet ni computador", "Solo internet", "Solo computador", "Internet y computador"
    ))
  ) %>%
  group_by(Estrato, Recursos) %>%
  summarise(n = n(), Tasa = mean(Supera), .groups = "drop") %>%
  mutate(
    Etiqueta = ifelse(n >= 30, pct(Tasa, 0), ""),
    texto = paste0(
      "<b>", Estrato, " · ", Recursos, "</b>",
      "<br>Supera P75: ", pct(Tasa),
      "<br>Estudiantes: ", mil(n),
      ifelse(n < 30, "<br><i>Menos de 30 estudiantes: estimación poco precisa</i>", "")
    )
  )

g7 <- ggplot(datos_fig7, aes(x = Estrato, y = Recursos, fill = Tasa, text = texto)) +
  geom_tile(color = "white", linewidth = 1) +
  geom_text(aes(label = Etiqueta, color = Tasa > 0.3), size = 3.8) +
  scale_fill_gradientn(
    colours = c("#FFFFFF", "#C9D7F0", "#7FA0D9", "#2F5BB7", col_rojo),
    limits = c(0, 0.5), labels = function(x) pct(x, 0), name = "Supera P75"
  ) +
  scale_color_manual(values = c("TRUE" = "white", "FALSE" = "#333333"), guide = "none") +
  labs(
    title = "Figura 7. Porcentaje que supera el P75 según estrato y recursos tecnológicos",
    x = "Estrato socioeconómico", y = ""
  ) +
  tema_caso() +
  theme(panel.grid = element_blank(), legend.position = "right")

interactivo(g7, alto = 470, nota_y = -0.25)

La Figura 7 muestra que el efecto de los recursos tecnológicos se mantiene dentro de cada estrato. En prácticamente todos los niveles socioeconómicos, los estudiantes con internet y computador superan el P75 en una proporción considerablemente mayor que quienes carecen de ambos. En el conjunto de la población, la tasa pasa de 8,7% entre los estudiantes sin ninguno de los dos recursos a 27,5% entre quienes cuentan con ambos, es decir, se multiplica por 3,2. Este hallazgo sugiere que el acceso a la tecnología aporta información propia, adicional a la del estrato, lo que justifica incluir ambas variables en los modelos.

agrupar_edad <- function(e) pmin(pmax(e, 15), 22)

datos_fig8 <- bind_rows(
  icfes_desc %>% mutate(Serie = paste("Colegio", tolower(as.character(Zona)))),
  icfes_desc %>% mutate(Serie = "Total del Valle")
) %>%
  mutate(Grupo = agrupar_edad(Edad)) %>%
  group_by(Serie, Grupo) %>%
  summarise(n = n(), Tasa = mean(Supera), .groups = "drop") %>%
  filter(n >= 30) %>%
  mutate(
    Etiqueta = case_when(Grupo == 15 ~ "15 o menos", Grupo == 22 ~ "22 o más", TRUE ~ as.character(Grupo)),
    texto = paste0(
      "<b>", Serie, "</b><br>Edad: ", Etiqueta, " años",
      "<br>Supera el P75: ", pct(Tasa),
      "<br>Estudiantes: ", mil(n)
    )
  )

g8 <- ggplot(datos_fig8, aes(x = Grupo, y = Tasa, color = Serie, group = Serie, text = texto)) +
  geom_hline(yintercept = tasa_gral, linetype = "dashed", color = "#6C757D") +
  geom_line(aes(linewidth = Serie == "Total del Valle")) +
  geom_point(size = 2.6) +
  scale_color_manual(values = c(
    "Total del Valle" = col_rojo, "Colegio urbano" = "#7FA0D9", "Colegio rural" = "#D80025"
  )) +
  scale_linewidth_manual(values = c("TRUE" = 1.6, "FALSE" = 0.9), guide = "none") +
  scale_x_continuous(breaks = 15:22, labels = c("≤15", 16:21, "22+")) +
  scale_y_continuous(labels = function(x) pct(x, 0)) +
  labs(
    title = "Figura 8. Porcentaje que supera el P75 según la edad y la ubicación del colegio",
    x = "Edad (años cumplidos a 2020)", y = "Porcentaje que supera el P75"
  ) +
  tema_caso()

interactivo(g8, alto = 540, leyenda_y = -0.2, nota_y = -0.42, b = 190)

La Figura 8 examina la relación entre la edad y el desempeño según la ubicación del colegio. La caída de la proporción de estudiantes que supera el P75 con la edad se presenta tanto en los colegios urbanos como en los rurales, pero en estos últimos los porcentajes son más bajos en prácticamente todas las edades. Esto sugiere que la edad y la zona aportan información distinta: el rezago escolar, que la edad refleja, afecta al desempeño en ambos contextos, y se suma a las desventajas propias de la educación rural. La caída más pronunciada se produce entre los 17 y los 18 años, es decir, justo cuando el estudiante deja de tener la edad esperada para terminar el grado once.

vars_matriz <- c(
  "Supera P75" = "Clase", "Género" = "Género", "Municipio" = "Municipio",
  "Estrato" = "Estrato", "Internet" = "Internet",
  "Computador" = "Computador", "Ubicación colegio" = "Zona"
)

matriz_v <- expand.grid(Var1 = names(vars_matriz), Var2 = names(vars_matriz), stringsAsFactors = FALSE) %>%
  rowwise() %>%
  mutate(
    r = list(if (Var1 == Var2) list(V = 1, p = NA) else v_cramer(icfes_desc[[vars_matriz[[Var1]]]], icfes_desc[[vars_matriz[[Var2]]]])),
    V = r$V, p = r$p
  ) %>%
  ungroup() %>%
  select(-r) %>%
  mutate(
    Var1 = factor(Var1, levels = names(vars_matriz)),
    Var2 = factor(Var2, levels = rev(names(vars_matriz))),
    Etiqueta = num(V, 2),
    texto = ifelse(
      as.character(Var1) == as.character(Var2), paste0("<b>", Var1, "</b>"),
      paste0(
        "<b>", Var1, " vs. ", Var2, "</b><br>V de Cramér: ", num(V, 3),
        "<br>Valor p: ", ifelse(p < 0.001, "< 0,001", num(p, 3)),
        "<br>Asociación: ", tolower(fuerza_v(V))
      )
    )
  )

g9 <- ggplot(matriz_v, aes(x = Var1, y = Var2, fill = V, text = texto)) +
  geom_tile(color = "white", linewidth = 1) +
  geom_text(aes(label = Etiqueta, color = V > 0.5), size = 3.8) +
  scale_fill_gradientn(
    colours = c("#FFFFFF", "#C9D7F0", "#7FA0D9", "#2F5BB7", col_rojo),
    limits = c(0, 1), name = "V de Cramér"
  ) +
  scale_color_manual(values = c("TRUE" = "white", "FALSE" = "#333333"), guide = "none") +
  labs(title = "Figura 9. Matriz de asociación entre variables (V de Cramér)", x = "", y = "") +
  tema_caso() +
  theme(
    panel.grid = element_blank(),
    legend.position = "right",
    axis.text.x = element_text(angle = 35, hjust = 1)
  )

interactivo(g9, alto = 560, nota_y = -0.28)

Por último, la Figura 9 presenta la matriz de asociación entre todas las variables, incluida la variable dependiente (primera fila y columna). Como las variables son categóricas, se utiliza la V de Cramér en lugar del coeficiente de correlación de Pearson. Las asociaciones más fuertes se dan entre las propias variables explicativas: internet y computador (V = 0,43), lo cual es esperable porque ambos recursos suelen estar presentes en los mismos hogares, y municipio y ubicación del colegio (V = 0,46). Ninguna asociación es tan alta como para generar un problema grave de multicolinealidad, de modo que todas las variables pueden incluirse en los modelos. Sin embargo, la relación entre internet y computador deberá tenerse en cuenta al interpretar sus coeficientes por separado en el modelo Logit.

En síntesis, el análisis descriptivo muestra que el desempeño de los estudiantes del Valle del Cauca está asociado de manera significativa con el acceso a recursos tecnológicos, la edad, la zona del colegio, el estrato, el género y el municipio de residencia. Sin embargo, cada una de estas asociaciones por separado es débil, y los grupos se superponen ampliamente. Estos resultados justifican el uso de modelos de clasificación multivariados y anticipan que su capacidad predictiva será moderada.

4 Modelos de clasificación

La clasificación es una técnica de aprendizaje supervisado cuyo objetivo es asignar cada observación a una categoría predefinida a partir de un conjunto de características conocidas. En este trabajo se estiman dos modelos para clasificar a los estudiantes del Valle del Cauca según si superan o no el percentil 75 nacional en las pruebas Saber 11 de 2020: un modelo paramétrico, la regresión logística (Logit), y un modelo no paramétrico basado en distancias, los K vecinos más cercanos (KNN). Ambos modelos se entrenan con las mismas variables, la misma partición de datos y el mismo esquema de validación cruzada, de modo que sus resultados sean directamente comparables.

4.1 Preparación de los datos para el modelado

La base depurada presenta un desbalance marcado entre clases: aproximadamente uno de cada cinco estudiantes supera el percentil 75. Si se entrenara con esta distribución, los modelos tenderían a favorecer la clase mayoritaria (“No supera”) y obtendrían una exactitud aparentemente alta sin identificar realmente a los estudiantes de alto desempeño. Por esta razón se aplicó un submuestreo aleatorio (undersampling) de la clase mayoritaria con la función downSample() del paquete caret, igualando el número de observaciones de ambas clases.

Posteriormente, la base balanceada se dividió de forma estratificada en un conjunto de entrenamiento (70%), empleado para ajustar los modelos, y un conjunto de prueba (30%), reservado exclusivamente para evaluar su desempeño fuera de muestra. Las variables explicativas categóricas se transformaron en variables dummy (0/1) con dummyVars(), tomando como categorías de referencia: género femenino, municipio de Cali, estrato 1, sin internet, sin computador y colegio rural. La edad se incluyó como variable numérica, en años. Todas las operaciones aleatorias se realizaron con una semilla fija (set.seed(123)) para garantizar la reproducibilidad de los resultados.

library(dplyr)
library(tidyr)
library(caret)
library(pROC)
library(plotly)
library(knitr)
library(kableExtra)

col_logit  <- "#003189"
col_knn    <- "#D80025"
col_claro  <- "#ADB5BD"
col_acento <- "#FFD800"

fuente <- paste(
  "Fuente: Elaboración propia con base en los resultados de las",
  "pruebas Saber 11 del ICFES, Valle del Cauca (2020)."
)

num <- function(x, d = 3) formatC(x, format = "f", digits = d, decimal.mark = ",")
pct <- function(x, d = 1) paste0(num(100 * x, d), "%")
mil <- function(x) format(x, big.mark = ".", decimal.mark = ",", scientific = FALSE, trim = TRUE)

estilo_tabla <- function(tabla, nota = fuente) {
  tabla %>%
    kable_styling(
      bootstrap_options = c("striped", "hover", "condensed"),
      full_width = FALSE,
      position = "center",
      font_size = 13
    ) %>%
    row_spec(0, bold = TRUE, background = "#003189", color = "white") %>%
    footnote(general = nota, general_title = "Nota: ", footnote_as_chunk = TRUE)
}

tema_plotly <- function(p, titulo, x_tit = "", y_tit = "",
                        extra = list(), nota_y = -0.36, b = 160) {
  p %>%
    layout(
      title = list(
        text = paste0("<b>", titulo, "</b>"),
        x = 0.5,
        font = list(size = 17, color = "#003189")
      ),
      xaxis = list(title = x_tit, gridcolor = "#EEEEEE", zeroline = FALSE),
      yaxis = list(title = y_tit, gridcolor = "#EEEEEE", zeroline = FALSE),
      plot_bgcolor = "white",
      paper_bgcolor = "white",
      font = list(family = "Arial", color = "#333333"),
      legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.15),
      hoverlabel = list(bgcolor = "white", font = list(color = "#333333")),
      margin = list(l = 80, r = 40, t = 90, b = b),
      annotations = c(
        extra,
        list(list(
          text = sub(" pruebas", "<br>pruebas", fuente), x = 0, y = nota_y, xref = "paper", yref = "paper",
          showarrow = FALSE, xanchor = "left",
          font = list(size = 10, color = "#6C757D")
        ))
      )
    ) %>%
    config(
      displaylogo = FALSE,
      modeBarButtonsToRemove = c("lasso2d", "select2d", "autoScale2d")
    )
}

arreglar_tildes <- function(x) {
  x <- as.character(x)
  reparar <- function(s, desde) {
    r <- iconv(s, from = "UTF-8", to = desde)
    if (is.na(r)) return(NA_character_)
    Encoding(r) <- "UTF-8"
    if (validUTF8(r)) r else NA_character_
  }
  valores <- unique(x)
  corregidos <- vapply(valores, function(s) {
    if (is.na(s) || !grepl("Ã", s, fixed = TRUE)) return(s)
    r <- reparar(s, "latin1")
    if (is.na(r)) r <- reparar(s, "CP1252")
    if (is.na(r)) s else r
  }, character(1), USE.NAMES = FALSE)
  corregidos[match(x, valores)]
}

icfes_modelo <- icfes_base %>%
  mutate(
    ESTU_GENERO = factor(ESTU_GENERO),
    ESTU_MCPIO_RESIDE = relevel(factor(arreglar_tildes(ESTU_MCPIO_RESIDE)), ref = "CALI"),
    FAMI_ESTRATOVIVIENDA = relevel(
      factor(FAMI_ESTRATOVIVIENDA, levels = c("Sin Estrato", paste("Estrato", 1:6))),
      ref = "Estrato 1"
    ),
    FAMI_TIENEINTERNET = factor(FAMI_TIENEINTERNET, levels = c("No", "Si")),
    FAMI_TIENECOMPUTADOR = factor(FAMI_TIENECOMPUTADOR, levels = c("No", "Si")),
    COLE_AREA_UBICACION = factor(COLE_AREA_UBICACION, levels = c("RURAL", "URBANO")),
    SUPERA = factor(
      ifelse(PERCENTIL_GLOBAL > 75, "Supera", "No_supera"),
      levels = c("No_supera", "Supera")
    )
  ) %>%
  select(
    ESTU_GENERO, ESTU_MCPIO_RESIDE, FAMI_ESTRATOVIVIENDA,
    FAMI_TIENEINTERNET, FAMI_TIENECOMPUTADOR, COLE_AREA_UBICACION,
    EDAD, SUPERA
  )

prevalencia_real <- mean(icfes_modelo$SUPERA == "Supera")

set.seed(123)

icfes_balanceada <- downSample(
  x = icfes_modelo %>% select(-SUPERA),
  y = icfes_modelo$SUPERA,
  yname = "SUPERA"
)

set.seed(123)

indice_train <- createDataPartition(icfes_balanceada$SUPERA, p = 0.7, list = FALSE)

train <- icfes_balanceada[indice_train, ]
test  <- icfes_balanceada[-indice_train, ]

dummies <- dummyVars(
  ~ .,
  data = icfes_balanceada %>% select(-SUPERA),
  fullRank = TRUE
)

x_train <- predict(dummies, newdata = train)
x_test  <- predict(dummies, newdata = test)

y_train <- train$SUPERA
y_test  <- test$SUPERA

set.seed(123)
pliegues <- createFolds(y_train, k = 10, returnTrain = TRUE)

resumen_supera <- function(data, lev = NULL, model = NULL) {
  roc_cv <- pROC::roc(
    data$obs, data$Supera,
    levels = c("No_supera", "Supera"), direction = "<", quiet = TRUE
  )
  c(
    ROC  = as.numeric(pROC::auc(roc_cv)),
    Sens = caret::sensitivity(data$pred, data$obs, positive = "Supera"),
    Spec = caret::specificity(data$pred, data$obs, negative = "No_supera")
  )
}

control <- trainControl(
  method = "cv",
  number = 10,
  index = pliegues,
  classProbs = TRUE,
  summaryFunction = resumen_supera,
  savePredictions = "final"
)

resumen_base <- function(y, nombre) {
  data.frame(
    Base = nombre,
    No_supera = sum(y == "No_supera"),
    Supera = sum(y == "Supera"),
    Total = length(y),
    Prop = mean(y == "Supera")
  )
}

conformacion <- bind_rows(
  resumen_base(icfes_modelo$SUPERA, "Base depurada (original)"),
  resumen_base(icfes_balanceada$SUPERA, "Base balanceada"),
  resumen_base(y_train, "Entrenamiento (70%)"),
  resumen_base(y_test, "Prueba (30%)")
)

conformacion %>%
  mutate(
    across(c(No_supera, Supera, Total), mil),
    Prop = pct(Prop)
  ) %>%
  kbl(
    format = "html",
    caption = "Tabla 7. Conformación de las bases utilizadas en el modelado",
    col.names = c("Base", "No supera P75 (n)", "Supera P75 (n)", "Total (n)", "Supera P75 (%)"),
    align = c("l", "c", "c", "c", "c")
  ) %>%
  estilo_tabla(
    nota = paste(
      fuente,
      "La base balanceada se obtuvo mediante submuestreo aleatorio de la clase",
      "mayoritaria. Entrenamiento y prueba conservan la proporción 50/50."
    )
  ) %>%
  row_spec(1, italic = TRUE, color = "#6C757D")
Tabla 7. Conformación de las bases utilizadas en el modelado
Base No supera P75 (n) Supera P75 (n) Total (n) Supera P75 (%)
Base depurada (original) 25.936 7.118 33.054 21,5%
Base balanceada 7.118 7.118 14.236 50,0%
Entrenamiento (70%) 4.983 4.983 9.966 50,0%
Prueba (30%) 2.135 2.135 4.270 50,0%
Nota: Fuente: Elaboración propia con base en los resultados de las pruebas Saber 11 del ICFES, Valle del Cauca (2020). La base balanceada se obtuvo mediante submuestreo aleatorio de la clase mayoritaria. Entrenamiento y prueba conservan la proporción 50/50.
datos_fig10 <- conformacion %>%
  select(Base, No_supera, Supera) %>%
  pivot_longer(-Base, names_to = "Clase", values_to = "n") %>%
  group_by(Base) %>%
  mutate(Porcentaje = n / sum(n)) %>%
  ungroup() %>%
  mutate(
    Clase = ifelse(Clase == "Supera", "Supera P75", "No supera P75"),
    Base = factor(Base, levels = conformacion$Base)
  )

figura_10 <- plot_ly(height = 480)

for (clase in c("No supera P75", "Supera P75")) {
  d <- datos_fig10 %>% filter(Clase == clase)
  color <- if (clase == "Supera P75") col_logit else col_claro

  figura_10 <- figura_10 %>%
    add_bars(
      data = d, x = ~Base, y = ~n, name = clase,
      marker = list(color = color),
      text = ~mil(n), textposition = "outside",
      customdata = ~pct(Porcentaje),
      hovertemplate = paste0(
        "<b>%{x}</b><br>", clase,
        "<br>Estudiantes: %{y:,}<br>Porcentaje: %{customdata}<extra></extra>"
      ),
      visible = TRUE
    )
}

for (clase in c("No supera P75", "Supera P75")) {
  d <- datos_fig10 %>% filter(Clase == clase)
  color <- if (clase == "Supera P75") col_logit else col_claro

  figura_10 <- figura_10 %>%
    add_bars(
      data = d, x = ~Base, y = ~Porcentaje * 100, name = clase,
      marker = list(color = color),
      text = ~pct(Porcentaje), textposition = "inside",
      customdata = ~mil(n),
      hovertemplate = paste0(
        "<b>%{x}</b><br>", clase,
        "<br>Porcentaje: %{y:.1f}%<br>Estudiantes: %{customdata}<extra></extra>"
      ),
      visible = FALSE, showlegend = FALSE
    )
}

figura_10 <- figura_10 %>%
  tema_plotly(
    "Figura 10. Distribución de clases antes y después del balanceo",
    y_tit = "Número de estudiantes", b = 170, nota_y = -0.42
  ) %>%
  layout(
    barmode = "group",
    updatemenus = list(list(
      type = "buttons", direction = "right", showactive = TRUE,
      x = 0.5, xanchor = "center", y = -0.24, yanchor = "top",
      buttons = list(
        list(
          label = "Número de estudiantes", method = "update",
          args = list(
            list(visible = c(TRUE, TRUE, FALSE, FALSE), showlegend = c(TRUE, TRUE, FALSE, FALSE)),
            list(barmode = "group", yaxis = list(title = "Número de estudiantes", gridcolor = "#EEEEEE", ticksuffix = ""))
          )
        ),
        list(
          label = "Composición porcentual", method = "update",
          args = list(
            list(visible = c(FALSE, FALSE, TRUE, TRUE), showlegend = c(FALSE, FALSE, TRUE, TRUE)),
            list(barmode = "stack", yaxis = list(title = "Porcentaje (%)", range = c(0, 100), gridcolor = "#EEEEEE", ticksuffix = "%"))
          )
        )
      )
    ))
  )

figura_10

El balanceo modifica el punto de partida del ejercicio. En la base original solo el 21,5% de los estudiantes supera el percentil 75, de manera que un clasificador que asignara a todos a la clase “No supera” acertaría en cerca del 78% de los casos sin haber aprendido nada sobre el problema. Al igualar ambas clases en 7.118 observaciones, la exactitud de referencia baja al 50%, y cualquier ganancia por encima de ese valor puede atribuirse a la información que aportan las variables. La decisión tiene un costo: quedan por fuera 18.818 registros de la clase mayoritaria, y las probabilidades que estiman los modelos quedan referidas a una población en la que la mitad de los estudiantes es de alto desempeño, algo que no corresponde a la realidad del departamento. Por esa razón, cuando más adelante se interpretan probabilidades (sección 4.2.3), estas se ajustan a la prevalencia observada. La partición dejó 9.966 registros para entrenar y 4.270 para evaluar, ambos con proporción 50/50. Tras la codificación, cada estudiante queda descrito por 52 variables (51 dummies y la edad), 41 de ellas asociadas al municipio de residencia.

4.2 Modelo Logit

La regresión logística modela la probabilidad de que un estudiante supere el percentil 75 como una función logística de las variables explicativas:

\[ P(Y = 1 \mid \mathbf{X}) = \frac{1}{1 + e^{-z}}, \qquad z = \beta_0 + \beta_1\,\text{Masculino} + \sum_{m} \gamma_m\,\text{Municipio}_m + \sum_{e} \delta_e\,\text{Estrato}_e + \beta_2\,\text{Internet} + \beta_3\,\text{Computador} + \beta_4\,\text{Urbano} + \beta_5\,\text{Edad} \]

donde \(Y = 1\) indica que el estudiante supera el percentil 75. Cada coeficiente representa el cambio en el logaritmo de los odds (razón de probabilidades) frente a la categoría de referencia (o, en el caso de la edad, por cada año adicional), manteniendo constantes las demás variables; su exponencial, \(e^{\beta}\), es el odds ratio (OR): valores mayores que 1 indican que la característica aumenta la probabilidad de superar el P75 y valores menores que 1 que la reduce. El modelo se estimó por máxima verosimilitud con la función train() de caret (método glm, familia binomial), evaluando su capacidad de discriminación mediante validación cruzada de 10 pliegues.

set.seed(123)

modelo_logit <- train(
  x = x_train,
  y = y_train,
  method = "glm",
  family = "binomial",
  trControl = control,
  metric = "ROC"
)
ajuste_logit <- modelo_logit$finalModel
coefs <- summary(ajuste_logit)$coefficients

etiquetar <- function(termino) {
  t <- gsub("`", "", termino)
  case_when(
    t == "(Intercept)" ~ "Intercepto",
    t == "ESTU_GENERO.M" ~ "Género: Masculino",
    startsWith(t, "ESTU_MCPIO_RESIDE.") ~ paste0(
      "Municipio: ",
      gsub(" De ", " de ", tools::toTitleCase(tolower(sub("ESTU_MCPIO_RESIDE.", "", t, fixed = TRUE))))
    ),
    startsWith(t, "FAMI_ESTRATOVIVIENDA.") ~ sub("Sin Estrato", "Sin estrato", sub("FAMI_ESTRATOVIVIENDA.", "", t, fixed = TRUE)),
    t == "FAMI_TIENEINTERNET.Si" ~ "Tiene internet",
    t == "FAMI_TIENECOMPUTADOR.Si" ~ "Tiene computador",
    t == "COLE_AREA_UBICACION.URBANO" ~ "Colegio urbano",
    t == "EDAD" ~ "Edad (por año adicional)",
    TRUE ~ t
  )
}

agrupar <- function(termino) {
  t <- gsub("`", "", termino)
  case_when(
    t == "(Intercept)" ~ "0 Intercepto",
    startsWith(t, "ESTU_MCPIO_RESIDE.") ~ "3 Municipio",
    startsWith(t, "FAMI_ESTRATOVIVIENDA.") ~ "2 Estrato",
    TRUE ~ "1 Individuales, socioeconómicas y educativas"
  )
}

tabla_coef <- data.frame(
  termino = rownames(coefs),
  beta = coefs[, 1],
  ee = coefs[, 2],
  z = coefs[, 3],
  p = coefs[, 4],
  row.names = NULL
) %>%
  mutate(
    Variable = etiquetar(termino),
    Grupo = agrupar(termino),
    OR = exp(beta),
    OR_inf = exp(beta - 1.96 * ee),
    OR_sup = exp(beta + 1.96 * ee),
    Signif = case_when(
      p < 0.001 ~ "***", p < 0.01 ~ "**", p < 0.05 ~ "*", p < 0.1 ~ ".", TRUE ~ ""
    )
  ) %>%
  arrange(Grupo, desc(abs(z)))

or_de <- function(patron) tabla_coef$OR[grepl(patron, tabla_coef$termino, fixed = TRUE)][1]
p_de  <- function(patron) tabla_coef$p[grepl(patron, tabla_coef$termino, fixed = TRUE)][1]

n_mun_sig <- sum(tabla_coef$Grupo == "3 Municipio" & tabla_coef$p < 0.05)
n_mun     <- sum(tabla_coef$Grupo == "3 Municipio")

mun_sig <- tabla_coef %>% filter(Grupo == "3 Municipio", p < 0.05)
nombres_mun <- function(d) {
  x <- sub("Municipio: ", "", d$Variable)
  if (length(x) == 0) return("ningún municipio")
  if (length(x) == 1) return(x)
  paste(paste(head(x, -1), collapse = ", "), "y", tail(x, 1))
}
mun_pos <- nombres_mun(mun_sig %>% filter(OR > 1) %>% arrange(desc(z)) %>% head(3))
mun_neg <- nombres_mun(mun_sig %>% filter(OR < 1) %>% arrange(z) %>% head(1))

lr_chi   <- ajuste_logit$null.deviance - ajuste_logit$deviance
lr_gl    <- ajuste_logit$df.null - ajuste_logit$df.residual
lr_p     <- pchisq(lr_chi, lr_gl, lower.tail = FALSE)
r2_mcf   <- 1 - ajuste_logit$deviance / ajuste_logit$null.deviance
auc_cv_logit <- modelo_logit$results$ROC

filas_sig <- which(tabla_coef$p < 0.05)

tabla_coef %>%
  transmute(
    Variable,
    Coeficiente = num(beta, 3),
    `Error est.` = num(ee, 3),
    z = num(z, 2),
    `Valor p` = ifelse(p < 0.001, "&lt; 0,001", num(p, 3)),
    Signif = gsub("*", "&#42;", Signif, fixed = TRUE),
    OR = num(OR, 3),
    `IC 95% OR` = paste0("[", num(OR_inf, 2), " ; ", num(OR_sup, 2), "]")
  ) %>%
  kbl(
    format = "html",
    caption = "Tabla 8. Resultados del modelo de regresión logística (Logit)",
    align = c("l", "r", "r", "r", "r", "c", "r", "c"),
    escape = FALSE
  ) %>%
  estilo_tabla(
    nota = paste(
      fuente,
      "Categorías de referencia: género femenino, municipio de Cali, estrato 1,",
      "sin internet, sin computador y colegio rural. OR = odds ratio (e^β).",
      "Significancia: *** p<0,001; ** p<0,01; * p<0,05; . p<0,1.",
      "Las filas sombreadas son significativas al 5%."
    )
  ) %>%
  row_spec(filas_sig, background = "#E7EEF9") %>%
  pack_rows(index = table(sub("^[0-9] ", "", tabla_coef$Grupo))[
    unique(sub("^[0-9] ", "", tabla_coef$Grupo))
  ]) %>%
  column_spec(1, bold = TRUE) %>%
  scroll_box(height = "520px") %>%
  as.character() %>%
  htmltools::HTML()
Tabla 8. Resultados del modelo de regresión logística (Logit)
Variable Coeficiente Error est. z Valor p Signif OR IC 95% OR
Intercepto
Intercepto 8,351 0,463 18,02 < 0,001 *** 4235,127 [1707,71 ; 10503,14]
Individuales, socioeconómicas y educativas
Edad (por año adicional) -0,576 0,026 -21,91 < 0,001 *** 0,562 [0,53 ; 0,59]
Tiene computador 0,569 0,055 10,39 < 0,001 *** 1,766 [1,59 ; 1,97]
Género: Masculino 0,417 0,044 9,39 < 0,001 *** 1,517 [1,39 ; 1,66]
Colegio urbano 0,590 0,079 7,48 < 0,001 *** 1,805 [1,55 ; 2,11]
Tiene internet 0,326 0,072 4,54 < 0,001 *** 1,386 [1,20 ; 1,60]
Estrato
Estrato 3 0,304 0,065 4,65 < 0,001 *** 1,355 [1,19 ; 1,54]
Estrato 2 0,184 0,056 3,30 < 0,001 *** 1,203 [1,08 ; 1,34]
Sin estrato -0,656 0,204 -3,21 0,001 ** 0,519 [0,35 ; 0,77]
Estrato 4 0,270 0,125 2,16 0,031 * 1,310 [1,02 ; 1,68]
Estrato 5 0,449 0,217 2,06 0,039 * 1,566 [1,02 ; 2,40]
Estrato 6 0,063 0,379 0,17 0,868 1,065 [0,51 ; 2,24]
Municipio
Municipio: Buenaventura -0,912 0,100 -9,12 < 0,001 *** 0,402 [0,33 ; 0,49]
Municipio: Palmira 0,703 0,084 8,37 < 0,001 *** 2,020 [1,71 ; 2,38]
Municipio: Tuluá 0,358 0,086 4,16 < 0,001 *** 1,431 [1,21 ; 1,69]
Municipio: Yumbo -0,432 0,136 -3,17 0,002 ** 0,649 [0,50 ; 0,85]
Municipio: Guadalajara de Buga 0,366 0,126 2,90 0,004 ** 1,442 [1,13 ; 1,85]
Municipio: La Victoria -1,132 0,397 -2,85 0,004 ** 0,322 [0,15 ; 0,70]
Municipio: Versalles -1,296 0,518 -2,50 0,012 * 0,274 [0,10 ; 0,76]
Municipio: Zarzal -0,414 0,206 -2,01 0,045 * 0,661 [0,44 ; 0,99]
Municipio: Vijes -0,982 0,508 -1,93 0,053 . 0,375 [0,14 ; 1,01]
Municipio: El Cairo -0,961 0,500 -1,92 0,054 . 0,382 [0,14 ; 1,02]
Municipio: Toro -0,744 0,400 -1,86 0,063 . 0,475 [0,22 ; 1,04]
Municipio: Restrepo -0,585 0,338 -1,73 0,083 . 0,557 [0,29 ; 1,08]
Municipio: Jamundí 0,208 0,133 1,56 0,118 1,231 [0,95 ; 1,60]
Municipio: Yotoco -0,613 0,412 -1,49 0,137 0,542 [0,24 ; 1,22]
Municipio: Pradera 0,283 0,210 1,35 0,178 1,327 [0,88 ; 2,01]
Municipio: Ginebra 0,409 0,324 1,26 0,207 1,505 [0,80 ; 2,84]
Municipio: Bolívar 0,411 0,352 1,17 0,243 1,509 [0,76 ; 3,01]
Municipio: Alcalá -0,500 0,447 -1,12 0,263 0,606 [0,25 ; 1,46]
Municipio: Ulloa -0,785 0,710 -1,11 0,268 0,456 [0,11 ; 1,83]
Municipio: Obando -0,718 0,702 -1,02 0,307 0,488 [0,12 ; 1,93]
Municipio: El Dovio 0,455 0,477 0,95 0,341 1,576 [0,62 ; 4,02]
Municipio: San Pedro -0,317 0,377 -0,84 0,401 0,729 [0,35 ; 1,53]
Municipio: Bugalagrande -0,204 0,304 -0,67 0,502 0,815 [0,45 ; 1,48]
Municipio: Cartago 0,066 0,114 0,58 0,563 1,068 [0,85 ; 1,33]
Municipio: Candelaria 0,086 0,163 0,53 0,598 1,090 [0,79 ; 1,50]
Municipio: Florida 0,082 0,166 0,49 0,622 1,085 [0,78 ; 1,50]
Municipio: Calima -0,130 0,264 -0,49 0,622 0,878 [0,52 ; 1,47]
Municipio: El Águila -0,181 0,422 -0,43 0,668 0,834 [0,37 ; 1,91]
Municipio: Sevilla 0,095 0,222 0,43 0,669 1,100 [0,71 ; 1,70]
Municipio: Trujillo -0,084 0,332 -0,25 0,800 0,919 [0,48 ; 1,76]
Municipio: Andalucía 0,068 0,308 0,22 0,824 1,071 [0,59 ; 1,96]
Municipio: Riofrío -0,069 0,362 -0,19 0,848 0,933 [0,46 ; 1,90]
Municipio: Roldanillo 0,032 0,216 0,15 0,880 1,033 [0,68 ; 1,58]
Municipio: La Unión -0,031 0,221 -0,14 0,887 0,969 [0,63 ; 1,50]
Municipio: Ansermanuevo -0,049 0,353 -0,14 0,890 0,952 [0,48 ; 1,90]
Municipio: Caicedonia -0,029 0,236 -0,12 0,902 0,971 [0,61 ; 1,54]
Municipio: Dagua 0,028 0,228 0,12 0,903 1,028 [0,66 ; 1,61]
Municipio: Argelia -12,677 123,577 -0,10 0,918 0,000 [0,00 ; 4845060851490589591840402642400284224846866228026684026820486622624666046060202028026684000688600484,00]
Municipio: Guacarí -0,016 0,221 -0,07 0,943 0,984 [0,64 ; 1,52]
Municipio: El Cerrito -0,009 0,169 -0,05 0,958 0,991 [0,71 ; 1,38]
Municipio: La Cumbre 0,013 0,323 0,04 0,967 1,013 [0,54 ; 1,91]
Nota: Fuente: Elaboración propia con base en los resultados de las pruebas Saber 11 del ICFES, Valle del Cauca (2020). Categorías de referencia: género femenino, municipio de Cali, estrato 1, sin internet, sin computador y colegio rural. OR = odds ratio (e^β). Significancia: *** p<0,001; ** p<0,01; * p<0,05; . p<0,1. Las filas sombreadas son significativas al 5%.
categorias_efecto <- c(
  "Aumenta la probabilidad (p < 0,05)",
  "Reduce la probabilidad (p < 0,05)",
  "No significativo"
)
colores_efecto <- c(col_logit, col_knn, "#ADB5BD")

datos_or <- tabla_coef %>%
  filter(Grupo != "0 Intercepto") %>%
  mutate(
    Efecto = case_when(
      p >= 0.05 ~ categorias_efecto[3],
      OR > 1 ~ categorias_efecto[1],
      TRUE ~ categorias_efecto[2]
    ),
    Hover = paste0(
      "<b>", Variable, "</b>",
      "<br>OR: ", num(OR, 3),
      "<br>IC 95%: [", num(OR_inf, 3), " ; ", num(OR_sup, 3), "]",
      "<br>Valor p: ", ifelse(p < 0.001, "< 0,001", num(p, 3)),
      "<br>", ifelse(OR > 1, "Aumenta", "Reduce"),
      " los odds de superar el P75 en ", pct(abs(OR - 1), 1)
    )
  )

grupos_or <- list(
  socio = datos_or %>% filter(Grupo != "3 Municipio") %>% arrange(OR),
  mun   = datos_or %>% filter(Grupo == "3 Municipio") %>% arrange(OR) %>%
    mutate(Variable = sub("Municipio: ", "", Variable))
)

figura_11 <- plot_ly(height = 940)
grupo_traza <- c()

for (g in names(grupos_or)) {
  for (k in seq_along(categorias_efecto)) {
    d <- grupos_or[[g]] %>% filter(Efecto == categorias_efecto[k])
    if (nrow(d) == 0) next
    figura_11 <- figura_11 %>%
      add_markers(
        data = d, x = ~OR, y = ~Variable,
        error_x = list(
          type = "data", symmetric = FALSE,
          array = d$OR_sup - d$OR, arrayminus = d$OR - d$OR_inf,
          color = colores_efecto[k], thickness = 2.2, width = 5
        ),
        marker = list(
          size = if (g == "socio") 13 else 9,
          color = colores_efecto[k],
          line = list(color = "white", width = 1.5)
        ),
        name = categorias_efecto[k],
        legendgroup = categorias_efecto[k],
        text = ~Hover,
        hovertemplate = "%{text}<extra></extra>",
        visible = (g == "socio"),
        showlegend = TRUE
      )
    grupo_traza <- c(grupo_traza, g)
  }
  if (g == "socio") {
    d <- grupos_or[[g]]
    figura_11 <- figura_11 %>%
      add_text(
        data = d, x = ~OR_sup * 1.06, y = ~Variable,
        text = ~num(OR, 2), textposition = "middle right",
        textfont = list(size = 12, color = "#495057"),
        hoverinfo = "skip", showlegend = FALSE, visible = TRUE
      )
    grupo_traza <- c(grupo_traza, g)
  }
}

eje_y <- function(g) {
  list(
    title = "", categoryorder = "array",
    categoryarray = grupos_or[[g]]$Variable,
    tickmode = "array", tickvals = grupos_or[[g]]$Variable,
    tickfont = list(size = if (g == "mun") 10 else 13, color = "#212529"),
    gridcolor = "#F1F3F5", automargin = TRUE
  )
}

eje_x <- function(g) {
  rango <- range(c(grupos_or[[g]]$OR_inf, grupos_or[[g]]$OR_sup * if (g == "socio") 1.35 else 1))
  list(
    type = "log", title = "Odds ratio (escala logarítmica)",
    range = log10(c(max(rango[1] * 0.9, 0.03), min(rango[2] * 1.1, 8))),
    tickvals = c(0.05, 0.1, 0.2, 0.3, 0.5, 0.7, 1, 1.5, 2, 3, 5),
    ticktext = c("0,05", "0,1", "0,2", "0,3", "0,5", "0,7", "1", "1,5", "2", "3", "5"),
    gridcolor = "#EEEEEE", zeroline = FALSE
  )
}

figura_11 <- figura_11 %>%
  tema_plotly(
    "Figura 11. Odds ratios del modelo Logit (IC 95%)",
    x_tit = "Odds ratio (escala logarítmica)", b = 200, nota_y = -0.25
  ) %>%
  layout(
    xaxis = eje_x("socio"),
    yaxis = eje_y("socio"),
    margin = list(l = 200, r = 30, t = 150),
    legend = list(orientation = "h", x = 0.5, xanchor = "center", y = 1.03, yanchor = "bottom",
                  font = list(size = 12)),
    shapes = list(
      list(type = "line", x0 = 1, x1 = 1, y0 = 0, y1 = 1, yref = "paper",
           line = list(color = "#212529", dash = "dash", width = 1.5)),
      list(type = "rect", x0 = 0.001, x1 = 1, y0 = 0, y1 = 1, yref = "paper",
           fillcolor = "rgba(216,0,37,0.04)", line = list(width = 0), layer = "below"),
      list(type = "rect", x0 = 1, x1 = 100, y0 = 0, y1 = 1, yref = "paper",
           fillcolor = "rgba(0,49,137,0.04)", line = list(width = 0), layer = "below")
    ),
    updatemenus = list(list(
      type = "buttons", direction = "right", showactive = TRUE,
      x = 0.5, xanchor = "center", y = -0.1, yanchor = "top",
      bgcolor = "#E7EEF9", bordercolor = "#003189", font = list(color = "#003189"),
      buttons = list(
        list(
          label = "Variables individuales y del hogar", method = "update",
          args = list(
            list(visible = grupo_traza == "socio"),
            list(yaxis = eje_y("socio"), xaxis = eje_x("socio"))
          )
        ),
        list(
          label = "Municipios (ref.: Cali)", method = "update",
          args = list(
            list(visible = grupo_traza == "mun"),
            list(yaxis = eje_y("mun"), xaxis = eje_x("mun"))
          )
        )
      )
    ))
  )

figura_11

Entre las condiciones del hogar, las variables con mayor peso en el modelo son las relacionadas con el acceso a recursos tecnológicos. Manteniendo constantes las demás características, tener computador en el hogar multiplica los odds de superar el percentil 75 por 1,77, y tener internet, por 1,39. Como ambos recursos suelen presentarse juntos (V de Cramér de 0,43 en la Figura 9), cada coeficiente debe leerse como el efecto de uno de ellos cuando el otro se mantiene fijo. Un estudiante que cuenta con los dos tiene, frente a uno que no tiene ninguno, unos odds aproximadamente 2,4 veces mayores. Que el computador pese más que la conexión sugiere que en 2020 no bastaba con estar conectado: disponer de un equipo propio para seguir las clases, hacer los trabajos y prepararse para la prueba marcaba una diferencia mayor. Con todo, estos coeficientes no deben leerse como efectos causales, porque es probable que el computador también recoja parte del capital económico y educativo del hogar, como el nivel educativo de los padres, que no está incluido en el modelo.

La ubicación del colegio también resulta determinante. Estudiar en una institución urbana se asocia con odds 1,80 veces mayores que en una rural, aun después de controlar el estrato y los recursos del hogar. Esto indica que la brecha rural no se explica solo por las condiciones de las familias, sino también por factores propios de la oferta educativa, como la dotación de los colegios, la rotación de docentes o las distancias que deben recorrer los estudiantes, aspectos que la base no permite observar. En cuanto al género, los hombres presentan odds 1,52 veces mayores que las mujeres. Esta diferencia coincide con la brecha que el ICFES ha reportado de forma reiterada, concentrada sobre todo en matemáticas y ciencias naturales, y que la literatura relaciona más con estereotipos y expectativas diferenciadas en el aula que con diferencias de capacidad.

La edad, incorporada como variable continua, es la característica con mayor poder explicativo del modelo: su estadístico z es el más alto en valor absoluto. Cada año adicional multiplica los odds de superar el percentil 75 por 0,56, es decir, los reduce en cerca de un 44%. Así, manteniendo constantes las demás características, los odds de un estudiante de 19 años equivalen al 18% de los de un compañero de 16. Este resultado tiene una lectura clara en el contexto educativo colombiano: quienes presentan la prueba con más edad de la esperada suelen haber repetido grados, interrumpido su escolaridad o validado el bachillerato en programas para jóvenes y adultos, trayectorias que se asocian con mayores dificultades académicas. La edad funciona, por tanto, como una señal de la trayectoria escolar del estudiante, una dimensión que las demás variables no capturan.

El estrato socioeconómico deja un resultado que vale la pena destacar. En el análisis descriptivo, la proporción de estudiantes que supera el P75 casi se duplicaba entre el estrato 1 y el estrato 3; en el modelo, en cambio, los odds de los estratos 2 y 3 son apenas 1,20 y 1,35 veces los del estrato 1. Esa reducción indica que buena parte de la ventaja asociada al estrato opera a través del acceso a computador e internet y de la zona del colegio: cuando estas variables se mantienen constantes, el efecto propio del estrato es moderado. Los estudiantes sin estrato asignado, que posiblemente residen en zonas rurales dispersas o en asentamientos informales, tienen odds 48% menores que los del estrato 1. Los estratos 4 y 5 son significativos solo al 5%, y el estrato 6 no lo es, en buena medida porque reúnen muy pocos estudiantes y sus intervalos de confianza son amplios.

Respecto al municipio de residencia, solo 8 de los 41 municipios difieren significativamente de Cali al 5%. Los efectos positivos más sólidos corresponden a Palmira, Tuluá y Guadalajara de Buga, ciudades intermedias con una oferta educativa consolidada. El caso de Buenaventura merece especial atención: aun comparando estudiantes con el mismo estrato, los mismos recursos tecnológicos y el mismo tipo de zona, sus odds equivalen a 0,40 veces los de Cali, es decir, menos de la mitad. Esto apunta a brechas estructurales del Pacífico vallecaucano, relacionadas con la calidad de la oferta educativa, las condiciones de seguridad y el rezago institucional, que no se capturan con las variables del hogar. Para la mayoría de los municipios, en cambio, el lugar de residencia no agrega información una vez se conocen las condiciones del estudiante.

data.frame(
  Indicador = c(
    "Devianza nula",
    "Devianza residual",
    "Estadístico de razón de verosimilitud (χ²)",
    "Grados de libertad",
    "Valor p (prueba de razón de verosimilitud)",
    "Pseudo R² de McFadden",
    "AIC",
    "AUC promedio en validación cruzada (10 pliegues)"
  ),
  Valor = c(
    num(ajuste_logit$null.deviance, 1),
    num(ajuste_logit$deviance, 1),
    num(lr_chi, 1),
    lr_gl,
    ifelse(lr_p < 0.001, "< 0,001", num(lr_p, 4)),
    num(r2_mcf, 4),
    num(ajuste_logit$aic, 1),
    num(auc_cv_logit, 4)
  ),
  Interpretación = c(
    "Ajuste de un modelo que solo incluye el intercepto.",
    "Ajuste del modelo con todas las variables explicativas.",
    "Reducción de la devianza lograda por las variables explicativas.",
    "Número de parámetros añadidos frente al modelo nulo.",
    "Las variables explicativas, en conjunto, son estadísticamente significativas.",
    "Proporción de la devianza explicada; valores entre 0,2 y 0,4 indican un ajuste muy bueno.",
    "Criterio de información de Akaike; útil para comparar especificaciones alternativas.",
    "Capacidad de discriminación estimada fuera de muestra durante el entrenamiento."
  )
) %>%
  kbl(
    format = "html",
    caption = "Tabla 9. Bondad de ajuste del modelo Logit",
    align = c("l", "c", "l")
  ) %>%
  estilo_tabla() %>%
  column_spec(1, bold = TRUE, width = "6cm") %>%
  column_spec(3, width = "9cm")
Tabla 9. Bondad de ajuste del modelo Logit
Indicador Valor Interpretación
Devianza nula 13815,8 Ajuste de un modelo que solo incluye el intercepto.
Devianza residual 12092,5 Ajuste del modelo con todas las variables explicativas.
Estadístico de razón de verosimilitud (χ²) 1723,3 Reducción de la devianza lograda por las variables explicativas.
Grados de libertad 52 Número de parámetros añadidos frente al modelo nulo.
Valor p (prueba de razón de verosimilitud) < 0,001 Las variables explicativas, en conjunto, son estadísticamente significativas.
Pseudo R² de McFadden 0,1247 Proporción de la devianza explicada; valores entre 0,2 y 0,4 indican un ajuste muy bueno.
AIC 12198,5 Criterio de información de Akaike; útil para comparar especificaciones alternativas.
AUC promedio en validación cruzada (10 pliegues) 0,7184 Capacidad de discriminación estimada fuera de muestra durante el entrenamiento.
Nota: Fuente: Elaboración propia con base en los resultados de las pruebas Saber 11 del ICFES, Valle del Cauca (2020).

La prueba de razón de verosimilitud confirma que, en conjunto, las variables mejoran de forma significativa el ajuste frente a un modelo sin predictores (χ² = 1723,3, 52 grados de libertad, p < 0,001). Sin embargo, el pseudo R² de McFadden de 0,125 muestra que, en términos relativos, la reducción de la devianza es modesta. Con todo, la inclusión de la edad elevó este indicador de 0,074, valor que tenía el modelo sin esta variable, a 0,125, lo que confirma su aporte. Los dos resultados no se contradicen: con cerca de diez mil observaciones de entrenamiento, diferencias modestas entre grupos bastan para ser estadísticamente significativas, pero eso no implica que el modelo explique bien el desempeño de cada estudiante. Las variables disponibles describen el entorno del estudiante y dejan por fuera determinantes que los estudios sobre las pruebas Saber 11 identifican como centrales, como la naturaleza del colegio (oficial o privado), la jornada y el nivel educativo de los padres. La edad recoge parte de la trayectoria académica previa, pero de forma indirecta. La ausencia de estas variables es, probablemente, la principal razón del bajo poder explicativo del modelo.

4.2.1 Selección del umbral de clasificación

El modelo Logit entrega probabilidades, que se convierten en una clase mediante un umbral de corte. Como la base de entrenamiento está balanceada, el umbral natural es 0,5. Como análisis complementario se calculó el índice de Youden (que maximiza la suma de sensibilidad y especificidad) a partir de las predicciones de validación cruzada. Esto evita usar la base de prueba para elegir el umbral y, por tanto, evita la fuga de información.

prob_logit <- predict(modelo_logit, newdata = x_test, type = "prob")$Supera

roc_cv_logit <- roc(
  modelo_logit$pred$obs, modelo_logit$pred$Supera,
  levels = c("No_supera", "Supera"), direction = "<", quiet = TRUE
)
umbral_youden <- coords(roc_cv_logit, "best", best.method = "youden", ret = "threshold")$threshold[1]

umbral_logit <- 0.5
pred_logit <- factor(
  ifelse(prob_logit >= umbral_logit, "Supera", "No_supera"),
  levels = c("No_supera", "Supera")
)

y_bin <- y_test == "Supera"
cortes <- seq(0.05, 0.95, by = 0.01)

metricas_corte <- lapply(cortes, function(c) {
  pred <- prob_logit >= c
  vp <- sum(pred & y_bin); fp <- sum(pred & !y_bin)
  vn <- sum(!pred & !y_bin); fn <- sum(!pred & y_bin)
  data.frame(
    Umbral = c,
    Sensibilidad = vp / (vp + fn),
    Especificidad = vn / (vn + fp),
    Exactitud = (vp + vn) / length(y_bin),
    Precisión = ifelse(vp + fp > 0, vp / (vp + fp), NA)
  )
}) %>%
  bind_rows() %>%
  pivot_longer(-Umbral, names_to = "Métrica", values_to = "Valor")

colores_metricas <- c(
  "Sensibilidad" = col_logit,
  "Especificidad" = col_knn,
  "Exactitud" = col_acento,
  "Precisión" = "#495057"
)

figura_12 <- plot_ly(height = 500)

for (m in names(colores_metricas)) {
  d <- metricas_corte %>% filter(Métrica == m)
  figura_12 <- figura_12 %>%
    add_lines(
      data = d, x = ~Umbral, y = ~Valor, name = m,
      line = list(color = colores_metricas[[m]], width = 3),
      hovertemplate = paste0(
        "<b>", m, "</b><br>Umbral: %{x:.2f}<br>Valor: %{y:.3f}<extra></extra>"
      )
    )
}

figura_12 <- figura_12 %>%
  tema_plotly(
    "Figura 12. Desempeño del modelo Logit según el umbral de clasificación",
    x_tit = "Umbral de probabilidad", y_tit = "Valor de la métrica",
    extra = list(
      list(
        x = umbral_logit, y = 1.04, yref = "y", text = "<b>Umbral 0,5</b>",
        showarrow = FALSE, font = list(color = "white", size = 11),
        bgcolor = col_logit, borderpad = 4
      ),
      list(
        x = umbral_youden, y = 0.04, yref = "y",
        text = paste0("<b>Youden (VC) = ", num(umbral_youden, 3), "</b>"),
        showarrow = FALSE, font = list(color = "white", size = 11),
        bgcolor = col_knn, borderpad = 4
      )
    )
  ) %>%
  layout(
    yaxis = list(range = c(0, 1.08), tickformat = ".0%"),
    xaxis = list(range = c(0.05, 0.95), dtick = 0.1),
    hovermode = "x unified",
    shapes = list(
      list(type = "line", x0 = umbral_logit, x1 = umbral_logit, y0 = 0, y1 = 1,
           line = list(color = col_logit, dash = "dash", width = 2)),
      list(type = "line", x0 = umbral_youden, x1 = umbral_youden, y0 = 0, y1 = 1,
           line = list(color = col_knn, dash = "dot", width = 2))
    )
  )

figura_12

La Figura 12 muestra el intercambio entre sensibilidad y especificidad que implica cualquier umbral. Un umbral bajo clasifica como “Supera” a casi todos los estudiantes con un perfil medianamente favorable: detecta a la mayoría de los de alto desempeño, pero se equivoca con muchos de los que no lo son. Un umbral alto hace lo contrario. El umbral de Youden estimado en la validación cruzada fue 0,483, muy cercano a 0,5, lo cual es esperable al entrenar con clases balanceadas. Por esta razón se mantuvo el corte de 0,5, que además coincide con la regla de mayoría del KNN y permite comparar los dos modelos en las mismas condiciones. Cabe aclarar que la elección del umbral no es solo un asunto técnico. Si el modelo se usara para focalizar un programa de becas, convendría un umbral más bajo, que no dejara por fuera a estudiantes con potencial; si se usara para identificar a quienes probablemente no alcanzarán el P75 y priorizar el apoyo académico, sería preferible un umbral más alto, que reduzca los falsos “Supera”.

4.2.2 Evaluación del modelo Logit

fuerza_kappa <- function(k) {
  case_when(
    k < 0.2 ~ "leve", k < 0.4 ~ "aceptable", k < 0.6 ~ "moderado",
    k < 0.8 ~ "considerable", TRUE ~ "casi perfecto"
  )
}

tabla_confusion <- function(cm, titulo, color) {
  m <- cm$table
  data.frame(
    Predicción = c("No supera P75", "Supera P75"),
    `No supera P75` = mil(c(m[1, 1], m[2, 1])),
    `Supera P75` = mil(c(m[1, 2], m[2, 2])),
    Total = mil(unname(rowSums(m))),
    check.names = FALSE,
    row.names = NULL
  ) %>%
    kbl(format = "html", caption = titulo, align = c("l", "c", "c", "c")) %>%
    kable_styling(
      bootstrap_options = c("bordered", "condensed"),
      full_width = FALSE, position = "center", font_size = 14
    ) %>%
    add_header_above(c(" " = 1, "Valor real" = 2, " " = 1),
                     bold = TRUE, background = color, color = "white") %>%
    row_spec(0, bold = TRUE, background = "#003189", color = "white") %>%
    column_spec(1, bold = TRUE, background = "#F8F9FA") %>%
    footnote(
      general = paste(
        "Base de prueba. La diagonal (No supera–No supera y Supera–Supera)",
        "corresponde a las clasificaciones correctas."
      ),
      general_title = "Nota: ", footnote_as_chunk = TRUE
    )
}

tabla_metricas <- function(cm, roc_obj, titulo) {
  ci <- as.numeric(ci.auc(roc_obj))
  ov <- cm$overall
  bc <- cm$byClass
  m  <- cm$table
  fp <- m[2, 1]; fn <- m[1, 2]

  data.frame(
    Métrica = c(
      "Exactitud (Accuracy)", "IC 95% de la exactitud", "Tasa de no información",
      "Valor p [Exactitud > TNI]", "Kappa de Cohen", "Sensibilidad (Recall)",
      "Especificidad", "Valor predictivo positivo (Precisión)",
      "Valor predictivo negativo", "F1-score", "Exactitud balanceada",
      "AUC (área bajo la curva ROC)", "Valor p prueba de McNemar"
    ),
    Valor = c(
      num(ov["Accuracy"], 4),
      paste0("[", num(ov["AccuracyLower"], 4), " ; ", num(ov["AccuracyUpper"], 4), "]"),
      num(ov["AccuracyNull"], 4),
      ifelse(ov["AccuracyPValue"] < 0.001, "< 0,001", num(ov["AccuracyPValue"], 4)),
      num(ov["Kappa"], 4),
      num(bc["Sensitivity"], 4),
      num(bc["Specificity"], 4),
      num(bc["Pos Pred Value"], 4),
      num(bc["Neg Pred Value"], 4),
      num(bc["F1"], 4),
      num(bc["Balanced Accuracy"], 4),
      paste0(num(ci[2], 4), " [", num(ci[1], 3), " ; ", num(ci[3], 3), "]"),
      ifelse(ov["McnemarPValue"] < 0.001, "< 0,001", num(ov["McnemarPValue"], 4))
    ),
    Interpretación = c(
      paste0("Clasifica correctamente al ", pct(ov["Accuracy"]), " de los estudiantes de la base de prueba."),
      paste0("Con 95% de confianza, la exactitud real se ubica entre ", pct(ov["AccuracyLower"]), " y ", pct(ov["AccuracyUpper"]), "."),
      "Exactitud de un clasificador trivial que siempre predice la misma clase (50% por el balanceo).",
      "La exactitud del modelo es significativamente superior a la de un clasificador al azar.",
      paste0("Acuerdo ", fuerza_kappa(ov["Kappa"]), " entre predicciones y valores reales, descontando el azar."),
      paste0("Identifica al ", pct(bc["Sensitivity"]), " de los estudiantes que realmente superan el P75."),
      paste0("Identifica al ", pct(bc["Specificity"]), " de los estudiantes que no superan el P75."),
      paste0("Cuando predice 'Supera P75', acierta en el ", pct(bc["Pos Pred Value"]), " de los casos."),
      paste0("Cuando predice 'No supera P75', acierta en el ", pct(bc["Neg Pred Value"]), " de los casos."),
      "Media armónica entre precisión y sensibilidad para la clase 'Supera P75'.",
      "Promedio de sensibilidad y especificidad; resume el desempeño en ambas clases.",
      paste0("Probabilidad de que el modelo asigne mayor probabilidad a un estudiante que supera el P75 que a uno que no (IC 95% DeLong)."),
      paste0(
        "Los errores no son simétricos: hay ", mil(fp), " falsos positivos frente a ", mil(fn),
        " falsos negativos, por lo que el modelo tiende a ",
        ifelse(fp > fn, "sobreestimar", "subestimar"), " la clase 'Supera P75'."
      )
    )
  ) %>%
    kbl(format = "html", caption = titulo, align = c("l", "c", "l")) %>%
    estilo_tabla(nota = paste(fuente, "Clase positiva: Supera P75. Métricas calculadas en la base de prueba.")) %>%
    row_spec(c(1, 6, 7, 12), bold = TRUE, background = "#E7EEF9") %>%
    column_spec(1, bold = TRUE, width = "5cm") %>%
    column_spec(2, width = "4cm") %>%
    column_spec(3, width = "9cm")
}

datos_roc <- function(roc_obj, modelo) {
  data.frame(
    FPR = 1 - roc_obj$specificities,
    TPR = roc_obj$sensitivities,
    Umbral = roc_obj$thresholds,
    Modelo = modelo
  ) %>%
    arrange(FPR, TPR)
}

punto_corte <- function(cm) {
  c(FPR = 1 - unname(cm$byClass["Specificity"]), TPR = unname(cm$byClass["Sensitivity"]))
}

cm_logit  <- confusionMatrix(pred_logit, y_test, positive = "Supera", mode = "everything")
roc_logit <- roc(y_test, prob_logit, levels = c("No_supera", "Supera"), direction = "<", quiet = TRUE)
auc_logit <- as.numeric(auc(roc_logit))

tabla_confusion(cm_logit, "Tabla 10. Matriz de confusión del modelo Logit (umbral 0,5)", col_logit)
Tabla 10. Matriz de confusión del modelo Logit (umbral 0,5)
Valor real
Predicción No supera P75 Supera P75 Total
No supera P75 1.214 564 1.778
Supera P75 921 1.571 2.492
Nota: Base de prueba. La diagonal (No supera–No supera y Supera–Supera) corresponde a las clasificaciones correctas.
tabla_metricas(cm_logit, roc_logit, "Tabla 11. Métricas de evaluación del modelo Logit")
Tabla 11. Métricas de evaluación del modelo Logit
Métrica Valor Interpretación
Exactitud (Accuracy) 0,6522 Clasifica correctamente al 65,2% de los estudiantes de la base de prueba.
IC 95% de la exactitud [0,6377 ; 0,6665] Con 95% de confianza, la exactitud real se ubica entre 63,8% y 66,7%.
Tasa de no información 0,5000 Exactitud de un clasificador trivial que siempre predice la misma clase (50% por el balanceo).
Valor p [Exactitud > TNI] < 0,001 La exactitud del modelo es significativamente superior a la de un clasificador al azar.
Kappa de Cohen 0,3044 Acuerdo aceptable entre predicciones y valores reales, descontando el azar.
Sensibilidad (Recall) 0,7358 Identifica al 73,6% de los estudiantes que realmente superan el P75.
Especificidad 0,5686 Identifica al 56,9% de los estudiantes que no superan el P75.
Valor predictivo positivo (Precisión) 0,6304 Cuando predice ‘Supera P75’, acierta en el 63,0% de los casos.
Valor predictivo negativo 0,6828 Cuando predice ‘No supera P75’, acierta en el 68,3% de los casos.
F1-score 0,6791 Media armónica entre precisión y sensibilidad para la clase ‘Supera P75’.
Exactitud balanceada 0,6522 Promedio de sensibilidad y especificidad; resume el desempeño en ambas clases.
AUC (área bajo la curva ROC) 0,7108 [0,695 ; 0,726] Probabilidad de que el modelo asigne mayor probabilidad a un estudiante que supera el P75 que a uno que no (IC 95% DeLong).
Valor p prueba de McNemar < 0,001 Los errores no son simétricos: hay 921 falsos positivos frente a 564 falsos negativos, por lo que el modelo tiende a sobreestimar la clase ‘Supera P75’.
Nota: Fuente: Elaboración propia con base en los resultados de las pruebas Saber 11 del ICFES, Valle del Cauca (2020). Clase positiva: Supera P75. Métricas calculadas en la base de prueba.
roc_df_logit <- datos_roc(roc_logit, "Logit")
pto_logit <- punto_corte(cm_logit)

figura_13 <- plot_ly(height = 520) %>%
  add_trace(
    data = roc_df_logit, x = ~FPR, y = ~TPR,
    type = "scatter", mode = "lines", name = "Curva ROC Logit",
    fill = "tozeroy", fillcolor = "rgba(0,49,137,0.12)",
    line = list(color = col_logit, width = 3),
    customdata = ~Umbral,
    hovertemplate = paste0(
      "<b>Logit</b><br>1 - Especificidad: %{x:.3f}",
      "<br>Sensibilidad: %{y:.3f}<br>Umbral: %{customdata:.3f}<extra></extra>"
    )
  ) %>%
  add_trace(
    x = c(0, 1), y = c(0, 1), type = "scatter", mode = "lines",
    name = "Clasificador aleatorio",
    line = list(color = "#6C757D", dash = "dash", width = 1.5),
    hoverinfo = "skip"
  ) %>%
  add_markers(
    x = pto_logit["FPR"], y = pto_logit["TPR"], name = "Umbral 0,5",
    marker = list(size = 13, color = "white", line = list(color = col_logit, width = 3)),
    hovertemplate = paste0(
      "<b>Umbral 0,5</b><br>Sensibilidad: ", num(pto_logit["TPR"]),
      "<br>Especificidad: ", num(1 - pto_logit["FPR"]), "<extra></extra>"
    )
  ) %>%
  tema_plotly(
    "Figura 13. Curva ROC del modelo Logit",
    x_tit = "1 - Especificidad (tasa de falsos positivos)",
    y_tit = "Sensibilidad (tasa de verdaderos positivos)",
    extra = list(list(
      x = 0.72, y = 0.18, xref = "x", yref = "y",
      text = paste0("<b>AUC = ", num(auc_logit, 3), "</b>"),
      showarrow = FALSE, font = list(size = 16, color = col_logit),
      bgcolor = "white", bordercolor = col_logit, borderwidth = 1.5, borderpad = 6
    ))
  ) %>%
  layout(
    xaxis = list(range = c(0, 1), dtick = 0.2),
    yaxis = list(range = c(0, 1.02), dtick = 0.2)
  )

figura_13
grafico_probabilidades <- function(prob, titulo, color, umbral = 0.5, nbins = 40) {
  d <- data.frame(
    Prob = prob,
    Real = ifelse(y_test == "Supera", "Supera P75 (real)", "No supera P75 (real)")
  )
  plot_ly(height = 480) %>%
    add_histogram(
      data = d %>% filter(Real == "No supera P75 (real)"), x = ~Prob,
      name = "No supera P75 (real)", nbinsx = nbins,
      marker = list(color = col_claro, line = list(color = "white", width = 0.5)),
      opacity = 0.85,
      hovertemplate = "Probabilidad: %{x}<br>Estudiantes: %{y}<extra>No supera P75</extra>"
    ) %>%
    add_histogram(
      data = d %>% filter(Real == "Supera P75 (real)"), x = ~Prob,
      name = "Supera P75 (real)", nbinsx = nbins,
      marker = list(color = color, line = list(color = "white", width = 0.5)),
      opacity = 0.65,
      hovertemplate = "Probabilidad: %{x}<br>Estudiantes: %{y}<extra>Supera P75</extra>"
    ) %>%
    tema_plotly(
      titulo,
      x_tit = "Probabilidad estimada de superar el P75",
      y_tit = "Número de estudiantes",
      extra = list(list(
        x = umbral, y = 1.02, yref = "paper", text = "<b>Umbral 0,5</b>",
        showarrow = FALSE, font = list(size = 11, color = "#333333")
      ))
    ) %>%
    layout(
      barmode = "overlay",
      xaxis = list(range = c(0, 1), dtick = 0.1),
      shapes = list(list(
        type = "line", x0 = umbral, x1 = umbral, y0 = 0, y1 = 1, yref = "paper",
        line = list(color = "#333333", dash = "dash", width = 2)
      ))
    )
}

grafico_probabilidades(
  prob_logit,
  "Figura 14. Probabilidades estimadas por el Logit según la clase real",
  col_logit
)
pesos_clase <- ifelse(
  icfes_modelo$SUPERA == "Supera",
  sum(icfes_modelo$SUPERA == "No_supera") / sum(icfes_modelo$SUPERA == "Supera"),
  1
)

perfiles_base <- icfes_modelo %>%
  mutate(peso = pesos_clase) %>%
  group_by(across(-c(SUPERA, peso))) %>%
  summarise(
    peso_supera = sum(peso[SUPERA == "Supera"]),
    peso_no = sum(peso[SUPERA == "No_supera"]),
    n = n(),
    .groups = "drop"
  )

n_perfiles  <- nrow(perfiles_base)
prop_mixtos <- sum(perfiles_base$n[perfiles_base$peso_supera > 0 & perfiles_base$peso_no > 0]) /
  sum(perfiles_base$n)
techo <- sum(pmax(perfiles_base$peso_supera, perfiles_base$peso_no)) /
  sum(perfiles_base$peso_supera + perfiles_base$peso_no)

En la base de prueba, el Logit clasifica correctamente al 65,2% de los estudiantes, 15,2 puntos por encima de lo que se obtendría al azar. La matriz de confusión (Tabla 10) muestra que acierta con 1.571 de los 2.135 estudiantes que superan el P75 (sensibilidad de 73,6%), pero solo reconoce a 1.214 de los 2.135 que no lo superan (especificidad de 56,9%). Los errores no son simétricos: hay 921 falsos positivos frente a 564 falsos negativos, y la prueba de McNemar confirma que esa diferencia es significativa.

Este patrón tiene una lectura de fondo. Los falsos positivos son estudiantes con condiciones favorables, como computador, internet, colegio urbano, estratos 2 y 3 o una edad acorde con el grado, que aun así no alcanzaron el percentil 75. El modelo identifica bien qué condiciones se asocian con el alto desempeño, pero esas condiciones no lo garantizan: lo facilitan sin determinarlo. El índice Kappa de 0,304 refleja un acuerdo aceptable entre las predicciones y la realidad, una vez descontado el efecto del azar.

El AUC de 0,711 (Figura 13) significa que, si se toman al azar un estudiante que supera el P75 y otro que no, el modelo le asigna mayor probabilidad al primero en cerca de 71 de cada 100 comparaciones, lo que corresponde a una capacidad de discriminación moderada. La edad mejoró de forma clara este resultado: en la especificación inicial, sin esta variable, el AUC del Logit era de 0,665. La Figura 14 ayuda a entender por qué no es mayor: las probabilidades estimadas para los dos grupos se superponen ampliamente. La razón está en la estructura de los datos. Los 33.054 estudiantes se reparten en apenas 5.593 combinaciones distintas de características, y el 76,4% de ellos comparte su perfil con otros estudiantes que obtuvieron el resultado contrario. Para el modelo, dos estudiantes con el mismo perfil son indistinguibles y reciben la misma probabilidad, sin importar que uno haya superado el percentil y el otro no.

4.2.3 Probabilidad estimada según el perfil del estudiante

Para facilitar la interpretación del Logit, se calculó la probabilidad estimada de superar el percentil 75 para distintos perfiles, combinando estrato y acceso a recursos tecnológicos. Se tomó como referencia una estudiante de género femenino, de 17 años (la edad más frecuente), que reside en Cali y estudia en un colegio urbano. Dado que el modelo se entrenó con una base balanceada (50/50), sus probabilidades sobreestiman la prevalencia real. Por esa razón también se presenta la versión ajustada a la prevalencia real (21,5%), obtenida mediante la corrección del intercepto propuesta por King y Zeng (2001).

niveles <- lapply(icfes_balanceada %>% select(-SUPERA), levels)

recursos <- data.frame(
  Recursos = c("Sin internet ni computador", "Solo internet", "Solo computador", "Internet y computador"),
  FAMI_TIENEINTERNET = c("No", "Si", "No", "Si"),
  FAMI_TIENECOMPUTADOR = c("No", "No", "Si", "Si")
)

orden_estratos <- c("Sin Estrato", paste("Estrato", 1:6))
etiquetas_estratos <- c("Sin estrato", paste("Estrato", 1:6))

perfiles <- expand.grid(
  FAMI_ESTRATOVIVIENDA = orden_estratos,
  Recursos = recursos$Recursos,
  stringsAsFactors = FALSE
) %>%
  left_join(recursos, by = "Recursos") %>%
  mutate(
    ESTU_GENERO = "F",
    ESTU_MCPIO_RESIDE = "CALI",
    COLE_AREA_UBICACION = "URBANO",
    EDAD = 17
  )

for (v in names(niveles)) {
  if (!is.null(niveles[[v]])) perfiles[[v]] <- factor(perfiles[[v]], levels = niveles[[v]])
}

perfiles$Prob <- predict(
  modelo_logit,
  newdata = predict(dummies, newdata = perfiles),
  type = "prob"
)$Supera

perfiles$Prob_real <- plogis(qlogis(perfiles$Prob) + qlogis(prevalencia_real) - qlogis(0.5))

matriz_perfil <- function(columna) {
  perfiles %>%
    select(Recursos, FAMI_ESTRATOVIVIENDA, all_of(columna)) %>%
    pivot_wider(names_from = FAMI_ESTRATOVIVIENDA, values_from = all_of(columna)) %>%
    mutate(Recursos = factor(Recursos, levels = recursos$Recursos)) %>%
    arrange(Recursos) %>%
    select(all_of(orden_estratos)) %>%
    as.matrix()
}

z_bal  <- matriz_perfil("Prob")
z_real <- matriz_perfil("Prob_real")

texto_celdas <- function(z) matrix(pct(z, 1), nrow = nrow(z))

escala <- list(
  c(0, "#FFFFFF"), c(0.25, "#C9D7F0"), c(0.5, "#7FA0D9"),
  c(0.75, "#2F5BB7"), c(1, "#003189")
)

figura_15 <- plot_ly(height = 470) %>%
  add_heatmap(
    x = etiquetas_estratos, y = recursos$Recursos, z = z_bal,
    text = texto_celdas(z_bal), texttemplate = "%{text}",
    textfont = list(size = 12),
    colorscale = escala, zmin = 0, zmax = 1,
    colorbar = list(title = list(text = "Probabilidad"), tickformat = ".0%", len = 0.8),
    hovertemplate = "<b>%{y}</b><br>%{x}<br>Probabilidad: %{text}<extra>Base balanceada</extra>",
    visible = TRUE
  ) %>%
  add_heatmap(
    x = etiquetas_estratos, y = recursos$Recursos, z = z_real,
    text = texto_celdas(z_real), texttemplate = "%{text}",
    textfont = list(size = 12),
    colorscale = escala, zmin = 0, zmax = 1,
    colorbar = list(title = list(text = "Probabilidad"), tickformat = ".0%", len = 0.8),
    hovertemplate = "<b>%{y}</b><br>%{x}<br>Probabilidad: %{text}<extra>Prevalencia real</extra>",
    visible = FALSE
  ) %>%
  tema_plotly(
    "Figura 15. Probabilidad estimada de superar el P75 según el perfil",
    b = 200, nota_y = -0.62
  ) %>%
  layout(
    yaxis = list(title = "", automargin = TRUE),
    xaxis = list(showgrid = FALSE),
    margin = list(l = 190),
    updatemenus = list(list(
      type = "buttons", direction = "right", showactive = TRUE,
      x = 0.5, xanchor = "center", y = -0.30, yanchor = "top",
      buttons = list(
        list(label = "Probabilidad en base balanceada", method = "restyle",
             args = list("visible", list(TRUE, FALSE))),
        list(label = "Ajustada a la prevalencia real", method = "restyle",
             args = list("visible", list(FALSE, TRUE)))
      )
    ))
  )

figura_15

La Figura 15 traduce los coeficientes a probabilidades, lo que facilita dimensionar las brechas. Con las probabilidades ajustadas a la prevalencia real, una estudiante de 17 años y estrato 1 que vive en Cali, estudia en un colegio urbano y no tiene internet ni computador tiene una probabilidad de 10,5% de superar el percentil 75. Si cuenta con ambos recursos, la probabilidad sube a 22,4%. En cambio, pasar del estrato 1 al estrato 3 sin cambiar el acceso a la tecnología solo la lleva a 13,8%. Dicho de otra forma, en este modelo la diferencia asociada a los recursos tecnológicos es mayor que la asociada al estrato. El resultado debe leerse con cautela: no demuestra que entregar un computador eleve el desempeño en esa magnitud, pero sí indica que, en el año en que la educación se trasladó a la virtualidad, la brecha digital estuvo estrechamente ligada a la brecha en los resultados.

4.3 Modelo KNN

El algoritmo de K vecinos más cercanos (KNN) es un método no paramétrico que clasifica cada estudiante según la clase mayoritaria entre sus k observaciones más cercanas en el espacio de las variables explicativas, usando la distancia euclidiana. A diferencia del Logit, no estima parámetros ni supone una forma funcional, por lo que puede captar patrones locales; a cambio, no ofrece una interpretación directa del efecto de cada variable.

Las variables dummy (0/1) comparten la misma escala, pero la edad se mide en años y tiene un rango mucho mayor. Para evitar que dominara el cálculo de las distancias, se estandarizó con la media y la desviación estándar del conjunto de entrenamiento, y la misma transformación se aplicó a la base de prueba. Aun así, muchos estudiantes comparten exactamente el mismo perfil (la base tiene cerca de 5.600 combinaciones distintas), lo que genera empates en las distancias. Para resolverlo se añadió un ruido aleatorio de magnitud despreciable (jitter, máximo ±0,001), que solo desempata perfiles idénticos sin alterar la estructura de los datos. El número de vecinos k se seleccionó entre 5 y 205 mediante la misma validación cruzada de 10 pliegues usada en el Logit, maximizando el AUC.

media_edad_train <- mean(x_train[, "EDAD"])
sd_edad_train    <- sd(x_train[, "EDAD"])

x_train_knn <- x_train
x_test_knn  <- x_test
x_train_knn[, "EDAD"] <- (x_train[, "EDAD"] - media_edad_train) / sd_edad_train
x_test_knn[, "EDAD"]  <- (x_test[, "EDAD"] - media_edad_train) / sd_edad_train

set.seed(123)
x_train_knn <- jitter(x_train_knn, amount = 0.001)
x_test_knn  <- jitter(x_test_knn, amount = 0.001)

set.seed(123)
modelo_knn <- train(
  x = x_train_knn,
  y = y_train,
  method = "knn",
  trControl = control,
  tuneGrid = data.frame(k = seq(5, 205, by = 20)),
  metric = "ROC"
)
res_knn <- modelo_knn$results
k_optimo <- modelo_knn$bestTune$k
auc_cv_knn <- res_knn$ROC[res_knn$k == k_optimo]

figura_16 <- plot_ly(height = 520) %>%
  add_ribbons(
    data = res_knn, x = ~k, ymin = ~ROC - ROCSD, ymax = ~ROC + ROCSD,
    name = "± 1 desv. estándar", fillcolor = "rgba(216,0,37,0.12)",
    line = list(color = "transparent"), hoverinfo = "skip", visible = TRUE
  ) %>%
  add_trace(
    data = res_knn, x = ~k, y = ~ROC, type = "scatter", mode = "lines+markers",
    name = "AUC (validación cruzada)",
    line = list(color = col_knn, width = 3),
    marker = list(size = 8, color = col_knn, line = list(color = "white", width = 1.5)),
    customdata = ~ROCSD,
    hovertemplate = "k = %{x}<br>AUC = %{y:.4f}<br>Desv. est. = %{customdata:.4f}<extra></extra>",
    visible = TRUE
  ) %>%
  add_trace(
    data = res_knn, x = ~k, y = ~Sens, type = "scatter", mode = "lines+markers",
    name = "Sensibilidad", line = list(color = col_logit, width = 3),
    marker = list(size = 7, color = col_logit),
    hovertemplate = "k = %{x}<br>Sensibilidad = %{y:.4f}<extra></extra>",
    visible = FALSE
  ) %>%
  add_trace(
    data = res_knn, x = ~k, y = ~Spec, type = "scatter", mode = "lines+markers",
    name = "Especificidad", line = list(color = col_knn, width = 3),
    marker = list(size = 7, color = col_knn),
    hovertemplate = "k = %{x}<br>Especificidad = %{y:.4f}<extra></extra>",
    visible = FALSE
  ) %>%
  tema_plotly(
    "Figura 16. Selección del número de vecinos (k) por validación cruzada",
    x_tit = "Número de vecinos (k)", y_tit = "AUC promedio (10 pliegues)",
    b = 170, nota_y = -0.42,
    extra = list(list(
      x = k_optimo, y = 1.02, yref = "paper",
      text = paste0("<b>k óptimo = ", k_optimo, "</b>"),
      showarrow = FALSE, font = list(color = "white", size = 12),
      bgcolor = col_logit, borderpad = 5
    ))
  ) %>%
  layout(
    xaxis = list(dtick = 20),
    shapes = list(list(
      type = "line", x0 = k_optimo, x1 = k_optimo, y0 = 0, y1 = 1, yref = "paper",
      line = list(color = col_logit, dash = "dash", width = 2)
    )),
    updatemenus = list(list(
      type = "buttons", direction = "right", showactive = TRUE,
      x = 0.5, xanchor = "center", y = -0.24, yanchor = "top",
      buttons = list(
        list(
          label = "AUC", method = "update",
          args = list(
            list(visible = c(TRUE, TRUE, FALSE, FALSE)),
            list(yaxis = list(title = "AUC promedio (10 pliegues)", gridcolor = "#EEEEEE", autorange = TRUE))
          )
        ),
        list(
          label = "Sensibilidad y especificidad", method = "update",
          args = list(
            list(visible = c(FALSE, FALSE, TRUE, TRUE)),
            list(yaxis = list(title = "Valor de la métrica", gridcolor = "#EEEEEE", autorange = TRUE))
          )
        )
      )
    ))
  )

figura_16

El AUC del KNN crece rápidamente al aumentar el número de vecinos y se estabiliza a partir de valores de k cercanos a 80. Con pocos vecinos, la clasificación depende de unos cuantos estudiantes y es muy sensible al ruido. El valor elegido fue k = 105, con un AUC de 0,7066 en validación cruzada. Sin embargo, en la zona estable las diferencias de AUC entre un k y otro son menores que la variación entre pliegues (banda gris), de modo que cualquier valor de ese rango produciría resultados prácticamente equivalentes. Que el k óptimo sea tan alto tiene una explicación: como muchos estudiantes comparten exactamente el mismo perfil, buena parte de los vecinos más cercanos de cada estudiante son idénticos a él, y la predicción termina siendo la proporción de estudiantes de ese perfil que supera el P75. En la práctica, el KNN se comporta como una tabla de frecuencias por perfil, lo que anticipa que sus resultados serán muy parecidos a los del Logit.

pred_knn <- predict(modelo_knn, newdata = x_test_knn)
prob_knn <- predict(modelo_knn, newdata = x_test_knn, type = "prob")$Supera

cm_knn  <- confusionMatrix(pred_knn, y_test, positive = "Supera", mode = "everything")
roc_knn <- roc(y_test, prob_knn, levels = c("No_supera", "Supera"), direction = "<", quiet = TRUE)
auc_knn <- as.numeric(auc(roc_knn))

tabla_confusion(cm_knn, paste0("Tabla 12. Matriz de confusión del modelo KNN (k = ", k_optimo, ")"), col_knn)
Tabla 12. Matriz de confusión del modelo KNN (k = 105)
Valor real
Predicción No supera P75 Supera P75 Total
No supera P75 1.128 523 1.651
Supera P75 1.007 1.612 2.619
Nota: Base de prueba. La diagonal (No supera–No supera y Supera–Supera) corresponde a las clasificaciones correctas.
tabla_metricas(cm_knn, roc_knn, "Tabla 13. Métricas de evaluación del modelo KNN")
Tabla 13. Métricas de evaluación del modelo KNN
Métrica Valor Interpretación
Exactitud (Accuracy) 0,6417 Clasifica correctamente al 64,2% de los estudiantes de la base de prueba.
IC 95% de la exactitud [0,6271 ; 0,6561] Con 95% de confianza, la exactitud real se ubica entre 62,7% y 65,6%.
Tasa de no información 0,5000 Exactitud de un clasificador trivial que siempre predice la misma clase (50% por el balanceo).
Valor p [Exactitud > TNI] < 0,001 La exactitud del modelo es significativamente superior a la de un clasificador al azar.
Kappa de Cohen 0,2834 Acuerdo aceptable entre predicciones y valores reales, descontando el azar.
Sensibilidad (Recall) 0,7550 Identifica al 75,5% de los estudiantes que realmente superan el P75.
Especificidad 0,5283 Identifica al 52,8% de los estudiantes que no superan el P75.
Valor predictivo positivo (Precisión) 0,6155 Cuando predice ‘Supera P75’, acierta en el 61,6% de los casos.
Valor predictivo negativo 0,6832 Cuando predice ‘No supera P75’, acierta en el 68,3% de los casos.
F1-score 0,6782 Media armónica entre precisión y sensibilidad para la clase ‘Supera P75’.
Exactitud balanceada 0,6417 Promedio de sensibilidad y especificidad; resume el desempeño en ambas clases.
AUC (área bajo la curva ROC) 0,6961 [0,680 ; 0,712] Probabilidad de que el modelo asigne mayor probabilidad a un estudiante que supera el P75 que a uno que no (IC 95% DeLong).
Valor p prueba de McNemar < 0,001 Los errores no son simétricos: hay 1.007 falsos positivos frente a 523 falsos negativos, por lo que el modelo tiende a sobreestimar la clase ‘Supera P75’.
Nota: Fuente: Elaboración propia con base en los resultados de las pruebas Saber 11 del ICFES, Valle del Cauca (2020). Clase positiva: Supera P75. Métricas calculadas en la base de prueba.
roc_df_knn <- datos_roc(roc_knn, "KNN")
pto_knn <- punto_corte(cm_knn)

figura_17 <- plot_ly(height = 520) %>%
  add_trace(
    data = roc_df_knn, x = ~FPR, y = ~TPR,
    type = "scatter", mode = "lines", name = "Curva ROC KNN",
    fill = "tozeroy", fillcolor = "rgba(216,0,37,0.10)",
    line = list(color = col_knn, width = 3),
    customdata = ~Umbral,
    hovertemplate = paste0(
      "<b>KNN</b><br>1 - Especificidad: %{x:.3f}",
      "<br>Sensibilidad: %{y:.3f}<br>Umbral: %{customdata:.3f}<extra></extra>"
    )
  ) %>%
  add_trace(
    x = c(0, 1), y = c(0, 1), type = "scatter", mode = "lines",
    name = "Clasificador aleatorio",
    line = list(color = "#6C757D", dash = "dash", width = 1.5),
    hoverinfo = "skip"
  ) %>%
  add_markers(
    x = pto_knn["FPR"], y = pto_knn["TPR"], name = "Regla de mayoría (0,5)",
    marker = list(size = 13, color = "white", line = list(color = col_knn, width = 3)),
    hovertemplate = paste0(
      "<b>Regla de mayoría</b><br>Sensibilidad: ", num(pto_knn["TPR"]),
      "<br>Especificidad: ", num(1 - pto_knn["FPR"]), "<extra></extra>"
    )
  ) %>%
  tema_plotly(
    "Figura 17. Curva ROC del modelo KNN",
    x_tit = "1 - Especificidad (tasa de falsos positivos)",
    y_tit = "Sensibilidad (tasa de verdaderos positivos)",
    extra = list(list(
      x = 0.72, y = 0.18, xref = "x", yref = "y",
      text = paste0("<b>AUC = ", num(auc_knn, 3), "</b>"),
      showarrow = FALSE, font = list(size = 16, color = col_knn),
      bgcolor = "white", bordercolor = col_knn, borderwidth = 1.5, borderpad = 6
    ))
  ) %>%
  layout(
    xaxis = list(range = c(0, 1), dtick = 0.2),
    yaxis = list(range = c(0, 1.02), dtick = 0.2)
  )

figura_17
grafico_probabilidades(
  prob_knn,
  "Figura 18. Probabilidades estimadas por KNN según la clase real",
  col_knn, nbins = 30
)

El KNN clasifica correctamente al 64,2% de los estudiantes de prueba, con una sensibilidad de 75,5% y una especificidad de 52,8%. Es un modelo que se inclina claramente hacia la clase “Supera”: detecta a 1.612 de los 2.135 estudiantes de alto desempeño, pero a costa de 1.007 falsos positivos, por lo que reconoce correctamente a poco más de la mitad de los estudiantes que no superan el percentil. Su AUC de 0,696 (Figura 17) indica una capacidad de discriminación moderada, similar a la del Logit. La Figura 18 muestra que las probabilidades del KNN toman pocos valores distintos, ya que corresponden a la fracción de los 105 vecinos que supera el P75, y que, como en el Logit, las distribuciones de los dos grupos se superponen ampliamente.

4.4 Comparación de modelos

Para determinar qué modelo es más adecuado se compararon sus resultados desde cuatro perspectivas: (i) las métricas de clasificación en la base de prueba, (ii) la capacidad de discriminación medida por la curva ROC, (iii) la estabilidad de su desempeño en los 10 pliegues de validación cruzada y (iv) pruebas estadísticas formales: la prueba de DeLong para comparar los AUC y la prueba de McNemar para comparar los errores de clasificación.

prueba_delong <- roc.test(roc_logit, roc_knn, method = "delong", paired = TRUE)

acierto_logit <- pred_logit == y_test
acierto_knn   <- pred_knn == y_test
tabla_aciertos <- table(Logit = acierto_logit, KNN = acierto_knn)
prueba_mcnemar <- mcnemar.test(tabla_aciertos)
concordancia   <- mean(pred_logit == pred_knn)

metricas_modelo <- function(cm, auc_val, auc_cv) {
  c(
    cm$overall["Accuracy"], cm$overall["Kappa"],
    cm$byClass[c("Sensitivity", "Specificity", "Pos Pred Value",
                 "Neg Pred Value", "F1", "Balanced Accuracy")],
    AUC = auc_val, AUC_CV = auc_cv
  )
}

valores_logit <- metricas_modelo(cm_logit, auc_logit, auc_cv_logit)
valores_knn   <- metricas_modelo(cm_knn, auc_knn, auc_cv_knn)

nombres_metricas <- c(
  "Exactitud (Accuracy)", "Kappa de Cohen", "Sensibilidad",
  "Especificidad", "Valor predictivo positivo", "Valor predictivo negativo",
  "F1-score", "Exactitud balanceada", "AUC (base de prueba)",
  "AUC (validación cruzada)"
)

comparacion <- data.frame(
  Métrica = nombres_metricas,
  Logit = as.numeric(valores_logit),
  KNN = as.numeric(valores_knn)
) %>%
  mutate(
    Diferencia = Logit - KNN,
    Mejor = case_when(
      abs(Diferencia) < 0.0005 ~ "Empate",
      Diferencia > 0 ~ "Logit",
      TRUE ~ "KNN"
    )
  )

comparacion %>%
  mutate(
    Logit = cell_spec(num(Logit, 4), bold = Mejor == "Logit", color = ifelse(Mejor == "Logit", col_logit, "#333333")),
    KNN = cell_spec(num(KNN, 4), bold = Mejor == "KNN", color = ifelse(Mejor == "KNN", col_knn, "#333333")),
    Diferencia = paste0(ifelse(Diferencia > 0, "+", ""), num(Diferencia, 4)),
    Mejor = cell_spec(Mejor, bold = TRUE, color = case_when(
      Mejor == "Logit" ~ col_logit, Mejor == "KNN" ~ col_knn, TRUE ~ "#6C757D"
    ))
  ) %>%
  kbl(
    format = "html", escape = FALSE,
    caption = "Tabla 14. Comparación del desempeño de los modelos Logit y KNN",
    col.names = c("Métrica", "Logit", paste0("KNN (k = ", k_optimo, ")"), "Diferencia (Logit − KNN)", "Mejor modelo"),
    align = c("l", "c", "c", "c", "c")
  ) %>%
  estilo_tabla(nota = paste(
    fuente,
    "Clase positiva: Supera P75. En negrita, el mejor valor de cada métrica."
  )) %>%
  column_spec(1, bold = TRUE)
Tabla 14. Comparación del desempeño de los modelos Logit y KNN
Métrica Logit KNN (k = 105) Diferencia (Logit − KNN) Mejor modelo
Exactitud (Accuracy) 0,6522 0,6417 +0,0105 Logit
Kappa de Cohen 0,3044 0,2834 +0,0211 Logit
Sensibilidad 0,7358 0,7550 -0,0192 KNN
Especificidad 0,5686 0,5283 +0,0403 Logit
Valor predictivo positivo 0,6304 0,6155 +0,0149 Logit
Valor predictivo negativo 0,6828 0,6832 -0,0004 Empate
F1-score 0,6791 0,6782 +0,0009 Logit
Exactitud balanceada 0,6522 0,6417 +0,0105 Logit
AUC (base de prueba) 0,7108 0,6961 +0,0147 Logit
AUC (validación cruzada) 0,7184 0,7066 +0,0118 Logit
Nota: Fuente: Elaboración propia con base en los resultados de las pruebas Saber 11 del ICFES, Valle del Cauca (2020). Clase positiva: Supera P75. En negrita, el mejor valor de cada métrica.
figura_19 <- plot_ly(height = 540) %>%
  add_trace(
    x = c(0, 1), y = c(0, 1), type = "scatter", mode = "lines",
    name = "Clasificador aleatorio",
    line = list(color = "#6C757D", dash = "dash", width = 1.5),
    hoverinfo = "skip"
  ) %>%
  add_trace(
    data = roc_df_logit, x = ~FPR, y = ~TPR, type = "scatter", mode = "lines",
    name = paste0("Logit (AUC = ", num(auc_logit, 3), ")"),
    line = list(color = col_logit, width = 3),
    hovertemplate = "<b>Logit</b><br>1 - Especificidad: %{x:.3f}<br>Sensibilidad: %{y:.3f}<extra></extra>"
  ) %>%
  add_trace(
    data = roc_df_knn, x = ~FPR, y = ~TPR, type = "scatter", mode = "lines",
    name = paste0("KNN (AUC = ", num(auc_knn, 3), ")"),
    line = list(color = col_knn, width = 3, dash = "dot"),
    hovertemplate = "<b>KNN</b><br>1 - Especificidad: %{x:.3f}<br>Sensibilidad: %{y:.3f}<extra></extra>"
  ) %>%
  add_markers(
    x = c(pto_logit["FPR"], pto_knn["FPR"]), y = c(pto_logit["TPR"], pto_knn["TPR"]),
    name = "Punto de operación (0,5)",
    marker = list(size = 13, color = "white", line = list(color = c(col_logit, col_knn), width = 3)),
    text = c("Logit", "KNN"),
    hovertemplate = "<b>%{text}</b> con umbral 0,5<br>1 - Especificidad: %{x:.3f}<br>Sensibilidad: %{y:.3f}<extra></extra>"
  ) %>%
  tema_plotly(
    "Figura 19. Curvas ROC comparadas: Logit vs. KNN",
    x_tit = "1 - Especificidad (tasa de falsos positivos)",
    y_tit = "Sensibilidad (tasa de verdaderos positivos)",
    extra = list(list(
      x = 0.70, y = 0.12, xref = "x", yref = "y",
      text = paste0(
        "<b>Prueba de DeLong</b><br>p ",
        ifelse(prueba_delong$p.value < 0.001, "< 0,001", paste("=", num(prueba_delong$p.value, 3)))
      ),
      showarrow = FALSE, font = list(size = 12, color = "#333333"),
      bgcolor = "white", bordercolor = "#ADB5BD", borderwidth = 1, borderpad = 6
    ))
  ) %>%
  layout(
    xaxis = list(range = c(0, 1), dtick = 0.2),
    yaxis = list(range = c(0, 1.02), dtick = 0.2)
  )

figura_19
metricas_graf <- comparacion %>%
  filter(Métrica %in% c(
    "Exactitud (Accuracy)", "Sensibilidad", "Especificidad",
    "Valor predictivo positivo", "Valor predictivo negativo",
    "F1-score", "AUC (base de prueba)"
  )) %>%
  mutate(Métrica = recode(
    Métrica,
    "Exactitud (Accuracy)" = "Exactitud",
    "Valor predictivo positivo" = "VPP",
    "Valor predictivo negativo" = "VPN",
    "AUC (base de prueba)" = "AUC"
  ))

figura_20 <- plot_ly(height = 520) %>%
  add_bars(
    data = metricas_graf, x = ~Métrica, y = ~Logit, name = "Logit",
    marker = list(color = col_logit), legendgroup = "Logit",
    text = ~num(Logit, 3), textposition = "outside",
    hovertemplate = "<b>Logit</b><br>%{x}: %{y:.4f}<extra></extra>"
  ) %>%
  add_bars(
    data = metricas_graf, x = ~Métrica, y = ~KNN, name = "KNN",
    marker = list(color = col_knn), legendgroup = "KNN",
    text = ~num(KNN, 3), textposition = "outside",
    hovertemplate = "<b>KNN</b><br>%{x}: %{y:.4f}<extra></extra>"
  ) %>%
  add_trace(
    type = "scatterpolar", mode = "lines+markers", fill = "toself",
    r = c(metricas_graf$Logit, metricas_graf$Logit[1]),
    theta = c(metricas_graf$Métrica, metricas_graf$Métrica[1]),
    name = "Logit", legendgroup = "Logit", showlegend = FALSE,
    line = list(color = col_logit, width = 2.5),
    fillcolor = "rgba(0,49,137,0.15)", marker = list(color = col_logit),
    hovertemplate = "<b>Logit</b><br>%{theta}: %{r:.4f}<extra></extra>"
  ) %>%
  add_trace(
    type = "scatterpolar", mode = "lines+markers", fill = "toself",
    r = c(metricas_graf$KNN, metricas_graf$KNN[1]),
    theta = c(metricas_graf$Métrica, metricas_graf$Métrica[1]),
    name = "KNN", legendgroup = "KNN", showlegend = FALSE,
    line = list(color = col_knn, width = 2.5, dash = "dot"),
    fillcolor = "rgba(216,0,37,0.10)", marker = list(color = col_knn),
    hovertemplate = "<b>KNN</b><br>%{theta}: %{r:.4f}<extra></extra>"
  ) %>%
  tema_plotly(
    "Figura 20. Comparación de métricas de clasificación",
    y_tit = "Valor de la métrica"
  ) %>%
  layout(
    barmode = "group",
    xaxis = list(domain = c(0, 0.55), tickangle = -30),
    yaxis = list(range = c(0, 0.9)),
    polar = list(
      domain = list(x = c(0.68, 0.95), y = c(0.12, 0.88)),
      radialaxis = list(range = c(0.45, 0.75), tickformat = ".2f", gridcolor = "#DDDDDD", angle = 90),
      angularaxis = list(gridcolor = "#DDDDDD", direction = "clockwise", tickfont = list(size = 10))
    )
  )

figura_20
remuestreo <- bind_rows(
  modelo_logit$resample %>% mutate(Modelo = "Logit"),
  modelo_knn$resample %>% mutate(Modelo = "KNN")
) %>%
  select(Modelo, Pliegue = Resample, AUC = ROC, Sensibilidad = Sens, Especificidad = Spec) %>%
  pivot_longer(c(AUC, Sensibilidad, Especificidad), names_to = "Métrica", values_to = "Valor") %>%
  mutate(Métrica = factor(Métrica, levels = c("AUC", "Sensibilidad", "Especificidad")))

figura_21 <- plot_ly(height = 500)

for (mod in c("Logit", "KNN")) {
  d <- remuestreo %>% filter(Modelo == mod)
  color <- if (mod == "Logit") col_logit else col_knn
  figura_21 <- figura_21 %>%
    add_boxplot(
      data = d, x = ~Métrica, y = ~Valor, name = mod,
      boxpoints = "all", jitter = 0.4, pointpos = 0,
      marker = list(color = color, size = 6, opacity = 0.8),
      line = list(color = color, width = 2),
      fillcolor = if (mod == "Logit") "rgba(0,49,137,0.12)" else "rgba(216,0,37,0.10)",
      text = ~Pliegue,
      hovertemplate = paste0("<b>", mod, "</b><br>%{text}<br>%{x}: %{y:.4f}<extra></extra>")
    )
}

figura_21 <- figura_21 %>%
  tema_plotly(
    "Figura 21. Estabilidad del desempeño en los 10 pliegues de validación cruzada",
    y_tit = "Valor de la métrica"
  ) %>%
  layout(boxmode = "group")

figura_21
heatmap_confusion <- function(cm, nombre, escala_color, mostrar_barra) {
  m <- cm$table
  z <- matrix(as.numeric(m), nrow = 2)
  prop_real <- sweep(z, 2, colSums(z), "/")
  etiquetas <- c("No supera P75", "Supera P75")
  texto <- matrix(
    paste0("<b>", mil(z), "</b><br>(", pct(prop_real), ")"),
    nrow = 2
  )
  tipo <- matrix(c("Verdadero negativo", "Falso positivo", "Falso negativo", "Verdadero positivo"), nrow = 2)

  plot_ly(
    x = etiquetas, y = etiquetas, z = prop_real,
    type = "heatmap", colorscale = escala_color, zmin = 0, zmax = 1,
    text = texto, texttemplate = "%{text}",
    textfont = list(size = 14),
    customdata = tipo,
    hovertemplate = paste0(
      "<b>", nombre, "</b><br>Real: %{x}<br>Predicción: %{y}",
      "<br>%{customdata}<br>Estudiantes y % de la clase real: %{text}<extra></extra>"
    ),
    showscale = mostrar_barra,
    colorbar = list(title = list(text = "% de la<br>clase real"), tickformat = ".0%", len = 0.8)
  )
}

figura_22 <- subplot(
  heatmap_confusion(cm_logit, "Logit", escala, FALSE),
  heatmap_confusion(cm_knn, "KNN", escala, TRUE),
  shareY = TRUE, margin = 0.06
) %>%
  tema_plotly(
    "Figura 22. Matrices de confusión en la base de prueba",
    b = 150, nota_y = -0.36,
    extra = list(
      list(text = "<b>Logit</b>", x = 0.22, y = 1.06, xref = "paper", yref = "paper",
           showarrow = FALSE, font = list(size = 14, color = col_logit)),
      list(text = paste0("<b>KNN (k = ", k_optimo, ")</b>"), x = 0.78, y = 1.06,
           xref = "paper", yref = "paper", showarrow = FALSE,
           font = list(size = 14, color = col_knn))
    )
  ) %>%
  layout(
    height = 460,
    xaxis = list(title = "Valor real", showgrid = FALSE),
    xaxis2 = list(title = "Valor real", showgrid = FALSE),
    yaxis = list(title = "Predicción", showgrid = FALSE, autorange = "reversed")
  )

figura_22

Los dos modelos llegan a resultados muy parecidos. La exactitud del Logit (65,22%) y la del KNN (64,17%) difieren en 1,05 puntos porcentuales, y sus intervalos de confianza se superponen casi por completo. En discriminación, el Logit obtiene un AUC ligeramente mayor (0,711 frente a 0,696), pero la prueba de DeLong (p < 0,001) indica que, aunque significativa al 5%, la diferencia es de una magnitud muy pequeña. Tampoco hay diferencias en la proporción total de errores (prueba de McNemar, p = 0,065), y ambos modelos asignan la misma clase al 86,7% de los estudiantes. La Figura 21 confirma que el desempeño de los dos es estable entre pliegues, sin señales de sobreajuste.

La diferencia más visible está en el tipo de error que comete cada modelo. El KNN tiene mayor sensibilidad (75,5% frente a 73,6%) y el Logit mayor especificidad (56,9% frente a 52,8%). Sin embargo, la Figura 19 muestra que las dos curvas ROC son muy próximas, con la del Logit ligeramente por encima en casi todo el recorrido. Más que una diferencia en la capacidad de distinguir a los dos grupos, lo que ocurre es que cada modelo opera en un punto distinto de curvas muy parecidas. Con el Logit bastaría con bajar levemente el umbral para obtener la sensibilidad del KNN.

Queda por explicar por qué dos métodos tan distintos, uno paramétrico y otro basado en distancias, llegan a resultados tan parecidos. La respuesta está en los datos más que en los algoritmos. Como se mostró en la sección 4.2.2, los estudiantes se agrupan en apenas 5.593 perfiles distintos, y la mayoría comparte su perfil con compañeros que obtuvieron el resultado contrario. Si se clasificara a cada estudiante según la clase mayoritaria de su perfil, conociendo de antemano el resultado de todos, la exactitud máxima alcanzable en una población balanceada sería de 71,9%. Este valor es optimista, porque al haber muchos perfiles con pocos estudiantes la regla aprovecha particularidades de la propia muestra. Aun así, funciona como un techo para cualquier modelo que use solo estas siete variables, y los dos modelos estimados, con 65,2% y 64,2%, se ubican a pocos puntos de él. La limitación del ejercicio está, por tanto, en la información disponible y no en la técnica de clasificación: un algoritmo más complejo difícilmente mejoraría los resultados sin incorporar nuevas variables.

4.4.1 ¿Qué modelo se recomienda?

Se recomienda el modelo Logit. Su desempeño predictivo es ligeramente superior al del KNN, con mayor exactitud y un AUC significativamente más alto, y en este problema ofrece ventajas que resultan decisivas. La primera es la interpretabilidad. El objetivo del estudio no es solo clasificar, sino entender qué condiciones se asocian con el alto desempeño, y el Logit permite cuantificar cada una mediante odds ratios con su significancia e intervalo de confianza; el KNN, en cambio, entrega una clasificación sin explicar en qué se basa. La segunda es la estabilidad: el Logit no depende de un hiperparámetro como k, cuyo valor óptimo en este caso es poco preciso, ni requiere añadir ruido artificial para resolver los empates entre perfiles idénticos. La tercera es la flexibilidad: sus probabilidades pueden ajustarse a la prevalencia real, y su umbral puede modificarse según el uso que se le quiera dar, de modo que, si se busca, puede alcanzar la misma sensibilidad que ofrece el KNN.

El KNN solo sería preferible si el único interés fuera maximizar la detección de estudiantes de alto desempeño sin necesidad de explicar el resultado, e incluso en ese caso la ventaja es pequeña y puede reproducirse con el Logit. Lo que ambos modelos dejan claro es que, con las variables disponibles, la clasificación alcanza un nivel de acierto moderado. Este punto se retoma en las conclusiones, al evaluar si el estudio logró responder a su objetivo.

5 Conclusiones

El presente estudio buscó identificar las condiciones del hogar, del colegio y del territorio asociadas con que un estudiante del Valle del Cauca supere el percentil 75 nacional en las pruebas Saber 11 para el año 2020, y esto se contrasto mediante dos aproximaciones , y esto se contrasto mediante dos aproximaciones metodológicas: regresión logística ( Logit) y los K vecinos más cercanos (KNN). Los resultados obtenidos nos permitieron extraer conclusiones que no solo resumen, si no que tratan de establecer hasta que punto del desempeño de alto nivel está ligado a condiciones de origen y qué puede y que no puede hacerse con un modelo construido sobre ellas.

Los resultados de ambos modelos fueron bastante similares y muestran un desempeño moderado. Por un lado el modelo Logit obtuvo una exactitud del 62,2% y un AUC de 0,665 y el modelo KNN una exactitud de 61,9% y un AUC de 0,658. Si bien los dos superan de forma significativa al clasificador aleatorio (p<0,001), ninguna de las diferencias entre ellos es concluyente, de hecho, coinciden al clasificar la misma clase al 90,8% de los estudiantes. El verdadero contraste entre ellos aparece al analizar los errores de cada uno. El KNN detecta mejor a los estudiantes de alto desempeño, presentando una sensibilidad de 74,1% frente a 69,1% y el Logit reconoce mejor a quienes no lo superan, con una especificidad de 55,4% frente a 49,7%. Esto coincide con lo que muestran las curvas de ROC , una diferencia que refleja puntos distintos sobre la mima curva y no una mejor capacidad de discriminar.

En esta comparación, se ve que dos métodos distintos logran un resultado muy similar, esto sucede porque aun cuando el algoritmo KNN es sumamente flexible para detectar interacciones complejas con patrones locales, entonces su rendimiento final no logra superar al del modelo aditivo. Con k = 165, el KNN se comporta como una tabla de frecuencias por perfil, lo que permite determinar que la exactitud maxima teórica permitida por los datos en una población balanceada es de 66,1% de exactitud. Además, como ambos modelos logran un 62,2% y 61,9% de exactitud respectivamente, estan cerca de ese techo y como el KNN no supera al modelo aditivo deja claro que el rendimiento está limitado por los propios datos disponibles, y no por fallas dentro del algoritmo.

El hallazgo más contundente de este análisis, es el impacto que tienen las herramientas tecnológicas en el rendimiento académico. Pues tener un computador multiplica por 1,95 las probabilidades de superar el Percentil 75, mientras que tener acceso a internet las aumenta un 1,65. Adicionalmente, aquellos estudiantes que poseen ambas cosas, sus posibilidades son son 3,2 veces mayores que quienes no tiene ninguno de estos recursos. Este resultado coincide con las alertas que emitieron CEPAL y UNESCO (2020), advirtieron al inicio de la pandemia, la virtualidad ampliaría las brechas de acceso, equidad y calidad , y afectaría sobre todo a los mas vulnerables. Aun así, estos coeficiente son asociaciones y no relación causa/efecto, por que los resultados indican que la brecha digital esta estrechamente ligada a la brecha en los resultados, pero no demuestra que solo porque los estudiantes tengan dispositivos o internet, se produzca por sí sola una mejora. El impacto que tiene el estrato, es un resultado discutible, debido a que si solo tomamos el análisis descriptivo, la proporción de estudiantes que supera el Percentil 75, casi se duplica entre los estratos 1 y 3. Sin embargo, cuando se aplica el modelo las probabilidades entre los estratos 2 y 3 pasan a ser apenas de 1,21 y 1,27 veces lo del estrato 1. Esta reducción, nos indica que, aunque el estrato describe una desigualdad visible, los mecanismos reales a través de los cuales se hacen visisbles esas desventajas, no se debe al estrato en sí, sino a las condiciones materiales y pedagógicas que se ofrence el hogar y el colegio.

Por otra parte, la desigualdad no solo se limita a ingresos, si no también es territorial y de género. De un lado, encontramos que estudiar en un colegio urbano aumenta las probabilidades de superar el Percentil 75, evidenciando que exite una brecha dentro de la calidad educativa entre las zonas rurales y urbanas. A nivel educativo municipal, la diferencias no son muy distintas de la referencia (Cali), con excepción de Buenaventura que refleja una crisis estructural propio del Pacífico vallecaucano. Es asi, que se observa que la desigualdad ocurre tanto por condiciones en el hogar, colegio y por dinamicas dentro de los territorios. Adicionalmente, los hombres tienen un desempeño mayor que las mujeres, lo que revela una brecha de género persistente.

El alcanze real del modelo utilizado es estadisticamente significativo por el gran volumen de datos, cerca de 10.000 casos, pero su capacidad predictiva real es muy baja. Se denota que las condiciones del hogar y del territorio influyen, pero no determinan que el estudiante supere el Percentil 75, pues muchos estudiantes con perfiles ventajosos fallan y otros con entornos vulnerables destacan. Ciertamente, al aplicar el modelo a la población real, su precisión se desploma, resultando que de cada diez estudiantes que etiqueta como “alto desempeño”, solo tres logran serlo en realidad.

5.1 ¿Logro el estudio responder al objetivo de investigación?

Los modelos implementados lograron cumplir con el objetivo de manera parcial. En la parte explicativa, el modelo permitió identificar, cuantificar y jerarquizar que condiciones se asocian con poder superar el Percentil 75, además, demostró que gran parte de la desigualdad por estrato socioeconómico, en realidad se da por estas variables concretas. En la parte comparativa, se contrastaron aproximaciones paramétricas y no paramétricas utilizando las mismas variables, particiones y métodos de validación, con pruebas formales, también se justificó la elección del Logit, con argumentos interpretables, estables y flexibles, ya que su predicción es equivalente y su umbral se puede ajustar al uso. En la parte predictiva, el objetivo es limitado, pues con un AUC de 0,66 indica que el modelo si ordena correctamente a una pareja de estudiantes ( uno que supera el P75 y otro que no) en dos de cada tres comparaciones, por lo que sirve para caracterizar grupos y orientar recursos, pero no posee la precisión necesaria para etiquetar o clasificar a estudiantes individualmente.

Finalmente, se concluye que el estudio muestra que las desigualdades en el alto rendimiento académico en el Valle del Cauca, siguen un patrón estructural. La inequidad se organiza en torno al acceso a tecnología, al tipo de colegio y a la ubicación de ciertos territorios. Pero también muestra que esas condiciones, por sí solas, no permiten anticipar un resultado para cada estudiante. El valor de los modelos no radica en predecir con exactitud quién logrará superar el Percentil 75, si no en orientar la mirada y los recursos hacia dónde se concentran las brechas.

RECOMENDACIÓN: Con base en los hallazgos, se propone las siguientes estrategias: primero a nivel del modelo y por su baja precisión individual, el modelo debe usarse solo para identificar grupos vulnerables o zonas prioritarias y en cuanto a políticas públicas se debería priorizar acceso a conectividad, en zonas rurales y estratos bajos, además de reforzar la calidad educativa en lugares como Buenaventura.

6 Bibliografias

King, G., & Zeng, L. (2001). Logistic regression in rare events data. Political Analysis, 9(2), 137-163.

James, G., Witten, D., Hastie, T., & Tibshirani, R. (2021). An introduction to statistical learning (2.ª ed.). Springer.

Hosmer, D. W., Lemeshow, S., & Sturdivant, R. X. (2013). Applied logistic regression (3.ª ed.). Wiley

CEPAL, & UNESCO. (2020). La educación en tiempos de la pandemia de COVID-19 (Informe COVID-19 CEPAL-UNESCO). Comisión Económica para América Latina y el Caribe; Oficina Regional de Educación para América Latina y el Caribe de la UNESCO. https://unesdoc.unesco.org/ark:/48223/pf0000374075

Paredes, D. (2024). Aprendizaje supervisado. https://bookdown.org/dparedesi/data-science-con-r/aprendizaje-supervisado.html

ICFES. (2020). Resultados Saber 11 Base de datos. Instituto Colombiano para la Evaluación de la Educación. https://www.icfes.gov.co

Fundación Empresarios por la Educación. (s. f.). Resultados de las pruebas Saber 11° 2020 https://fundacionexe.org.co/wp-content/uploads/2024/03/Resultados-de-las-pruebas-Saber-11°-2020.pdf

Redacción Educación. (2021, 19 de marzo). Resultados de Saber11 evidencian consecuencias a la educación por la pandemia. El Espectador. https://www.elespectador.com/educacion/resultados-de-saber11-evidencian-consecuencias-a-la-educacion-por-la-pandemia-article/

Universidad Icesi, Observatorio de Realidades Educativas. (s. f.). Informe Saber 11 para El Dovio (Valle del Cauca). https://repository.icesi.edu.co/biblioteca_digital/bitstream/10906/87145/3/Libro_ORE_Saber11_Dovio.pdf

Chica Gómez, S. M., Galvis Gutiérrez, D. M., & Ramírez Hassan, A. (2010). Determinantes del rendimiento académico en Colombia: pruebas ICFES Saber 11°, 2009. Revista Universidad EAFIT, 46(160), 48–72. https://www.redalyc.org/pdf/215/21520989005.pdf

Raigoso-Espinosa, J. D. (2025). Análisis de los determinantes del rendimiento académico en matemáticas para los estudiantes de Quindío, Colombia, a partir de las pruebas Saber 11. Revista de Investigación, Desarrollo e Innovación, 15(1), 101–118. https://revistas.uptc.edu.co/index.php/investigacion_duitama/article/download/19182/15516/93392

Isaza Betancourth, N. A. (s. f.). Explicando la diferencia urbano-rural en el rendimiento educativo en el Valle del Cauca [Trabajo de grado, Universidad Icesi]. Repositorio Institucional Universidad Icesi. https://repository.icesi.edu.co/bitstreams/b6fc285e-ac16-4ad3-8c2f-8637f39c758a/download

Alonso, J. C., Urbano, C. C., & Chavarriaga Antonio, V. (s. f.). Calidad de la educación media en Jamundí tras dos años de pandemia: Resultados del Examen Saber 11 – 2021. Observatorio de Realidades Educativas, Universidad Icesi. https://repository.icesi.edu.co/server/api/core/bitstreams/531c4c54-f364-4d44-a38e-366fec120413/content