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

1 Planteamiento del problema

El rendimiento académico es un fenómeno multicausal. Suele abordarse desde los hábitos de estudio individuales; sin embargo, la disponibilidad de datos abiertos permite examinarlo desde una dimensión complementaria y de mayor alcance: el contexto territorial en el que se ubica la institución educativa.

Pregunta de investigación: ¿cómo se distribuye el puntaje promedio global en las pruebas Saber 11 entre las Instituciones Educativas Oficiales de Santiago de Cali, y qué diferencias se observan según su ubicación geográfica y su evolución en el periodo 2016-2025?

1.1 Objetivos

General. Analizar el comportamiento del puntaje promedio global obtenido por las Instituciones Educativas Oficiales de Santiago de Cali en las pruebas Saber 11 durante 2016-2025, mediante tablas de frecuencia e indicadores de estadística descriptiva.

Específicos.

  1. Caracterizar la distribución de los registros según comuna y zona geográfica.
  2. Describir el puntaje promedio global mediante medidas de tendencia central, dispersión y forma.
  3. Comparar el desempeño entre zonas urbanas y rurales, y entre zonas educativas.
  4. Identificar la tendencia del puntaje a lo largo del periodo estudiado.

2 Descripción de la base de datos

Fuente: Portal de Datos Abiertos de la Alcaldía de Santiago de Cali. Cobertura: Santiago de Cali, 2016-2025. Unidad de análisis: institución educativa observada en un año determinado. Registros: 916. Variables: 7. Instituciones distintas: 99.

Variable (nombre en el archivo) Tipo Escala
Código DANE Cualitativa Nominal
Nombre Institución Educativa Cualitativa Nominal
Comuna Cualitativa Nominal
Zona Cualitativa Nominal
Zona Educativa Cualitativa Nominal
Año aplicación Cuantitativa Intervalo
Promedio Cuantitativa Razón

Nota sobre la variable Comuna. Aunque se registra con números, es una variable cualitativa nominal: el número identifica un territorio, no una cantidad. Los códigos 1 a 21 corresponden a comunas urbanas y los códigos 51 a 65 a corregimientos rurales. Por eso en R debe convertirse a factor.

3 Carga y depuración de los datos

datos <- readxl::read_excel("C:\\Users\\EXITO\\OneDrive - PUJ Cali\\Escritorio\\Universidad\\2026 - 2\\Teoría de probabilidades\\Taller 112\\Base_de_datos_promedios-saber11global.xlsx", 
sheet = "promedios-saber-11-global")

names(datos)
## [1] "Código DANE"                  "Nombre Institución Educativa"
## [3] "Comuna"                       "Zona"                        
## [5] "Zona Educativa"               "Año aplicación"              
## [7] "Promedio"
dim(datos)
## [1] 916   7
datos <- datos %>%
  rename(
    codigo_dane = `Código DANE`,
    institucion = `Nombre Institución Educativa`,
    comuna      = Comuna,
    zona        = Zona,
    zona_edu    = `Zona Educativa`,
    anio        = `Año aplicación`,
    promedio    = Promedio
  ) %>%
  mutate(
    zona_edu    = factor(trimws(zona_edu)),
    zona        = factor(zona),
    codigo_dane = as.character(codigo_dane),
    comuna      = factor(comuna, levels = sort(unique(comuna))),
    promedio    = as.numeric(promedio)
  )

glimpse(datos)
## Rows: 916
## Columns: 7
## $ codigo_dane <chr> "176001007166", "176001007166", "176001032055", "176001032…
## $ institucion <chr> "Institucion Educativa Tecnico Industrial Jose Maria Carbo…
## $ comuna      <fct> 10, 10, 17, 17, 10, 10, 19, 10, 51, 17, 19, 19, 17, 17, 51…
## $ zona        <fct> Urbana, Urbana, Urbana, Urbana, Urbana, Urbana, Urbana, Ur…
## $ zona_edu    <fct> Sur-Oriente, Sur-Oriente, Sur, Sur, Sur-Oriente, Sur-Orien…
## $ anio        <dbl> 2024, 2025, 2025, 2024, 2022, 2023, 2017, 2020, 2023, 2023…
## $ promedio    <dbl> 317, 316, 316, 311, 308, 305, 303, 303, 302, 300, 300, 299…

3.1 Verificación de la calidad de los datos

# 1. Valores faltantes
colSums(is.na(datos)) %>%
  kable(col.names = "Datos faltantes") %>%
  kable_styling(full_width = FALSE)
Datos faltantes
codigo_dane 0
institucion 0
comuna 0
zona 0
zona_edu 0
anio 0
promedio 0
# 2. Registros duplicados (una institución no debería repetirse en un mismo año)
sum(duplicated(datos[, c("codigo_dane", "anio")]))
## [1] 0
# 3. Inconsistencia detectada en los nombres de las instituciones
n_distinct(datos$codigo_dane)   # 99 códigos
## [1] 99
n_distinct(datos$institucion)   # 189 nombres
## [1] 189

Hallazgo importante. Existen 99 códigos DANE pero 189 nombres distintos de institución. La causa es el uso inconsistente de tildes en el archivo original (“Institucion Educativa Tecnico Industrial” frente a “Institución Educativa Técnico Industrial”). Por esta razón, el identificador válido de institución es el Código DANE, no el nombre. es una limitación de la fuente.

# --- Formato numérico con coma decimal (estilo español) ---
pct <- function(v) paste0(formatC(v, format = "f", digits = 2, decimal.mark = ","), " %")

# --- Tabla de frecuencias para variables CUALITATIVAS (3 columnas) ---
# c1: categoría | c2: frecuencia absoluta | c3: frecuencia relativa
tabla_cualitativa <- function(x) {
  fi <- table(x)
  data.frame(
    Categoria = names(fi),
    fi        = as.numeric(fi),
    fr        = 100 * as.numeric(fi) / sum(fi),
    stringsAsFactors = FALSE
  ) %>% arrange(desc(fi))
}

mostrar_cualitativa <- function(tab, nombre_var, titulo) {
  disp <- data.frame(
    c1 = c(tab$Categoria, "Total"),
    c2 = c(as.character(tab$fi), as.character(sum(tab$fi))),
    c3 = c(pct(tab$fr), "100,00 %"),
    stringsAsFactors = FALSE
  )
  kable(disp, caption = titulo, align = c("l", "r", "r"),
        col.names = c(nombre_var, "frecuencia absoluta", "frecuencia relativa")) %>%
    kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) %>%
    row_spec(0, background = "#1F6FA8", color = "white") %>%
    row_spec(nrow(disp), bold = TRUE, background = "#AED6F1")
}

4 Análisis de variables cualitativas

4.1 Variable 1: Zona geográfica

tab_zona <- tabla_cualitativa(datos$zona)
mostrar_cualitativa(tab_zona, "Zona",
                    "Tabla 1. Distribución de registros según zona geográfica")
Tabla 1. Distribución de registros según zona geográfica
Zona frecuencia absoluta frecuencia relativa
Urbana 776 84,72 %
Rural 140 15,28 %
Total 916 100,00 %
# Variable NOMINAL -> diagrama circular
ggplot(tab_zona, aes(x = "", y = fi, fill = Categoria)) +
  geom_col(width = 1, color = "white") +
  coord_polar(theta = "y") +
  geom_text(aes(label = paste0(Categoria, "\n", pct(fr))),
            position = position_stack(vjust = 0.5), size = 4, color = "white",
            fontface = "bold") +
  scale_fill_manual(values = c("Rural" = "#5F9E5F", "Urbana" = "#1F6FA8")) +
  labs(title = "Distribución de registros según zona geográfica",
       fill = "Zona") +
  theme_void() + theme(legend.position = "right",
                       plot.title = element_text(hjust = 0.5, face = "bold"))

Moda: Urbana, con 776 registros (84.72%).

Interpretación: la distribución es fuertemente desbalanceada (aprox. 85% urbana / 15% rural). Predominan las instituciones ubicadas en la zona urbana, mientras que la zona rural representa una proporción menor, esto puede reflejar una mayor concentración de la oferta educativa oficial en el casco urbano. Debido a que los tamaños de ambos grupos son distintos, la comparación de sus resultados debe basarse en medidas como el promedio y la mediana, y no únicamente en las frecuencias.

4.2 Variable 2: Zona educativa

tab_zedu <- tabla_cualitativa(datos$zona_edu)
mostrar_cualitativa(tab_zedu, "Zona educativa",
                    "Tabla 2. Distribución de registros según zona educativa")
Tabla 2. Distribución de registros según zona educativa
Zona educativa frecuencia absoluta frecuencia relativa
Sur-Oriente 210 22,93 %
Sur 156 17,03 %
Centro 150 16,38 %
Nor-Oriente 150 16,38 %
Norte 140 15,28 %
Oriente 110 12,01 %
Total 916 100,00 %
# Variable NOMINAL -> diagrama circular
ggplot(tab_zedu, aes(x = "", y = fi, fill = reorder(Categoria, -fi))) +
  geom_col(width = 1, color = "white") +
  coord_polar(theta = "y") +
  geom_text(aes(label = pct(fr)), position = position_stack(vjust = 0.5),
            size = 3.5, color = "white", fontface = "bold") +
  scale_fill_brewer(palette = "Blues", direction = -1) +
  labs(title = "Distribución de registros según zona educativa",
       fill = "Zona educativa") +
  theme_void() + theme(legend.position = "right",
                       plot.title = element_text(hjust = 0.5, face = "bold"))

Interpretación. La zona con mayor número de registros es la zona modal. La distribución entre las zonas es desbalanceada, pues algunas concentran muchos más registros que otras. Además, la limpieza de espacios en blanco fue necesaria: sin aplicar trimws(), la base habría mostrado siete categorías en lugar de las seis categorías reales, al contar una misma zona dos veces por tener un espacio al final.

4.3 Variable de contexto: Comuna

tab_comuna <- tabla_cualitativa(datos$comuna) %>%
  arrange(as.numeric(Categoria)) %>%
  mutate(Tipo = ifelse(as.numeric(Categoria) >= 51,
                       "Corregimiento (rural)", "Comuna urbana"))

disp_comuna <- data.frame(
  c1 = c(tab_comuna$Categoria, "Total"),
  c2 = c(tab_comuna$Tipo, ""),
  c3 = c(as.character(tab_comuna$fi), as.character(sum(tab_comuna$fi))),
  c4 = c(pct(tab_comuna$fr), "100,00 %"),
  stringsAsFactors = FALSE
)

kable(disp_comuna, caption = "Tabla 3. Distribución de registros por comuna",
      align = c("l", "l", "r", "r"),
      col.names = c("Comuna", "Tipo", "frecuencia absoluta", "frecuencia relativa")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) %>%
  row_spec(0, background = "#1F6FA8", color = "white") %>%
  row_spec(nrow(disp_comuna), bold = TRUE, background = "#AED6F1") %>%
  scroll_box(height = "400px")
Tabla 3. Distribución de registros por comuna
Comuna Tipo frecuencia absoluta frecuencia relativa
1 Comuna urbana 30 3,28 %
2 Comuna urbana 10 1,09 %
3 Comuna urbana 20 2,18 %
4 Comuna urbana 70 7,64 %
5 Comuna urbana 20 2,18 %
6 Comuna urbana 10 1,09 %
7 Comuna urbana 50 5,46 %
8 Comuna urbana 70 7,64 %
9 Comuna urbana 30 3,28 %
10 Comuna urbana 60 6,55 %
11 Comuna urbana 70 7,64 %
12 Comuna urbana 50 5,46 %
13 Comuna urbana 60 6,55 %
14 Comuna urbana 40 4,37 %
15 Comuna urbana 30 3,28 %
16 Comuna urbana 50 5,46 %
17 Comuna urbana 10 1,09 %
18 Comuna urbana 36 3,93 %
19 Comuna urbana 40 4,37 %
20 Comuna urbana 10 1,09 %
21 Comuna urbana 10 1,09 %
51 Corregimiento (rural) 20 2,18 %
52 Corregimiento (rural) 10 1,09 %
53 Corregimiento (rural) 10 1,09 %
54 Corregimiento (rural) 10 1,09 %
55 Corregimiento (rural) 10 1,09 %
56 Corregimiento (rural) 10 1,09 %
57 Corregimiento (rural) 10 1,09 %
58 Corregimiento (rural) 10 1,09 %
59 Corregimiento (rural) 10 1,09 %
60 Corregimiento (rural) 10 1,09 %
63 Corregimiento (rural) 10 1,09 %
64 Corregimiento (rural) 10 1,09 %
65 Corregimiento (rural) 10 1,09 %
Total 916 100,00 %

Nota Al ser una variable nominal, a Comuna le correspondería un diagrama circular. Sin embargo, con 34 categorías un gráfico de este tipo resulta ilegible, pues ninguna porción alcanza el 8% del total. Por esta razón se presenta únicamente la tabla de frecuencias, y el comportamiento territorial se examina gráficamente mediante el mapa de calor de la sección de análisis cruzado.

Interpretación. Los corregimientos aportan diez registros cada uno, lo que corresponde a una institución observada durante diez años. En cambio, las comunas urbanas concentran varias instituciones educativas y, por tanto, acumulan un mayor número de registros. También se observa la ausencia de la comuna 22, debido a que no cuenta con instituciones educativas oficiales en la base de datos.

5 Análisis de variables cuantitativas

5.1 Variable 1: Promedio (puntaje global)

5.1.1 Tabla de frecuencias agrupada

x <- na.omit(datos$promedio)
n <- length(x)

k        <- ceiling(1 + 3.322 * log10(n))          # Regla de Sturges
amplitud <- ceiling((max(x) - min(x)) / k)
limites  <- seq(min(x), min(x) + k * amplitud, by = amplitud)

clases <- cut(x, breaks = limites, right = FALSE, include.lowest = TRUE)
fi     <- as.numeric(table(clases))

# Tabla con las 8 columnas del formato visto en clase
tab_prom <- data.frame(
  id         = seq_along(fi),
  li         = head(limites, -1),                       # límite inferior
  ls         = tail(limites, -1),                       # límite superior
  mc         = (head(limites, -1) + tail(limites, -1)) / 2,   # marca de clase
  fi         = fi,                                      # frecuencia absoluta
  fr         = 100 * fi / n,                            # frecuencia relativa
  Fi         = cumsum(fi),                              # frec. acumulada absoluta
  Fr         = 100 * cumsum(fi) / n                     # frec. acumulada relativa
)

disp_prom <- data.frame(
  c1 = c(as.character(tab_prom$id), ""),
  c2 = c(formatC(tab_prom$li, format = "f", digits = 1, decimal.mark = ","), ""),
  c3 = c(formatC(tab_prom$ls, format = "f", digits = 1, decimal.mark = ","), ""),
  c4 = c(formatC(tab_prom$mc, format = "f", digits = 2, decimal.mark = ","), ""),
  c5 = c(as.character(tab_prom$fi), as.character(n)),
  c6 = c(pct(tab_prom$fr), "100,00 %"),
  c7 = c(as.character(tab_prom$Fi), ""),
  c8 = c(pct(tab_prom$Fr), ""),
  stringsAsFactors = FALSE
)

kable(disp_prom,
      caption = paste0("Tabla 4. Distribución de frecuencias del puntaje promedio global (n = ",
                       n, ", k = ", k, ", amplitud = ", amplitud, ")"),
      align = c("c", "r", "r", "r", "r", "r", "r", "r"),
      col.names = c("id", "límite inferior", "límite superior", "marca de clase",
                    "frecuencia absoluta", "frecuencia relativa",
                    "frecuencia acumulada absoluta", "frecuencia acumulada relativa")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) %>%
  row_spec(0, background = "#1F6FA8", color = "white") %>%
  row_spec(nrow(disp_prom), bold = TRUE, background = "#AED6F1")
Tabla 4. Distribución de frecuencias del puntaje promedio global (n = 916, k = 11, amplitud = 12)
id límite inferior límite superior marca de clase frecuencia absoluta frecuencia relativa frecuencia acumulada absoluta frecuencia acumulada relativa
1 195,0 207,0 201,00 1 0,11 % 1 0,11 %
2 207,0 219,0 213,00 24 2,62 % 25 2,73 %
3 219,0 231,0 225,00 92 10,04 % 117 12,77 %
4 231,0 243,0 237,00 191 20,85 % 308 33,62 %
5 243,0 255,0 249,00 244 26,64 % 552 60,26 %
6 255,0 267,0 261,00 198 21,62 % 750 81,88 %
7 267,0 279,0 273,00 101 11,03 % 851 92,90 %
8 279,0 291,0 285,00 34 3,71 % 885 96,62 %
9 291,0 303,0 297,00 23 2,51 % 908 99,13 %
10 303,0 315,0 309,00 5 0,55 % 913 99,67 %
11 315,0 327,0 321,00 3 0,33 % 916 100,00 %
916 100,00 %

Nota: Para agrupar la variable cuantitativa se utilizó la regla de Sturges. Con 916 registros, esta regla recomienda usar 11 clases, con una amplitud aproximada de 12 puntos. Se eligió este criterio porque ofrece un número de intervalos adecuado para el tamaño de la muestra: permite resumir la distribución de los puntajes sin crear demasiadas categorías ni perder información importante.

5.1.2 Indicadores estadísticos

# --- POSICIÓN ---
minimo  <- min(x);  maximo <- max(x)
cuart   <- quantile(x, c(0.25, 0.50, 0.75))
decil   <- quantile(x, c(0.10, 0.90))
perc    <- quantile(x, c(0.05, 0.95))

# --- CENTRO ---
media       <- mean(x)
mediana     <- median(x)
media_trunc <- mean(x, trim = 0.05)          # media truncada al 5%
rango_medio <- (minimo + maximo) / 2
clase_modal <- paste0("[", tab_prom$li[which.max(tab_prom$fi)], " - ",
                           tab_prom$ls[which.max(tab_prom$fi)], ")")

# --- DISPERSIÓN ---
rango <- maximo - minimo
desv  <- sd(x)
cv    <- 100 * desv / media
ric   <- cuart[3] - cuart[1]

# --- FORMA ---
sigma_p <- sd(x) * sqrt((n - 1) / n)   # desviación poblacional, para los momentos
asim    <- (sum((x - media)^3) / n) / sigma_p^3
curt    <- (sum((x - media)^4) / n) / sigma_p^4 - 3

indicadores <- data.frame(
  Grupo = c(rep("Posición", 8), rep("Centro", 5),
            rep("Dispersión", 5), rep("Forma", 2)),
  Indicador = c("Mínimo", "Máximo", "P5", "D1 (P10)", "Q1 (P25)", "Q2 (P50)",
                "Q3 (P75)", "D9 (P90)",
                "Media", "Mediana", "Clase modal", "Media truncada (5%)", "Rango medio",
                "Rango", "Varianza", "Desviación estándar",
                "Coeficiente de variación (%)", "Rango intercuartílico",
                "Asimetría (Fisher)", "Curtosis (exceso)"),
  Valor = c(minimo, maximo, round(perc[1], 2), round(decil[1], 2),
            round(cuart[1], 2), round(cuart[2], 2), round(cuart[3], 2),
            round(decil[2], 2),
            round(media, 2), round(mediana, 2), clase_modal,
            round(media_trunc, 2), round(rango_medio, 2),
            rango, round(var(x), 2), round(desv, 2), round(cv, 2), round(ric, 2),
            round(asim, 3), round(curt, 3))
)

kable(indicadores, align = c("l", "l", "r"),
      caption = "Tabla 5. Indicadores estadísticos del puntaje promedio global",
      col.names = c("Grupo", "Indicador", "Valor")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) %>%
  row_spec(0, background = "#1F6FA8", color = "white") %>%
  collapse_rows(columns = 1, valign = "top")
Tabla 5. Indicadores estadísticos del puntaje promedio global
Grupo Indicador Valor
Posición Mínimo 195
Máximo 317
P5 222
D1 (P10) 228
Q1 (P25) 238
Q2 (P50) 250
Q3 (P75) 262
D9 (P90) 274
Centro Media 250.87
Mediana 250
Clase modal [243 - 255)
Media truncada (5%) 250.27
Rango medio 256
Dispersión Rango 122
Varianza 347.87
Desviación estándar 18.65
Coeficiente de variación (%) 7.43
Rango intercuartílico 24
Forma Asimetría (Fisher) 0.445
Curtosis (exceso) 0.402

Criterio de selección de los indicadores. No se calcularon todos los indicadores disponibles, sino los pertinentes para esta variable. Se descartaron la media geométrica y la media armónica, apropiadas para tasas de crecimiento y razones (velocidades, rendimientos), no para un puntaje en escala fija. Tampoco se usó la media ponderada, porque la base no ofrece un criterio de ponderación natural: todas las instituciones aportan un promedio ya calculado y ninguna debería pesar más que otra. Se incluyeron en cambio la media truncada al 5% y el rango medio, útiles para verificar la estabilidad de la media frente a los valores extremos.

Interpretación El puntaje promedio es de 250.87 y mediana 250: valores muy parecidos, lo que indica una distribución bastante simétrica y sin distorsión por valores extremos.

Además tenemos un coeficiente de variación de 7.43%: MUY BAJO. Al estar por debajo del 15%, indica que los puntajes son homogéneos entre las instituciones. Este es el hallazgo más interesante del análisis: las instituciones oficiales de Cali rinden de forma relativamente pareja, sin una brecha extrema entre ellas.

Obtenemos una asimetría +0.45: asimetría POSITIVA (leve). La cola se extiende hacia la derecha, es decir, hay un grupo reducido de instituciones que se destacan muy por encima del resto (el máximo es 317 frente a una media de 251), mientras que las de bajo desempeño no se alejan tanto hacia abajo.

La Curtosis es cercana a 0 = +0.41: distribución prácticamente mesocúrtica, muy cercana a la forma normal.

Se observa además que la media truncada al 5% es prácticamente igual a la media aritmética, lo que confirma que los valores extremos no distorsionan el centro de la distribución.

5.1.3 Gráficos

ggplot(data.frame(x), aes(x = x)) +
  geom_histogram(breaks = limites, fill = "#1F6FA8", color = "white") +
  geom_vline(xintercept = media,   color = "red",       linetype = "dashed", linewidth = 1) +
  geom_vline(xintercept = mediana, color = "darkgreen", linetype = "dotted", linewidth = 1) +
  labs(title = "Histograma del puntaje promedio global",
       subtitle = "Línea roja discontinua: media | Línea verde punteada: mediana",
       x = "Puntaje promedio global", y = "Frecuencia") +
  theme_minimal()

ggplot(datos, aes(x = promedio)) +
  geom_density(fill = "#1F6FA8", alpha = 0.45, color = "#1F6FA8", linewidth = 1) +
  geom_vline(xintercept = media, color = "red", linetype = "dashed") +
  labs(title = "Gráfico de densidad del puntaje promedio global",
       subtitle = "Confirma la leve asimetría positiva de la distribución",
       x = "Puntaje promedio global", y = "Densidad") +
  theme_minimal()

# Diagrama de tallos y hojas.
# Se aplica solo a la aplicación de 2025 (n = 92): con los 916 registros de la
# base completa el diagrama resultaría ilegible.
stem(datos$promedio[datos$anio == 2025], scale = 1)
## 
##   The decimal point is 1 digit(s) to the right of the |
## 
##   22 | 35568
##   23 | 011234446689
##   24 | 0111344455777789
##   25 | 011112223333334445557999
##   26 | 00011234445666789
##   27 | 0000122244669
##   28 | 4
##   29 | 3
##   30 | 0
##   31 | 66
ggplot(datos, aes(y = promedio)) +
  geom_boxplot(fill = "#1F6FA8", alpha = 0.7, width = 0.4) +
  labs(title = "Diagrama de cajas y bigotes del puntaje promedio global",
       y = "Puntaje promedio global") +
  theme_minimal() +
  theme(axis.text.x = element_blank(), axis.ticks.x = element_blank())

# Valores atípicos según el criterio de 1.5 * RIC
datos %>%
  filter(promedio > cuart[3] + 1.5 * ric | promedio < cuart[1] - 1.5 * ric) %>%
  select(institucion, comuna, zona, anio, promedio) %>%
  arrange(desc(promedio)) %>%
  kable(caption = "Tabla 6. Registros atípicos",
        col.names = c("Institución", "Comuna", "Zona", "Año", "Promedio")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) %>%
  row_spec(0, background = "#1F6FA8", color = "white")
Tabla 6. Registros atípicos
Institución Comuna Zona Año Promedio
Institucion Educativa Tecnico Industrial Jose Maria Carbonell 10 Urbana 2024 317
Institución Educativa Técnico Industrial José María Carbonell 10 Urbana 2025 316
Institución Educativa Técnico Industrial Comuna 17 17 Urbana 2025 316
Institucion Educativa Tecnico Industrial Comuna 17 17 Urbana 2024 311
Institucion Educativa Tecnico Industrial Jose Maria Carbonell 10 Urbana 2022 308
Institucion Educativa Tecnico Industrial Jose Maria Carbonell 10 Urbana 2023 305
Institucion Educativa Liceo Departamental 19 Urbana 2017 303
Institucion Educativa Tecnico Industrial José Maria Carbonell 10 Urbana 2020 303
Institucion Educativa Tecnica De Ballet Clásico Incolballet 51 Rural 2023 302
Institucion Educativa Tecnico Industrial Comuna 17 17 Urbana 2023 300
Institución Educativa Liceo Departamental 19 Urbana 2025 300
Institucion Educativa Liceo Departamental 19 Urbana 2019 299
Institucion Educativa Tecnico Industrial Comuna 17 17 Urbana 2016 299
Institucion Educativa Navarro 51 Rural 2022 195

5.2 Variable 2: Año de aplicación

tab_anio <- datos %>%
  count(anio, name = "fi") %>%
  mutate(fr = 100 * fi / sum(fi), Fi = cumsum(fi), Fr = 100 * cumsum(fi) / sum(fi))

disp_anio <- data.frame(
  c1 = c(as.character(tab_anio$anio), "Total"),
  c2 = c(as.character(tab_anio$fi), as.character(sum(tab_anio$fi))),
  c3 = c(pct(tab_anio$fr), "100,00 %"),
  c4 = c(as.character(tab_anio$Fi), ""),
  c5 = c(pct(tab_anio$Fr), ""),
  stringsAsFactors = FALSE
)

kable(disp_anio, caption = "Tabla 7. Distribución de registros por año de aplicación",
      align = c("c", "r", "r", "r", "r"),
      col.names = c("Año", "frecuencia absoluta", "frecuencia relativa",
                    "frecuencia acumulada absoluta", "frecuencia acumulada relativa")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) %>%
  row_spec(0, background = "#1F6FA8", color = "white") %>%
  row_spec(nrow(disp_anio), bold = TRUE, background = "#AED6F1")
Tabla 7. Distribución de registros por año de aplicación
Año frecuencia absoluta frecuencia relativa frecuencia acumulada absoluta frecuencia acumulada relativa
2016 91 9,93 % 91 9,93 %
2017 91 9,93 % 182 19,87 %
2018 91 9,93 % 273 29,80 %
2019 91 9,93 % 364 39,74 %
2020 92 10,04 % 456 49,78 %
2021 92 10,04 % 548 59,83 %
2022 92 10,04 % 640 69,87 %
2023 92 10,04 % 732 79,91 %
2024 92 10,04 % 824 89,96 %
2025 92 10,04 % 916 100,00 %
Total 916 100,00 %

Nota. Año de aplicación es una variable cuantitativa de escala de intervalo, por lo que carece de cero absoluto y sus medidas de tendencia central no tienen interpretación sustantiva: afirmar que “la media es 2020.5” no describe ninguna característica del fenómeno. Su valor analítico reside en dos aspectos: verificar el balance de la cobertura temporal y servir como eje para observar la evolución del rendimiento. Por la misma razón, para esta variable no se calculan indicadores de centro, dispersión ni forma: solo se reporta su tabla de frecuencias.

Interpretación. La distribución de registros por año es prácticamente uniforme, con entre 91 y 92 registros anuales, equivalentes aproximadamente al 9.93% y 10.04% del total. Esto indica que la base de datos es un panel balanceado: ningún año está sobrerrepresentado. Por tanto, es válido realizar comparaciones de los puntajes a través del tiempo.

evolucion <- datos %>%
  group_by(anio) %>%
  summarise(n = n(),
            Media   = round(mean(promedio), 2),
            Mediana = round(median(promedio), 2),
            `Desv. est.` = round(sd(promedio), 2))

kable(evolucion, caption = "Tabla 8. Indicadores del puntaje por año",
      col.names = c("Año", "n", "Media", "Mediana", "Desv. est.")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) %>%
  row_spec(0, background = "#1F6FA8", color = "white")
Tabla 8. Indicadores del puntaje por año
Año n Media Mediana Desv. est.
2016 91 258.38 257.0 17.40
2017 91 254.99 254.0 19.02
2018 91 250.63 251.0 17.58
2019 91 247.51 247.0 18.54
2020 92 246.76 245.5 18.14
2021 92 244.09 243.0 17.32
2022 92 248.01 246.0 19.31
2023 92 250.89 250.5 18.84
2024 92 252.29 252.0 18.41
2025 92 255.20 253.0 17.93
# Cajas y bigotes por año + diagrama de puntos con la media de cada año (rojo)
ggplot(datos, aes(x = factor(anio), y = promedio)) +
  geom_boxplot(fill = "#1F6FA8", alpha = 0.55, outlier.size = 1) +
  stat_summary(fun = mean, geom = "point", color = "red", size = 2.5) +
  labs(title = "Distribución del puntaje promedio global por año, 2016-2025",
       subtitle = "Los puntos rojos señalan la media de cada año",
       x = "Año de aplicación", y = "Puntaje promedio global") +
  theme_minimal()

Interpretación: Aquí está el hallazgo más contundente del informe, es una coincidencia temporal. Este gráfico denota un forma de V: porque desciende de 258.4 en 2016 hasta un mínimo de 244.1 en 2021, y luego se recupera de manera sostenida hasta 255.2 en 2025. El mínimo coincide con la promoción que cursó la media vocacional durante el cierre de colegios por la pandemia de COVID-19.

6 Análisis cruzado

ggplot(datos, aes(x = zona, y = promedio, fill = zona)) +
  geom_boxplot(alpha = 0.7, width = 0.5) +
  scale_fill_manual(values = c("Rural" = "#5F9E5F", "Urbana" = "#1F6FA8")) +
  labs(title = "Puntaje promedio global según zona geográfica",
       x = "Zona", y = "Puntaje") +
  theme_minimal() + theme(legend.position = "none")

datos %>%
  group_by(zona) %>%
  summarise(n = n(),
            Media = round(mean(promedio), 2),
            Mediana = round(median(promedio), 2),
            `Desv. est.` = round(sd(promedio), 2),
            `CV (%)` = round(100 * sd(promedio) / mean(promedio), 2)) %>%
  kable(caption = "Tabla 9. Puntaje según zona geográfica") %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) %>%
  row_spec(0, background = "#1F6FA8", color = "white")
Tabla 9. Puntaje según zona geográfica
zona n Media Mediana Desv. est. CV (%)
Rural 140 247.29 246.5 19.95 8.07
Urbana 776 251.51 251.0 18.35 7.29
datos %>%
  group_by(zona_edu) %>%
  summarise(n = n(),
            Media = round(mean(promedio), 2),
            Mediana = round(median(promedio), 2),
            `Desv. est.` = round(sd(promedio), 2),
            `CV (%)` = round(100 * sd(promedio) / mean(promedio), 2)) %>%
  arrange(desc(Media)) %>%
  kable(caption = "Tabla 10. Puntaje según zona educativa",
        col.names = c("Zona educativa", "n", "Media", "Mediana", "Desv. est.", "CV (%)")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) %>%
  row_spec(0, background = "#1F6FA8", color = "white")
Tabla 10. Puntaje según zona educativa
Zona educativa n Media Mediana Desv. est. CV (%)
Norte 140 258.14 258.5 19.10 7.40
Nor-Oriente 150 253.73 255.0 13.84 5.46
Centro 150 252.72 252.5 15.90 6.29
Sur-Oriente 210 251.15 250.0 18.92 7.53
Sur 156 250.35 248.5 22.26 8.89
Oriente 110 235.37 235.5 11.41 4.85
ggplot(datos, aes(x = reorder(zona_edu, promedio, FUN = median), y = promedio,
                  fill = zona_edu)) +
  geom_boxplot(alpha = 0.75) +
  coord_flip() +
  labs(title = "Puntaje promedio global según zona educativa",
       x = "Zona educativa", y = "Puntaje") +
  theme_minimal() + theme(legend.position = "none")

# Gráfico de radar: perfil comparativo del puntaje medio por zona educativa
if (requireNamespace("fmsb", quietly = TRUE)) {
  medias_ze <- round(tapply(datos$promedio, datos$zona_edu, mean), 1)
  df_radar  <- as.data.frame(rbind(rep(265, length(medias_ze)),
                                   rep(230, length(medias_ze)),
                                   medias_ze))
  colnames(df_radar) <- names(medias_ze)

  fmsb::radarchart(df_radar, axistype = 1,
                   pcol = "#1F6FA8", pfcol = scales::alpha("#1F6FA8", 0.4), plwd = 2,
                   cglcol = "grey80", cglty = 1, axislabcol = "grey40",
                   caxislabels = seq(230, 265, 7), cglwd = 0.8, vlcex = 0.9,
                   title = "Puntaje medio por zona educativa")
} else {
  message("Para ver el gráfico de radar instala el paquete: install.packages('fmsb')")
}

# Mapa de calor: puntaje medio por comuna y año
datos %>%
  group_by(comuna, anio) %>%
  summarise(media = mean(promedio), .groups = "drop") %>%
  ggplot(aes(x = factor(anio), y = comuna, fill = media)) +
  geom_tile(color = "white", linewidth = 0.3) +
  scale_fill_gradient2(low = "#C0392B", mid = "#F7F7B0", high = "#1F6FA8",
                       midpoint = mean(datos$promedio), name = "Puntaje\nmedio") +
  labs(title = "Mapa de calor del puntaje medio por comuna y año",
       subtitle = "Azul: por encima del promedio general | Rojo: por debajo",
       x = "Año de aplicación", y = "Comuna") +
  theme_minimal() + theme(panel.grid = element_blank())

Interpretación. La diferencia entre los puntajes promedio de las zonas urbana y rural es cercana a 4 puntos: aproximadamente 251.5 para la zona urbana y 247.3 para la rural. Esta brecha es menor de lo que podría suponerse inicialmente.

Sin embargo, las diferencias entre las zonas educativas son más amplias. La zona Norte alcanza un promedio cercano a 258 puntos, mientras que Oriente registra alrededor de 235 puntos, lo que representa una brecha aproximada de 23 puntos. Además, Oriente presenta la menor desviación estándar: aunque su promedio es más bajo, sus instituciones muestran resultados relativamente homogéneos. Esto evidencia que la desigualdad más marcada se presenta entre zonas educativas y no necesariamente entre el área urbana y rural.

Interpretación. El mapa de calor muestra que la comuna 17 se mantiene de forma consistente en tonos azules durante todo el periodo, un poco parecido están las comunas 2 y 10 lo que indica puntajes promedio superiores al promedio general. Por el contrario, las comunas 14, 15 y 20 aparecen principalmente en tonos rojos o anaranjados, asociados con puntajes más bajos.

Este patrón se mantiene a través de los años, por lo que la brecha territorial no parece ser un fenómeno de un año específico, sino una diferencia persistente entre comunas.

7 Conclusiones

  1. La pandemia coincidió con el peor desempeño de la década. El puntaje promedio cayó de forma sostenida desde 258,4 puntos en 2016 hasta un mínimo de 244,1 en 2021, año que corresponde a la promoción que cursó parte de su formación bajo el cierre de colegios por COVID-19. Para 2025 el puntaje se recuperó hasta 255,2, un nivel que se acerca al de 2016 pero que todavía no lo alcanza, lo que sugiere un efecto persistente más que un episodio aislado.

  2. La brecha territorial más fuerte no es urbano-rural, sino entre zonas educativas. Mientras que la diferencia entre zona urbana (251,5) y zona rural (247,3) es de apenas 4 puntos, la zona Oriente registra un promedio de 235 puntos frente a los 258 de la zona Norte: una brecha de 23 puntos, casi seis veces mayor que la diferencia urbano-rural. Oriente es, además, la zona con menor desviación estándar, lo que indica que su bajo desempeño es generalizado entre sus instituciones y no producto de unos pocos colegios.

  3. Los puntajes más altos son los que la base identifica como atípicos. Los registros que superan el límite de 1,5 veces el rango intercuartílico corresponden a instituciones con los promedios más altos, no a valores por debajo del rango normal. Esto confirma la asimetría positiva de la distribución (+0,45): existe un grupo reducido de colegios que se despega claramente por encima del resto, mientras que hacia abajo los puntajes no se alejan tanto de la media.

  4. La media y la mediana son prácticamente idénticas (250,87 frente a 250), lo que indica que la distribución del puntaje global es simétrica y que su centro no está siendo distorsionado por valores extremos. Este resultado es consistente con el coeficiente de variación de apenas 7,43%, muy por debajo del 15% que suele considerarse el umbral de homogeneidad.

  5. La diferencia entre zona rural y urbana es real pero moderada. Los 4,2 puntos de diferencia entre ambas zonas (251,5 frente a 247,3) muestran que la ubicación geográfica influye en el desempeño, pero en una magnitud mucho menor de lo que suele suponerse; la brecha entre zonas educativas (punto 2) es un factor más determinante que la simple condición urbana o rural.

  6. La base de datos está balanceada temporalmente, lo que respalda las comparaciones anteriores. Cada año aporta entre 91 y 92 registros (entre 9,93% y 10,04% del total), por lo que ningún año pesa más que otro en los promedios generales. Esto es relevante porque garantiza que la caída y posterior recuperación descritas en la conclusión 1 no son un artefacto de un año con más o menos instituciones reportando datos.

8 Limitaciones del estudio

  1. La base registra el promedio institucional, no puntajes individuales, por lo que no permite inferencias sobre estudiantes.
  2. Los nombres de las instituciones presentan inconsistencias de tildes en la fuente original.
  3. El análisis es solo descriptivo: describe diferencias entre grupos, pero no permite establecer relaciones de causalidad.
  4. No se dispone de variables socioeconómicas ni de infraestructura que ayuden a explicar las diferencias territoriales observadas.

9 Referencias

  • Alcaldía de Santiago de Cali. (s. f.). Promedios Saber 11 global [Conjunto de datos]. Portal de Datos Abiertos.
  • Instituto Colombiano para la Evaluación de la Educación (ICFES).