library(readxl)
library(dplyr)
library(ggplot2)
library(knitr)
library(kableExtra)
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?
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.
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.
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…
# 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")
}
tab_zona <- tabla_cualitativa(datos$zona)
mostrar_cualitativa(tab_zona, "Zona",
"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.
tab_zedu <- tabla_cualitativa(datos$zona_edu)
mostrar_cualitativa(tab_zedu, "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.
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")
| 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.
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")
| 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.
# --- 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")
| 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.
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")
| 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 |
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")
| 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")
| 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.
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")
| 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")
| 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.
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.
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.
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.
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.
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.
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.