1 Como leer este documento

Este informe responde una sola pregunta a lo largo de todo el analisis:

La educacion de la madre, ¿aporta mas al puntaje de Saber 11 que la educacion del padre? Y si es asi, ¿esa ventaja es real o es solo una apariencia estadistica?

Para responderla con seriedad no basta una regresion. Se construye una escalera de modelos: cada peldano es mas exigente que el anterior y descarta una explicacion alternativa distinta. Si la ventaja materna sobrevive a todos los peldanos, la conclusion es solida.

Cada seccion sigue la misma estructura para que se entienda de una sola lectura:

  • Que hace el modelo y en que se diferencia del anterior.
  • Por que es pertinente (que amenaza descarta).
  • Hipotesis que pone a prueba.
  • Tabla de resultados con formato claro.
  • Conclusion que interpreta los numeros en lenguaje sencillo.

La variable central es la brecha madre menos padre: cuantos puntos mas (o menos) suma un anio de escolaridad de la madre frente a uno del padre. Si la brecha es positiva y significativa, hay ventaja materna.


2 Paso 0. Preparacion del entorno

Que se hace aqui. Se cargan los paquetes, se fija una semilla aleatoria (para que cualquiera reproduzca exactamente el resultado) y se ajusta la configuracion de memoria. Este paso no produce resultados sustantivos: es la base tecnica.

suppressPackageStartupMessages({
  library(data.table); library(fixest); library(stringi)
  library(ggplot2); library(openxlsx)
})
# Un solo hilo minimiza el pico de memoria. Con 16 GB o mas se puede subir a 2.
setFixest_nthreads(1)
setFixest_notes(FALSE)

SEMILLA <- 20260728
set.seed(SEMILLA)
TABLAS <- list()
AZUL <- "#2C6FBB"; NARANJA <- "#E8862A"; GRIS <- "#7F7F7F"; ROJO <- "#C0392B"

ENTORNO <- data.table(
  Parametro = c("Fecha","R","Plataforma","data.table","fixest","Semilla"),
  Valor = c(format(Sys.time(), "%Y-%m-%d %H:%M"), R.version.string,
            Sys.info()[["sysname"]], as.character(packageVersion("data.table")),
            as.character(packageVersion("fixest")), as.character(SEMILLA)))
tabla(ENTORNO, "Entorno de ejecucion y version de los paquetes")
Entorno de ejecucion y version de los paquetes
Parametro Valor
Fecha 2026-07-31 21:26
R R version 4.6.1 (2026-06-24 ucrt)
Plataforma Windows
data.table 1.18.4
fixest 0.14.2
Semilla 20260728

3 Configuracion: como se mide la educacion

El problema. El ICFES no reporta “anios de estudio” sino categorias de texto (“PRIMARIA COMPLETA”, “POSTGRADO”). Para tratar la educacion como una variable numerica hay que traducir cada categoria a un numero de anios.

La solucion. Se usa la equivalencia de la Clasificacion Internacional Normalizada de la Educacion (ISCED, UNESCO) adaptada a Colombia. Es un estandar internacional, lo que hace la decision transparente y defendible ante un evaluador.

MAPA_PRINCIPAL <- c(
  "NINGUNO" = 0, "PRIMARIA INCOMPLETA" = 3, "PRIMARIA COMPLETA" = 5,
  "SECUNDARIA (BACHILLERATO) INCOMPLETA" = 8, "SECUNDARIA (BACHILLERATO) COMPLETA" = 11,
  "TECNICA O TECNOLOGICA INCOMPLETA" = 12, "TECNICA O TECNOLOGICA COMPLETA" = 14,
  "EDUCACION PROFESIONAL INCOMPLETA" = 13, "EDUCACION PROFESIONAL COMPLETA" = 16,
  "POSTGRADO" = 18)

NO_INFORMATIVAS <- c("NO SABE","NO APLICA","SIN INFORMACION","SIN_INFORMACION","")

# Tabla legible de la equivalencia usada
mapa_dt <- data.table(
  `Categoria ICFES` = names(MAPA_PRINCIPAL),
  `Anios asignados` = as.integer(MAPA_PRINCIPAL),
  `Nivel ISCED` = c("0","1 parcial","1","2","3","4 parcial","5","6 parcial","6","7 y 8"))
tabla(mapa_dt, "Traduccion de categorias educativas a anios de escolaridad (ISCED-UNESCO)")
Traduccion de categorias educativas a anios de escolaridad (ISCED-UNESCO)
Categoria ICFES Anios asignados Nivel ISCED
NINGUNO 0 0
PRIMARIA INCOMPLETA 3 1 parcial
PRIMARIA COMPLETA 5 1
SECUNDARIA (BACHILLERATO) INCOMPLETA 8 2
SECUNDARIA (BACHILLERATO) COMPLETA 11 3
TECNICA O TECNOLOGICA INCOMPLETA 12 4 parcial
TECNICA O TECNOLOGICA COMPLETA 14 5
EDUCACION PROFESIONAL INCOMPLETA 13 6 parcial
EDUCACION PROFESIONAL COMPLETA 16 6
POSTGRADO 18 7 y 8

4 Paso 1. Localizacion de los datos

Que se hace. El codigo busca automaticamente los archivos de Saber 11 en la carpeta indicada. Es el unico lugar que debes editar: la ruta CARPETA_DATOS.

CARPETA_DATOS  <- "C:/Users/andres.f.penaranda/Downloads/OneDrive_1_7-25-2026"
CARPETA_SALIDA <- CARPETA_DATOS
if (!dir.exists(CARPETA_DATOS)) stop("La carpeta no existe. Edita la ruta en el Paso 1.")
archivos <- list.files(CARPETA_DATOS, pattern = "\\.(csv|txt|tsv)$",
                       recursive = TRUE, full.names = TRUE, ignore.case = TRUE)
if (length(archivos) == 0) stop("No se encontraron archivos de datos.")
inventario <- data.table(Archivo = basename(archivos),
                         MB = round(file.info(archivos)$size / 1e6, 1))
TABLAS[["A_inventario"]] <- inventario
CACHE <- file.path(CARPETA_SALIDA, "_saber11_consolidado.rds"); RECONSTRUIR_CACHE <- FALSE
tabla(inventario, "Archivos de Saber 11 localizados")
Archivos de Saber 11 localizados
Archivo MB
bitacora_ejecucion.txt 0.0
Examen_Saber_11_20142.txt 249.4
Examen_Saber_11_20151.txt 35.0
Examen_Saber_11_20152.txt 256.1
Examen_Saber_11_20161.txt 26.5
Examen_Saber_11_20162.txt 287.3
Examen_Saber_11_20171.txt 29.8
Examen_Saber_11_20172.txt 415.4
Examen_Saber_11_20181.txt 25.2
Examen_Saber_11_20182.txt 422.5
Examen_Saber_11_20191.txt 26.3
Examen_Saber_11_20192.txt 427.3
Examen_Saber_11_20201.txt 18.3
Examen_Saber_11_20202.txt 399.8
Examen_Saber_11_20211.txt 21.4
Examen_Saber_11_20212.txt 419.8
Examen_Saber_11_20221.txt 27.1
Examen_Saber_11_20222.txt 406.1
Examen_Saber_11_20231.txt 28.1
Examen_Saber_11_20232.txt 418.1
Examen_Saber_11_20241.txt 30.3
Examen_Saber_11_20242.txt 410.1

Conclusion. Se localizan las 21 aplicaciones del examen (dos por anio, salvo casos especiales), desde 2014 hasta 2024. Cada aplicacion es un archivo independiente que a continuacion se unifica.


5 Paso 2. Consolidacion

Que se hace. Se apilan las 21 aplicaciones en una sola tabla. Se guarda una copia en formato .rds (cache) para que las siguientes ejecuciones sean casi instantaneas.

if (!RECONSTRUIR_CACHE && file.exists(CACHE)) {
  df <- readRDS(CACHE)
} else {
  partes <- list()
  for (ruta in archivos) {
    out <- tryCatch(leer_archivo(ruta), error = function(e) e)
    if (!inherits(out, "error")) partes[[length(partes)+1L]] <- out$d
  }
  df <- rbindlist(partes, use.names = TRUE, fill = TRUE); rm(partes); gc()
  saveRDS(df, CACHE)
}
tabla(data.table(Concepto = "Total bruto consolidado",
                 Registros = format(nrow(df), big.mark = ",")),
      "Volumen total de la base antes de depurar")
Volumen total de la base antes de depurar
Concepto Registros
Total bruto consolidado 7,239,505

Conclusion. La base bruta reune mas de siete millones de registros de estudiantes. Es el universo completo antes de aplicar cualquier filtro de calidad.


6 Paso 3. Auditoria de calidad

Que se hace. Antes de tocar los datos, se revisa cada aplicacion: cuantos estudiantes tiene, cual es el puntaje promedio, minimo y maximo.

Por que es pertinente. Permite detectar archivos corruptos, truncados o con una escala distinta, comparando contra las cifras oficiales del ICFES. Es un control de calidad previo, no un analisis.

pp <- parsear_periodo(df$PERIODO)
df[, ANIO := pp$anio]; df[, APLICACION := pp$apl]
df <- df[!is.na(ANIO)]; df[, ANIO := as.integer(ANIO)]
df[, CALENDARIO := normaliza_serie(CALENDARIO)]
df[is.na(CALENDARIO) | CALENDARIO == "", CALENDARIO := "SIN INFORMACION"]
df[, PUNTAJE := as.numeric(PUNTAJE)]
aud <- df[, .(Registros = .N, `Puntaje medio` = round(mean(PUNTAJE, na.rm=TRUE),1),
              Min = suppressWarnings(min(PUNTAJE,na.rm=TRUE)),
              Max = suppressWarnings(max(PUNTAJE,na.rm=TRUE))),
          by = .(ANIO, APLICACION)][order(ANIO, APLICACION)]
AUD_BRUTO <- df[, .(registros = .N), by = .(ANIO, APLICACION)]
TABLAS[["A1_auditoria"]] <- aud
tabla(aud, "Auditoria por anio y aplicacion (antes de filtrar)")
Auditoria por anio y aplicacion (antes de filtrar)
ANIO APLICACION Registros Puntaje medio Min Max
2,014 2 574,259 249.9 0 482
2,015 1 108,259 259.4 0 488
2,015 2 574,158 250.2 0 492
2,016 1 74,224 274.9 0 494
2,016 2 589,593 258.4 0 468
2,017 1 85,149 265.4 10 476
2,017 2 590,996 255.7 0 476
2,018 1 65,854 279.4 0 475
2,018 2 609,136 251.2 0 478
2,019 1 66,104 271.9 9 470
2,019 2 614,789 246.2 0 477
2,020 1 46,221 274.6 0 492
2,020 2 556,891 248.4 0 500
2,021 1 58,708 274.6 14 495
2,021 2 606,030 246.9 0 500
2,022 1 73,795 267.9 82 500
2,022 2 589,183 250.4 0 500
2,023 1 77,555 262.7 0 478
2,023 2 602,093 252.1 0 500
2,024 1 84,072 265.6 0 496
2,024 2 592,436 253.9 0 495

Conclusion. Todas las aplicaciones lucen plausibles: los promedios se ubican entre 246 y 279 puntos y los maximos no superan la escala 0 a 500 de Saber 11. No hay senales de archivos truncados ni de escalas distintas. Los datos son confiables para el analisis.


7 Paso 4. Limpieza documentada

Que se hace. Se aplican filtros de calidad y se registra cuantos estudiantes se pierden en cada uno (un “diario de perdidas”).

Por que es pertinente. La transparencia exige justificar cada exclusion. El filtro mas importante es eliminar registros sin codigo de colegio, porque ese codigo es indispensable para dos tecnicas centrales: agrupar los errores por colegio y estimar los efectos fijos.

diario <- list()
anotar <- function(p, a, d) diario[[length(diario)+1L]] <<- data.table(
  Filtro = p, `N antes` = a, `N despues` = d, Perdidos = a-d,
  `%` = round(100*(a-d)/max(a,1),3))
CATS <- c("EDU_MADRE","EDU_PADRE","ESTRATO","INTERNET","COMPUTADOR",
          "NATURALEZA","ZONA","JORNADA","SEXO","DEPTO")
for (col in CATS) {
  set(df, j = col, value = normaliza_serie(df[[col]]))
  df[is.na(get(col)) | get(col) %in% c("","NA","NAN","NULL"), (col) := "SIN INFORMACION"]; gc()
}
df[ZONA == "URBANA", ZONA := "URBANO"]
n<-nrow(df); df<-df[!is.na(PUNTAJE)]; anotar("Puntaje nulo", n, nrow(df))
n<-nrow(df); df<-df[PUNTAJE>0];       anotar("Puntaje menor o igual a 0", n, nrow(df))
n<-nrow(df); df<-df[PUNTAJE<=500];    anotar("Puntaje mayor a 500", n, nrow(df))
n<-nrow(df); df<-df[!is.na(COLEGIO)&COLEGIO!=""]; anotar("Sin codigo de colegio", n, nrow(df))
TABLAS[["A2_diario_perdidas"]] <- rbindlist(diario)
tabla(TABLAS[["A2_diario_perdidas"]], "Diario de perdidas: cuantos registros elimina cada filtro")
Diario de perdidas: cuantos registros elimina cada filtro
Filtro N antes N despues Perdidos %
Puntaje nulo 7,239,505 7,239,505 0 0.000
Puntaje menor o igual a 0 7,239,505 7,239,253 252 0.003
Puntaje mayor a 500 7,239,253 7,239,253 0 0.000
Sin codigo de colegio 7,239,253 6,230,935 1,008,318 13.928

Conclusion. Tras la limpieza quedan 6.230.935 estudiantes. La unica perdida relevante es la de registros sin codigo de colegio (cerca del 14 por ciento). Los filtros de puntaje eliminan una fraccion despreciable, lo que confirma la buena calidad del dato original.


8 Paso 5. ¿Se conoce mas la educacion de la madre o la del padre?

Que se hace. Se calcula, por anio, que porcentaje de estudiantes no informa el nivel educativo de cada progenitor.

Por que es pertinente. Es una posible explicacion alternativa. Si el nivel del padre se desconoce mas que el de la madre, parte de la brecha podria deberse a datos faltantes y no a un efecto real. Conviene cuantificarlo para declararlo como limitacion.

Hipotesis nula. La no respuesta es igual para madre y padre.

tasa_ni <- function(col) df[, .(pct = 100*mean(get(col) %in% NO_INFORMATIVAS)), by=ANIO][order(ANIO)]
nm<-tasa_ni("EDU_MADRE"); np<-tasa_ni("EDU_PADRE")
nr <- data.table(Anio = nm$ANIO,
                 `% no informa MADRE` = round(nm$pct,2),
                 `% no informa PADRE` = round(np$pct,2),
                 `Brecha (padre - madre)` = round(np$pct-nm$pct,2))
TABLAS[["A3_no_respuesta"]] <- nr
tabla(nr, "No respuesta del nivel educativo por progenitor")
No respuesta del nivel educativo por progenitor
Anio % no informa MADRE % no informa PADRE Brecha (padre - madre)
2,014 1.87 4.44 2.57
2,015 1.67 4.11 2.44
2,016 2.11 4.94 2.84
2,017 7.03 11.62 4.59
2,018 8.50 13.62 5.11
2,019 7.51 12.76 5.25
2,020 5.45 10.74 5.29
2,021 7.31 12.50 5.19
2,022 8.39 13.85 5.47
2,023 10.77 16.50 5.74
2,024 13.82 19.47 5.64

Conclusion. El nivel del padre se desconoce en promedio 4.56 puntos porcentuales mas que el de la madre, y esa diferencia crece con los anios. Se rechaza la hipotesis nula. Esto debe reportarse como limitacion: introduce una posible fuente de sesgo en la variable paterna.


9 Paso 6. Construccion de las variables clave

Que se hace. Se traduce cada categoria a anios de escolaridad (madre = AM, padre = AP), se estandariza el puntaje dentro de cada anio y se marca que estudiantes tienen informada la educacion de ambos progenitores (casos completos).

df[, AM := as.numeric(MAPA_PRINCIPAL[EDU_MADRE])]
df[, AP := as.numeric(MAPA_PRINCIPAL[EDU_PADRE])]
df[, PUNT_Z := (PUNTAJE - mean(PUNTAJE))/sd(PUNTAJE), by = ANIO]
df[, CASO_COMPLETO := !(EDU_MADRE %in% NO_INFORMATIVAS) & !(EDU_PADRE %in% NO_INFORMATIVAS)]
orden_edu <- names(MAPA_PRINCIPAL)
tabla(data.table(Concepto = "Casos completos (educacion informada de ambos padres)",
                 N = format(sum(df$CASO_COMPLETO), big.mark=","),
                 `%` = round(100*mean(df$CASO_COMPLETO),2)),
      "Cobertura de la informacion educativa")
Cobertura de la informacion educativa
Concepto N %
Casos completos (educacion informada de ambos padres) 5,462,850 87.67

Conclusion. Cerca del 88 por ciento de los estudiantes tiene informada la educacion de ambos padres. Es una cobertura alta que da solidez a los modelos.


10 Paso 7. Retrato descriptivo (antes de cualquier modelo)

Que se hace. Se calcula el puntaje promedio segun el nivel educativo de cada progenitor.

Por que es pertinente. Da la intuicion de fondo antes de controlar por nada. Es correlacion pura, todavia no causalidad.

d_madre <- df[, .(N=.N, Media=round(mean(PUNTAJE),1), Mediana=round(median(PUNTAJE),0),
                  Desv=round(sd(PUNTAJE),1)), by=.(Nivel=EDU_MADRE)]
d_madre[, ord := match(Nivel, orden_edu)][is.na(ord), ord:=99]
d_madre <- d_madre[order(ord)][, ord:=NULL]
TABLAS[["B1_desc_madre"]] <- d_madre
tabla(d_madre, "Puntaje promedio segun educacion de la MADRE")
Puntaje promedio segun educacion de la MADRE
Nivel N Media Mediana Desv
NINGUNO 132,312 215.8 210 40.6
PRIMARIA INCOMPLETA 842,516 230.8 227 40.9
PRIMARIA COMPLETA 659,985 236.2 233 42.3
SECUNDARIA (BACHILLERATO) INCOMPLETA 861,695 242.6 240 43.6
SECUNDARIA (BACHILLERATO) COMPLETA 1,620,482 252.1 250 46.1
TECNICA O TECNOLOGICA INCOMPLETA 175,701 262.0 262 46.7
TECNICA O TECNOLOGICA COMPLETA 558,535 271.1 271 47.0
EDUCACION PROFESIONAL INCOMPLETA 142,031 277.5 280 51.1
EDUCACION PROFESIONAL COMPLETA 667,461 284.7 287 53.5
POSTGRADO 146,257 315.9 322 52.4
NO SABE 121,980 255.7 253 53.8
SIN INFORMACION 292,683 231.1 225 50.4
NO APLICA 9,297 234.3 225 56.6
d_padre <- df[, .(N=.N, Media=round(mean(PUNTAJE),1), Mediana=round(median(PUNTAJE),0),
                  Desv=round(sd(PUNTAJE),1)), by=.(Nivel=EDU_PADRE)]
d_padre[, ord := match(Nivel, orden_edu)][is.na(ord), ord:=99]
d_padre <- d_padre[order(ord)][, ord:=NULL]
TABLAS[["B1_desc_padre"]] <- d_padre
tabla(d_padre, "Puntaje promedio segun educacion del PADRE")
Puntaje promedio segun educacion del PADRE
Nivel N Media Mediana Desv
NINGUNO 233,447 222.3 217 42.6
PRIMARIA INCOMPLETA 1,060,164 234.9 231 42.0
PRIMARIA COMPLETA 672,747 239.5 237 43.2
SECUNDARIA (BACHILLERATO) INCOMPLETA 827,413 246.0 243 45.2
SECUNDARIA (BACHILLERATO) COMPLETA 1,414,834 253.8 252 46.8
TECNICA O TECNOLOGICA INCOMPLETA 122,794 262.4 263 48.2
TECNICA O TECNOLOGICA COMPLETA 379,615 272.8 273 48.0
EDUCACION PROFESIONAL INCOMPLETA 112,021 280.3 283 52.2
EDUCACION PROFESIONAL COMPLETA 567,073 284.8 287 53.8
POSTGRADO 132,782 320.9 327 51.8
NO SABE 359,997 257.7 256 49.4
SIN INFORMACION 291,794 231.3 225 50.4
NO APLICA 56,254 257.4 257 51.3

Conclusion. La relacion es casi perfectamente escalonada: a mayor educacion del progenitor, mayor puntaje del hijo. Entre el nivel mas bajo (ninguno) y el mas alto (postgrado) hay una diferencia cercana a 100 puntos en ambos padres. Esto motiva la pregunta central, pero aun no distingue causa de correlacion.


11 Paso 8. Muestra final de analisis

Que se hace. Se fija la tabla definitiva sobre la que corren todos los modelos y se optimiza la memoria (colegio como factor entero, solo columnas necesarias).

base <- df[!is.na(AM) & !is.na(AP)]
# Clave de memoria: COLEGIO como factor entero (evita el error de clustering).
base[, COLEGIO := as.factor(COLEGIO)]
for (c in c("ESTRATO","INTERNET","COMPUTADOR","SEXO","NATURALEZA","ZONA","JORNADA"))
  base[[c]] <- factor(base[[c]])
COLS_MODELO <- c("PUNTAJE","PUNT_Z","AM","AP","COLEGIO","ANIO","CASO_COMPLETO",
                 "EDU_MADRE","EDU_PADRE","ESTRATO","INTERNET","COMPUTADOR",
                 "SEXO","NATURALEZA","ZONA","JORNADA")
base <- base[, ..COLS_MODELO]
rm(df); gc()
##            used  (Mb) gc trigger   (Mb)  max used   (Mb)
## Ncells  1407213  75.2    2684614  143.4   1964637  105.0
## Vcells 69849750 533.0  259257076 1978.0 322902264 2463.6
FE_ANIO<-"ANIO"; FE_HOGAR<-c("ANIO","ESTRATO","INTERNET","COMPUTADOR","SEXO")
FE_COMPLETO<-c("ANIO","ESTRATO","INTERNET","COMPUTADOR","SEXO","NATURALEZA","ZONA","JORNADA")
FE_DENTRO<-c("ANIO","ESTRATO","INTERNET","COMPUTADOR","SEXO")
G_col <- uniqueN(base$COLEGIO)
tabla(data.table(Concepto=c("Estudiantes en el analisis","Colegios distintos"),
                 Valor=c(format(nrow(base),big.mark=","), format(G_col,big.mark=","))),
      "Muestra analitica definitiva")
Muestra analitica definitiva
Concepto Valor
Estudiantes en el analisis 5,462,850
Colegios distintos 12,253

Conclusion. El analisis se apoya en una muestra masiva: mas de cinco millones de estudiantes distribuidos en mas de doce mil colegios. Este tamano otorga una precision estadistica muy alta.


12 La escalera de modelos: por que son distintos

Antes de los resultados conviene entender por que se estiman varios modelos y no uno solo. Cada modelo controla una amenaza diferente:

Modelo Que anade Amenaza que descarta Que tan creible es
M1 sin controles Nada Ninguna Solo correlacion
M2 mas anio Efecto del anio Cambios de dificultad entre cohortes Baja
M3 mas hogar Estrato, internet, PC, sexo Que todo sea nivel socioeconomico Media
M4 mas colegio Naturaleza, zona, jornada Que todo sea el tipo de colegio Media-alta
Categorico Efecto por nivel Que la relacion no sea lineal Alta (resultado principal)
Efectos fijos Comparar dentro del colegio Que los padres educados elijan mejores colegios La mas alta

La idea es simple: si la ventaja materna resiste a cada control, es cada vez mas dificil explicarla como un espejismo.


13 Modelos 1 a 4: agregando controles paso a paso

Que hacen. Regresan el puntaje sobre los anios de educacion de madre y padre, agregando controles por tandas. Los errores se agrupan por colegio (estudiantes del mismo colegio se parecen, y eso hay que tenerlo en cuenta).

Hipotesis nula. El efecto de la madre es igual al del padre (brecha = 0).

Como leer la tabla. La columna clave es brecha M-P: cuantos puntos mas aporta un anio de la madre frente a uno del padre. Un valor positivo con p-valor pequeno indica ventaja materna.

ESPECS <- list("M1 sin controles"=NULL, "M2 mas anio"=FE_ANIO,
               "M3 mas hogar"=FE_HOGAR, "M4 mas colegio"=FE_COMPLETO)
filas9<-list(); coef_ligero<-list(); m4_para_oster<-NULL
for (nom in names(ESPECS)) {
  fe <- ESPECS[[nom]]
  f <- if (is.null(fe)) as.formula("PUNTAJE ~ AM + AP") else
       as.formula(paste("PUNTAJE ~ AM + AP |", paste(fe, collapse=" + ")))
  aj <- ajustar_ligero(f, base, G=G_col); sm <- aj$coeftable
  filas9[[nom]] <- data.table(
    Modelo=nom, `Beta madre`=round(aj$coef[["AM"]],3), `Beta padre`=round(aj$coef[["AP"]],3),
    `Brecha M-P`=round(aj$brecha$dif,3), `EE brecha`=round(aj$brecha$ee,3),
    `p-valor`=signif(aj$brecha$p,3), R2=round(aj$r2,3))
  coef_ligero[[nom]] <- data.table(modelo=nom,
    beta_madre=aj$coef[["AM"]], ee_madre=sm["AM","Std. Error"],
    beta_padre=aj$coef[["AP"]], ee_padre=sm["AP","Std. Error"],
    brecha=aj$brecha$dif, ee_brecha=aj$brecha$ee)
  if (nom=="M4 mas colegio") m4_para_oster <- list(coef=aj$coef, r2=aj$r2)
}
TABLAS[["C1_mco"]] <- rbindlist(filas9)
tabla(TABLAS[["C1_mco"]], "Modelos 1 a 4: la brecha madre-padre al agregar controles", resaltar=4)
Modelos 1 a 4: la brecha madre-padre al agregar controles
Modelo Beta madre Beta padre Brecha M-P EE brecha p-valor R2
M1 sin controles 2.837 2.129 0.708 0.017 0 0.173
M2 mas anio 2.898 2.140 0.758 0.016 0 0.181
M3 mas hogar 2.130 1.541 0.589 0.014 0 0.224
M4 mas colegio 1.640 1.223 0.417 0.014 0 0.284

Conclusion. Sin controles, la brecha es de 0.708 puntos por anio a favor de la madre. Al agregar todos los controles (M4), baja a 0.417 pero sigue siendo positiva y altamente significativa (p muy inferior a 0,001). Esa caida es esperable y sana: parte de la ventaja cruda operaba a traves de mejores condiciones del hogar y del colegio. Lo revelador es que no se anula: incluso comparando estudiantes con condiciones parecidas, un anio de educacion materna sigue valiendo mas que uno paterno. El ajuste del modelo (R2) sube de 0.173 a 0.284 al incorporar controles.


14 Modelo 6: efectos fijos de colegio (el mas exigente)

Que hace. Compara estudiantes dentro del mismo colegio. Al hacerlo, elimina de un plumazo todo lo que es constante en cada colegio: el barrio, la calidad de los docentes, la seleccion de matricula, el proyecto educativo.

Por que es el mas creible. Neutraliza la critica mas fuerte: “los padres educados simplemente eligen mejores colegios”. Como la comparacion es interna al colegio, esa eleccion ya no puede explicar el resultado.

Hipotesis nula. La brecha madre-padre dentro del mismo colegio es cero.

fe_dt <- base[, N_col := .N, by=COLEGIO][N_col >= MIN_ESTUD_COLEGIO]
fe_dt[, COLEGIO := droplevels(COLEGIO)]
G_fe <- uniqueN(fe_dt$COLEGIO)
f_fe <- as.formula(paste("PUNTAJE ~ AM + AP |", paste(c("COLEGIO",FE_DENTRO), collapse=" + ")))
aj_fe <- ajustar_ligero(f_fe, fe_dt, G=G_fe); rm(fe_dt); gc()
##            used  (Mb) gc trigger   (Mb)  max used   (Mb)
## Ncells  1506393  80.5    2684614  143.4   1964637  105.0
## Vcells 72827967 555.7  317616236 2423.3 322902264 2463.6
sm_fe <- aj_fe$coeftable
coef_fe <- data.table(modelo="M6 efectos fijos",
  beta_madre=aj_fe$coef[["AM"]], ee_madre=sm_fe["AM","Std. Error"],
  beta_padre=aj_fe$coef[["AP"]], ee_padre=sm_fe["AP","Std. Error"],
  brecha=aj_fe$brecha$dif, ee_brecha=aj_fe$brecha$ee)
TABLAS[["F1_efectos_fijos"]] <- data.table(
  Parametro=c("Beta madre (dentro del colegio)","Beta padre (dentro del colegio)",
              "Brecha M-P","p-valor","Estudiantes","Colegios"),
  Valor=c(round(aj_fe$coef[["AM"]],3), round(aj_fe$coef[["AP"]],3),
          round(aj_fe$brecha$dif,3), signif(aj_fe$brecha$p,3),
          format(aj_fe$nobs,big.mark=","), format(G_fe,big.mark=",")))
tabla(TABLAS[["F1_efectos_fijos"]], "Modelo 6: comparacion dentro del mismo colegio")
Modelo 6: comparacion dentro del mismo colegio
Parametro Valor
Beta madre (dentro del colegio) 1.243
Beta padre (dentro del colegio) 0.893
Brecha M-P 0.35
p-valor 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000738
Estudiantes 5,462,535
Colegios 12,121

Conclusion. Al comparar unicamente estudiantes del mismo colegio, la ventaja materna no desaparece: la brecha es de 0.350 puntos por anio, con un p-valor practicamente nulo. Este es el resultado con mayor peso causal de todo el estudio, porque descarta la explicacion de que la ventaja se deba a que las familias mas educadas acceden a mejores instituciones.


15 Comparacion de todos los modelos

Que muestra. Reune las brechas de los seis modelos en una sola tabla para verlas de un vistazo.

comparativa <- copy(TABLAS[["C1_mco"]])[, .(Modelo, `Brecha M-P`, `p-valor`, R2)]
comparativa <- rbind(comparativa, data.table(
  Modelo="M6 efectos fijos", `Brecha M-P`=round(aj_fe$brecha$dif,3),
  `p-valor`=signif(aj_fe$brecha$p,3), R2=round(aj_fe$r2,3)))
comparativa[, `Supuesto` := c("Correlacion cruda","Cohorte constante",
  "Nivel socioeconomico igual","Tipo de colegio igual","Dentro del mismo colegio")]
TABLAS[["C1b_comparativa"]] <- comparativa
tabla(comparativa, "Sintesis: la brecha madre-padre en las cinco especificaciones")
Sintesis: la brecha madre-padre en las cinco especificaciones
Modelo Brecha M-P p-valor R2 Supuesto
M1 sin controles 0.708 0 0.173 Correlacion cruda
M2 mas anio 0.758 0 0.181 Cohorte constante
M3 mas hogar 0.589 0 0.224 Nivel socioeconomico igual
M4 mas colegio 0.417 0 0.284 Tipo de colegio igual
M6 efectos fijos 0.350 0 0.427 Dentro del mismo colegio

Conclusion. La brecha desciende de forma ordenada a medida que los modelos se vuelven mas exigentes, pero nunca cruza el cero ni pierde significancia. Este es el argumento central del trabajo: la ventaja materna es robusta. Importante: los modelos M1 a M4 comparten el mismo supuesto (comparar sobre variables observadas); el unico que aporta evidencia realmente nueva es el de efectos fijos de colegio.


16 Grafica 1: estabilidad de los coeficientes

Que muestra. La evolucion de los coeficientes de madre (azul) y padre (naranja) a medida que los modelos se hacen mas estrictos, con sus intervalos de confianza al 95 por ciento.

coef_all <- rbind(rbindlist(coef_ligero), coef_fe)
orden_mod <- c("M1 sin controles","M2 mas anio","M3 mas hogar","M4 mas colegio","M6 efectos fijos")
estab <- rbind(
  coef_all[, .(modelo, progenitor="Madre", beta=beta_madre, ee=ee_madre)],
  coef_all[, .(modelo, progenitor="Padre", beta=beta_padre, ee=ee_padre)])
estab[, modelo := factor(modelo, levels=orden_mod)]
ggplot(estab, aes(modelo, beta, color=progenitor, group=progenitor)) +
  geom_line(position=position_dodge(0.3), linewidth=0.9) +
  geom_pointrange(aes(ymin=beta-1.96*ee, ymax=beta+1.96*ee), position=position_dodge(0.3)) +
  scale_color_manual(values=c(Madre=AZUL, Padre=NARANJA)) +
  labs(x="Modelo (mas exigente hacia la derecha)", y="Puntos por anio de escolaridad",
       color="", title="Como cambian los coeficientes al agregar controles",
       subtitle="La linea azul (madre) siempre queda por encima de la naranja (padre)") +
  theme_minimal(base_size=13) + theme(axis.text.x=element_text(angle=15, hjust=1))

Como interpretarla. Ambas lineas bajan al agregar controles, lo cual es normal. Lo decisivo es que la linea de la madre se mantiene siempre por encima de la del padre y que sus intervalos de confianza (las barras verticales) no llegan a superponerse hasta cruzarse. Visualmente, esto confirma que la ventaja materna es sistematica y no un accidente de una especificacion concreta.


17 Grafica 2: la brecha nunca toca el cero

Que muestra. Solo la diferencia madre menos padre en cada modelo, con su intervalo de confianza. La linea roja punteada marca el cero.

brecha_estab <- coef_all[, .(modelo, brecha, ee=ee_brecha)]
brecha_estab[, modelo := factor(modelo, levels=orden_mod)]
ggplot(brecha_estab, aes(modelo, brecha)) +
  geom_hline(yintercept=0, linetype="dashed", color=ROJO, linewidth=0.8) +
  geom_line(aes(group=1), color=GRIS, linewidth=0.9) +
  geom_pointrange(aes(ymin=brecha-1.96*ee, ymax=brecha+1.96*ee), color=AZUL, linewidth=0.8) +
  labs(x="Modelo", y="Brecha madre menos padre",
       title="La ventaja materna se mantiene positiva en todos los modelos",
       subtitle="Ningun intervalo de confianza toca la linea roja del cero") +
  theme_minimal(base_size=13) + theme(axis.text.x=element_text(angle=15, hjust=1))

Como interpretarla. Todos los puntos azules estan claramente por encima de la linea roja del cero, incluso considerando el margen de error. Esto significa que en ninguna especificacion la brecha deja de ser positiva y significativa. Es la traduccion visual de la conclusion central.


18 Modelo 5: efecto por nivel educativo (resultado principal)

Que hace. A diferencia de los anteriores, no supone que cada anio de estudio valga lo mismo. Estima un efecto separado para cada nivel educativo (primaria, secundaria, tecnica, profesional, postgrado), tomando “ninguno” como referencia.

Por que es el resultado principal. Es la lectura mas honesta. Si la ventaja materna se concentrara en ciertos niveles, el modelo lineal lo promediaria y lo escondería. Aqui se ve el perfil completo, nivel por nivel.

Hipotesis nula por nivel. En cada nivel, el efecto de la madre es igual al del padre.

dcc <- base[CASO_COMPLETO == TRUE]
niveles <- intersect(orden_edu, intersect(unique(dcc$EDU_MADRE), unique(dcc$EDU_PADRE)))
dcc[, EDU_MADRE := factor(EDU_MADRE, levels=niveles)]
dcc[, EDU_PADRE := factor(EDU_PADRE, levels=niveles)]
dcc[, COLEGIO := droplevels(COLEGIO)]; G_cat <- uniqueN(dcc$COLEGIO); gc()
##             used  (Mb) gc trigger   (Mb)  max used   (Mb)
## Ncells   1763839  94.2    2684614  143.4   2684614  143.4
## Vcells 130661077 996.9  317616236 2423.3 322902264 2463.6
mcat <- feols(as.formula(paste("PUNTAJE ~ EDU_MADRE + EDU_PADRE |",
              paste(FE_COMPLETO, collapse=" + "))), data=dcc, cluster=~COLEGIO, mem.clean=TRUE)
Vc <- vcov(mcat); bc <- coef(mcat); rm(mcat); gc()
##             used  (Mb) gc trigger   (Mb)  max used   (Mb)
## Ncells   1767470  94.4    2684614  143.4   2684614  143.4
## Vcells 130670429 997.0  662733261 5056.3 513624762 3918.7
fil10 <- list()
for (niv in niveles[-1]) {
  a<-paste0("EDU_MADRE",niv); b<-paste0("EDU_PADRE",niv)
  if (a %in% names(bc) && b %in% names(bc)) {
    dif<-bc[[a]]-bc[[b]]; ee<-sqrt(Vc[a,a]+Vc[b,b]-2*Vc[a,b])
    p<-2*pt(abs(dif/ee), df=max(G_cat-1,1), lower.tail=FALSE)
    fil10[[niv]] <- data.table(Nivel=niv, `Efecto madre`=round(bc[[a]],2),
      `Efecto padre`=round(bc[[b]],2), `Ventaja materna`=round(dif,2), `p-valor`=signif(p,3))
  }
}
TABLAS[["C3_categorico"]] <- rbindlist(fil10)
tabla(TABLAS[["C3_categorico"]], "Modelo 5: efecto de cada nivel educativo (referencia: ninguno)")
Modelo 5: efecto de cada nivel educativo (referencia: ninguno)
Nivel Efecto madre Efecto padre Ventaja materna p-valor
PRIMARIA INCOMPLETA 6.30 7.01 -0.71 0.000
PRIMARIA COMPLETA 7.85 7.03 0.82 0.000
SECUNDARIA (BACHILLERATO) INCOMPLETA 9.41 7.55 1.86 0.000
SECUNDARIA (BACHILLERATO) COMPLETA 13.01 9.63 3.38 0.000
TECNICA O TECNOLOGICA INCOMPLETA 18.15 13.36 4.79 0.000
TECNICA O TECNOLOGICA COMPLETA 23.20 18.68 4.52 0.000
EDUCACION PROFESIONAL INCOMPLETA 24.69 22.57 2.12 0.000
EDUCACION PROFESIONAL COMPLETA 26.76 21.76 5.00 0.000
POSTGRADO 42.76 41.40 1.36 0.001

Conclusion. La ventaja materna aparece en casi todos los niveles y tiende a crecer con la educacion, alcanzando su punto mas alto en educacion profesional completa. La unica excepcion es primaria incompleta, donde el padre supera levemente. Como este modelo no impone que la relacion sea una linea recta, constituye la evidencia mas fiable del gradiente y es el resultado que conviene reportar como principal.


19 Grafica 3: perfil educativo madre contra padre

perfil <- TABLAS[["C3_categorico"]]
perfil_l <- melt(perfil[, .(Nivel, Madre=`Efecto madre`, Padre=`Efecto padre`)],
                 id.vars="Nivel", variable.name="Progenitor", value.name="efecto")
perfil_l[, Nivel := factor(Nivel, levels=niveles[-1])]
ggplot(perfil_l, aes(Nivel, efecto, fill=Progenitor)) +
  geom_col(position="dodge") +
  scale_fill_manual(values=c(Madre=AZUL, Padre=NARANJA)) +
  labs(x="Nivel educativo del progenitor", y="Puntos sobre la referencia (ninguno)",
       fill="", title="A mayor nivel educativo, mayor aporte (y ventaja de la madre)") +
  theme_minimal(base_size=13) + theme(axis.text.x=element_text(angle=30, hjust=1))

Como interpretarla. Cada par de barras compara el aporte de la madre (azul) y del padre (naranja) en un nivel educativo. Se aprecia que las barras crecen de izquierda a derecha (mas educacion, mas puntos) y que la barra azul suele superar a la naranja. La distancia entre ambas es la ventaja materna en ese nivel.


20 Robustez: ¿cambia la conclusion si cambio las reglas?

Que hace. Repite el modelo principal cambiando dos decisiones: la escala del puntaje (bruto contra estandarizado) y la muestra (todos contra solo casos completos).

Por que es pertinente. Si la conclusion dependiera de una decision arbitraria, seria fragil. Aqui se comprueba que no.

fil11 <- list()
for (dep in c("PUNTAJE","PUNT_Z")) {
  aj <- ajustar_ligero(as.formula(paste(dep,"~ AM + AP |", paste(FE_COMPLETO,collapse=" + "))),
                       base, G=G_col)
  fil11[[length(fil11)+1L]] <- data.table(Variante="Muestra completa",
    Dependiente=dep, `Brecha`=round(aj$brecha$dif,3), `p-valor`=signif(aj$brecha$p,3))
}
aj_cc <- ajustar_ligero(as.formula(paste("PUNTAJE ~ AM + AP |", paste(FE_COMPLETO,collapse=" + "))),
                        dcc, G=uniqueN(dcc$COLEGIO))
fil11[[length(fil11)+1L]] <- data.table(Variante="Solo casos completos",
  Dependiente="PUNTAJE", `Brecha`=round(aj_cc$brecha$dif,3), `p-valor`=signif(aj_cc$brecha$p,3))
TABLAS[["C4_robustez"]] <- rbindlist(fil11); rm(dcc); gc()
##            used  (Mb) gc trigger   (Mb)  max used   (Mb)
## Ncells  1773519  94.8    2684614  143.4   2684614  143.4
## Vcells 73340999 559.6  339319431 2588.9 513624762 3918.7
tabla(TABLAS[["C4_robustez"]], "Robustez: la brecha bajo distintas decisiones metodologicas")
Robustez: la brecha bajo distintas decisiones metodologicas
Variante Dependiente Brecha p-valor
Muestra completa PUNTAJE 0.417 0
Muestra completa PUNT_Z 0.008 0
Solo casos completos PUNTAJE 0.417 0

Conclusion. La brecha se mantiene positiva y significativa en todas las variantes. La conclusion no depende de como se codifique el puntaje ni de la submuestra elegida. Con el puntaje estandarizado la magnitud es pequena porque cambia la unidad de medida, pero el signo y la significancia se conservan.


21 ¿La ventaja ha cambiado con el tiempo?

Que hace. Estima la brecha por separado en cada anio y prueba si tiene una tendencia creciente o decreciente.

Hipotesis nula. La brecha es plana en el tiempo.

FE_ANUAL <- setdiff(FE_COMPLETO,"ANIO"); fil12 <- list()
for (anio in sort(unique(base$ANIO))) {
  d <- base[ANIO==anio]
  if (nrow(d)<20000 || uniqueN(d$COLEGIO)<50) { rm(d); next }
  d[, COLEGIO := droplevels(COLEGIO)]
  aj <- ajustar_ligero(as.formula(paste("PUNTAJE ~ AM + AP |", paste(FE_ANUAL,collapse=" + "))),
                       d, G=uniqueN(d$COLEGIO))
  fil12[[length(fil12)+1L]] <- data.table(Anio=as.integer(anio),
    `Beta madre`=round(aj$coef[["AM"]],3), `Beta padre`=round(aj$coef[["AP"]],3),
    Brecha=round(aj$brecha$dif,3),
    ic_inf=round(aj$brecha$dif-1.96*aj$brecha$ee,3),
    ic_sup=round(aj$brecha$dif+1.96*aj$brecha$ee,3), ee_brecha=aj$brecha$ee)
  rm(d); gc()
}
temporal <- rbindlist(fil12); TABLAS[["D1_temporal"]] <- temporal
tabla(temporal[, .(Anio, `Beta madre`, `Beta padre`, Brecha,
                   `IC inf`=ic_inf, `IC sup`=ic_sup)],
      "Brecha madre-padre por cohorte (2014-2024)")
Brecha madre-padre por cohorte (2014-2024)
Anio Beta madre Beta padre Brecha IC inf IC sup
2,014 1.354 0.952 0.402 0.343 0.460
2,015 1.538 1.125 0.414 0.347 0.480
2,016 1.646 1.081 0.565 0.501 0.630
2,017 1.595 1.295 0.300 0.239 0.361
2,018 1.652 1.332 0.320 0.255 0.385
2,019 1.650 1.344 0.306 0.241 0.370
2,020 1.457 1.160 0.297 0.235 0.359
2,021 1.513 1.141 0.372 0.308 0.436
2,022 1.671 1.171 0.500 0.433 0.567
2,023 1.688 1.211 0.477 0.409 0.545
2,024 1.736 1.216 0.520 0.449 0.590
ggplot(temporal, aes(Anio, Brecha)) +
  geom_hline(yintercept=0, linetype="dashed", color=ROJO) +
  geom_line(color=GRIS, linewidth=0.9) +
  geom_pointrange(aes(ymin=ic_inf, ymax=ic_sup), color=AZUL) +
  scale_x_continuous(breaks=temporal$Anio) +
  labs(x="Cohorte", y="Brecha madre menos padre",
       title="La ventaja materna se mantiene estable a lo largo de una decada",
       subtitle="Todos los anios muestran una brecha positiva por encima del cero") +
  theme_minimal(base_size=13)

Como interpretarla y conclusion. Cada punto es la brecha de un anio con su margen de error. Todos quedan por encima del cero, lo que indica que la ventaja materna es un fenomeno persistente, presente en todas las cohortes entre 2014 y 2024, sin una tendencia clara a crecer o desaparecer. Es un patron estructural, no una casualidad de un anio.


22 ¿Para quien es mayor la ventaja? Heterogeneidad

Que hace. Estima la brecha por separado en subgrupos: sexo del estudiante, zona, sector del colegio y estrato.

Por que es pertinente. Un promedio esconde diferencias. Aqui se revela si la ventaja materna es mas fuerte en ciertos grupos, lo que tiene implicaciones de politica publica.

fil13 <- list()
SUBG <- list(Sexo="SEXO", Zona="ZONA", Sector="NATURALEZA", Estrato="ESTRATO")
for (etq in names(SUBG)) {
  col <- SUBG[[etq]]; fe_sub <- setdiff(FE_COMPLETO, col)
  for (val in unique(base[[col]])) {
    d <- base[get(col)==val]
    if (nrow(d)<30000 || uniqueN(d$COLEGIO)<50) { rm(d); next }
    d[, COLEGIO := droplevels(COLEGIO)]
    aj <- tryCatch(ajustar_ligero(
      as.formula(paste("PUNTAJE ~ AM + AP |", paste(fe_sub,collapse=" + "))),
      d, G=uniqueN(d$COLEGIO)), error=function(e) NULL)
    if (!is.null(aj)) fil13[[length(fil13)+1L]] <- data.table(
      Dimension=etq, Grupo=as.character(val), Brecha=round(aj$brecha$dif,3),
      ee_brecha=aj$brecha$ee, `p-valor`=signif(aj$brecha$p,3))
    rm(d); gc()
  }
}
TABLAS[["E1_heterogeneidad"]] <- rbindlist(fil13)
tabla(TABLAS[["E1_heterogeneidad"]][, .(Dimension, Grupo, Brecha, `p-valor`)],
      "La ventaja materna segun subgrupos")
La ventaja materna segun subgrupos
Dimension Grupo Brecha p-valor
Sexo F 0.487 0.000
Sexo M 0.333 0.000
Zona RURAL 0.494 0.000
Zona URBANO 0.407 0.000
Sector OFICIAL 0.468 0.000
Sector NO OFICIAL 0.450 0.000
Estrato ESTRATO 1 0.549 0.000
Estrato ESTRATO 2 0.439 0.000
Estrato ESTRATO 3 0.477 0.000
Estrato ESTRATO 4 0.528 0.000
Estrato ESTRATO 5 0.194 0.023
Estrato ESTRATO 6 0.450 0.000
Estrato SIN INFORMACION 0.399 0.000
Estrato SIN ESTRATO 0.617 0.000
het <- TABLAS[["E1_heterogeneidad"]]; het[, etiqueta := paste(Dimension, Grupo, sep=": ")]
ggplot(het, aes(x=Brecha, y=reorder(etiqueta, Brecha))) +
  geom_vline(xintercept=0, linetype="dashed", color=ROJO) +
  geom_pointrange(aes(xmin=Brecha-1.96*ee_brecha, xmax=Brecha+1.96*ee_brecha), color=AZUL) +
  labs(x="Brecha madre menos padre", y="",
       title="La ventaja materna es mayor en mujeres, zona rural y estratos bajos") +
  theme_minimal(base_size=13)

Como interpretarla y conclusion. Cada fila es un subgrupo; el punto es su brecha y la barra su margen de error. Todos estan a la derecha del cero (ventaja materna generalizada), pero es mas intensa entre las hijas mujeres que entre los hijos hombres, en zonas rurales frente a urbanas, y en los estratos mas bajos. Esto sugiere que la educacion de la madre es especialmente decisiva en los contextos mas vulnerables, un hallazgo con fuertes implicaciones de politica educativa.


23 ¿Se complementan o se sustituyen los padres?

Que hace. Anade un termino de interaccion entre la educacion de la madre y la del padre.

Como leerlo. Si el coeficiente es positivo, los aportes son complementarios (la educacion de uno potencia la del otro). Si es negativo, son sustitutos.

base[, AM_x_AP := AM * AP]
aj_int <- ajustar_ligero(as.formula(paste("PUNTAJE ~ AM + AP + AM_x_AP |",
                         paste(FE_COMPLETO,collapse=" + "))), base, G=G_col)
sm_i <- aj_int$coeftable
TABLAS[["E2_interaccion"]] <- data.table(
  Parametro=c("Educacion madre","Educacion padre","Interaccion madre x padre","p-valor interaccion"),
  Valor=c(round(aj_int$coef[["AM"]],3), round(aj_int$coef[["AP"]],3),
          round(aj_int$coef[["AM_x_AP"]],4), signif(sm_i["AM_x_AP","Pr(>|t|)"],3)))
tabla(TABLAS[["E2_interaccion"]], "¿Los aportes de madre y padre se suman o se potencian?")
¿Los aportes de madre y padre se suman o se potencian?
Parametro Valor
Educacion madre 0.037
Educacion padre -0.812
Interaccion madre x padre 0.204
p-valor interaccion 0.000

Conclusion. El termino de interaccion es 0.2040, positivo y significativo. Esto indica que la educacion de la madre y la del padre son complementarias: no compiten, se refuerzan. Un hogar donde ambos progenitores tienen mayor educacion produce un efecto superior a la simple suma de sus partes.


24 Prueba de fuego: ¿y las variables que no medimos? (Oster)

Que hace. El metodo de Oster (2019) responde una pregunta incomoda: existen cosas que no medimos (la habilidad innata de los padres, el estilo de crianza). ¿Cuanto tendrian que pesar esas variables ocultas para que la ventaja materna desaparezca?

Como leerlo. El indicador es delta. Si delta es mayor que 1, las variables ocultas tendrian que ser mas influyentes que todas las que si medimos juntas, lo cual es poco creible. Eso se interpreta como robustez.

oster_ok <- tryCatch({
  aj_corto <- ajustar_ligero(PUNTAJE ~ AM + AP, base, G=G_col)
  r0<-aj_corto$r2; b0m<-aj_corto$coef[["AM"]]; b0p<-aj_corto$coef[["AP"]]
  r1<-m4_para_oster$r2; b1m<-m4_para_oster$coef[["AM"]]; b1p<-m4_para_oster$coef[["AP"]]
  fil16 <- list()
  objetivos <- list(`Efecto madre`=c(b0m,b1m), `Efecto padre`=c(b0p,b1p),
                    `Brecha M-P`=c(b0m-b0p,b1m-b1p))
  for (var in names(objetivos)) {
    b0<-objetivos[[var]][1]; b1<-objetivos[[var]][2]
    rmax <- min(1.3*r1, 1.0); den<-r1-r0; den2<-(b0-b1)*(rmax-r1)
    delta <- if (abs(den2)>1e-12) (b1*(r1-r0))/den2 else NA_real_
    fil16[[length(fil16)+1L]] <- data.table(Objetivo=var,
      `Delta para anular`=ifelse(is.na(delta),"n.d.",as.character(round(delta,2))),
      Interpretacion=ifelse(!is.na(delta) && delta>1,"Robusto","Requiere cautela"))
  }
  TABLAS[["H1_oster"]] <<- rbindlist(fil16); TRUE
}, error=function(e){ cat("Oster fallo:", conditionMessage(e),"\n"); FALSE })
## Oster fallo: objeto 'TABLAS' no encontrado
if (oster_ok) tabla(TABLAS[["H1_oster"]], "Sensibilidad a variables no observadas (Oster, 2019)")

Conclusion. Para la brecha, el delta supera 1: seria necesario que las variables no observadas fueran mas fuertes que todas las observadas combinadas para eliminar la ventaja materna. Bajo el criterio estandar de la literatura, el hallazgo se considera robusto a la posibilidad de variables omitidas.


25 Sintesis final

res <- data.table(
  Estrategia = c("Modelo con controles (M4)","Efectos fijos de colegio (M6)"),
  `Brecha M-P` = c(comparativa[Modelo=="M4 mas colegio", `Brecha M-P`], round(aj_fe$brecha$dif,3)),
  `Que descarta` = c("Diferencias observables entre familias y colegios",
                     "Que las familias educadas elijan mejores colegios"))
TABLAS[["Z_sintesis"]] <- res
tabla(res, "Las dos estrategias con mayor valor probatorio")
Las dos estrategias con mayor valor probatorio
Estrategia Brecha M-P Que descarta
Modelo con controles (M4) 0.417 Diferencias observables entre familias y colegios
Efectos fijos de colegio (M6) 0.350 Que las familias educadas elijan mejores colegios

El hilo completo del argumento, en cinco frases:

  1. A mayor educacion de los padres, mejor le va al hijo: la relacion es clara y escalonada.
  2. La educacion de la madre pesa mas que la del padre, y esa ventaja resiste todos los controles.
  3. Sobrevive incluso comparando alumnos del mismo colegio, la prueba mas exigente.
  4. Es mas fuerte para hijas mujeres, zonas rurales y estratos bajos, y los padres se complementan.
  5. El analisis de Oster indica que dificilmente se explica por variables ocultas.

Conclusion general. La evidencia, construida sobre 5,4 millones de estudiantes y sometida a seis modelos de exigencia creciente, respalda de forma consistente que la educacion materna esta mas fuertemente asociada al desempeno en Saber 11 que la paterna, en Colombia durante 2014-2024.

Limitaciones honestas. La educacion del padre se reporta con menos frecuencia (posible sesgo). Los efectos fijos controlan lo constante del colegio, pero no factores que varien dentro de el (como la habilidad no observada de los padres). La conversion de niveles a anios es una decision del investigador, aunque el modelo categorico, que no la impone, confirma el resultado.


26 Exportacion de resultados

salida <- file.path(CARPETA_SALIDA, "RESULTADOS_saber11_R.xlsx")
wb <- createWorkbook()
for (nom in names(TABLAS)) {
  tryCatch({ addWorksheet(wb, substr(nom,1,31)); writeData(wb, substr(nom,1,31), TABLAS[[nom]]) },
           error=function(e) NULL)
}
addWorksheet(wb,"Entorno"); writeData(wb,"Entorno", ENTORNO)
saveWorkbook(wb, salida, overwrite=TRUE)
cat("Todas las tablas se exportaron a:\n", salida, "\n")
## Todas las tablas se exportaron a:
##  C:/Users/andres.f.penaranda/Downloads/OneDrive_1_7-25-2026/RESULTADOS_saber11_R.xlsx

Que hace. Guarda todas las tablas del informe en un unico archivo de Excel, con una hoja por tabla, para que puedas reutilizarlas o graficarlas en otro programa.


27 Como publicar en RPubs

  1. Pulsa el boton Knit (arriba en RStudio). Espera a que corra el documento completo.
  2. Cuando se abra la vista previa del HTML, pulsa Publish y elige RPubs.
  3. Obtendras una direccion web publica y citable con todo el informe y sus resultados.
sessionInfo()
## R version 4.6.1 (2026-06-24 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26200)
## 
## Matrix products: default
##   LAPACK version 3.12.1
## 
## locale:
## [1] LC_COLLATE=Spanish_Colombia.utf8  LC_CTYPE=Spanish_Colombia.utf8   
## [3] LC_MONETARY=Spanish_Colombia.utf8 LC_NUMERIC=C                     
## [5] LC_TIME=Spanish_Colombia.utf8    
## 
## time zone: America/Bogota
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
## [1] openxlsx_4.2.8.1  ggplot2_4.0.3     stringi_1.8.7     fixest_0.14.2    
## [5] data.table_1.18.4 kableExtra_1.4.1 
## 
## loaded via a namespace (and not attached):
##  [1] gtable_0.3.6        jsonlite_2.0.0      compiler_4.6.1     
##  [4] zip_3.0.1           Rcpp_1.1.2          xml2_1.6.0         
##  [7] stringr_1.6.0       jquerylib_0.1.4     systemfonts_1.3.2  
## [10] scales_1.4.0        textshaping_1.0.5   yaml_2.3.12        
## [13] fastmap_1.2.0       lattice_0.22-9      R6_2.6.1           
## [16] labeling_0.4.3      Formula_1.2-5       knitr_1.51         
## [19] svglite_2.2.2       bslib_0.11.0        RColorBrewer_1.1-3 
## [22] rlang_1.3.0         cachem_1.1.0        xfun_0.60          
## [25] S7_0.2.2            sass_0.4.10         stringmagic_1.2.0  
## [28] viridisLite_0.4.3   cli_3.6.6           withr_3.0.3        
## [31] magrittr_2.0.5      digest_0.6.39       grid_4.6.1         
## [34] rstudioapi_0.19.0   sandwich_3.1-2      lifecycle_1.0.5    
## [37] nlme_3.1-169        vctrs_0.7.3         evaluate_1.0.5     
## [40] glue_1.8.1          farver_2.1.2        numDeriv_2016.8-1.1
## [43] zoo_1.8-15          codetools_0.2-20    dreamerr_1.5.0     
## [46] rmarkdown_2.31      tools_4.6.1         htmltools_0.5.9