¿Qué carrera universitaria está creciendo más rápido en Colombia, no en volumen, sino en tasa relativa y de forma estable? La respuesta vive en los datos abiertos del SNIES (Sistema Nacional de Información de la Educación Superior, MinEducación). Once años de información, repartidos en archivos Excel inconsistentes, con encabezados distintos cada año y caracteres mal codificados. Este artículo recorre, de punta a punta, cómo convertí ese material en un modelo estrella de Power BI con 18 dimensiones canónicas y 21 medidas DAX listas para responder preguntas de negocio.
Cifras del proyecto:
- 11 años cubiertos (2014–2024), 22 semestres.
- 483.327 filas unificadas tras pasar 11 archivos por un pipeline de unificación.
- 9.776.932 matriculados acumulados en primer curso.
- 17.411 programas SNIES, 7.193 nombres canónicos (2.790 variantes ortográficas colapsadas).
El problema: 11 archivos, 11 esquemas distintos
El portal del MEN publica cada año un Excel con los matriculados en primer curso por programa, IES, año, semestre y sexo. El detalle es que ningún año tiene la misma estructura:
- Hojas con nombres distintos:
Sheet 1,Hoja1,Primer_Curso_2015,1.. - Filas de encabezado en posiciones distintas: 6, 7, 9, 10, 11.
- De 33 a 41 columnas según el año (los más antiguos no tenían
Id_Sector,Id_Nivelni los códigos CINE). - Variantes ortográficas en los nombres de columna del mismo concepto.
Sumar pd.concat no funciona. Hace falta un diccionario de mapeo
(nombre original × año → nombre canónico) construido a partir de los archivos
de «metadatos bases» que el propio MEN publica.
Fase 1 — Adquisición masiva
El portal no permite «descargar todo» filtrando por palabra clave. La
forma más rápida fue un script de DevTools Console que busca todos los
<a> cuyo title contiene la palabra clave y dispara descargas con un
delay para no saturar:
const PALABRAS_CLAVE = ["primer curso"]; // luego: "metadatos bases"
const DELAY_MS = 800;
const enlaces = [...document.querySelectorAll('a[href][title]')]
.filter(a => PALABRAS_CLAVE.some(kw => a.title.toLowerCase().includes(kw)));
for (const a of enlaces) {
const link = document.createElement('a');
link.href = a.href; link.download = a.href.split('/').pop();
document.body.appendChild(link); link.click(); link.remove();
await new Promise(r => setTimeout(r, DELAY_MS));
}
Dos pases: uno por matriculados, otro por metadatos.
Fase 2 — Unificación con diccionario
Pipeline de cuatro scripts:
extraer_diccionarios.py → mapeo (nombre original × año) → canónico
inspeccionar_metadatos.py → audita campos faltantes/sobrantes por año
unificar_data_u_co.py → 11 Excel → un solo CSV con 44 columnas
auditar_unificado.py → totales por archivo, conteo de filas, sanity check
Cada fila lleva tres columnas extra (SOURCE_FILE, SOURCE_SHEET,
SOURCE_ROW) que sirven como rastro de auditoría — si una métrica se ve
rara, se puede regresar al Excel y la fila exacta de origen.
Resultado: 483.327 filas con 44 columnas comunes, listas para análisis.
Fase 3 — La auditoría dimensional que cambió el rumbo
Al revisar el primer notebook descriptivo aparecieron dos hallazgos que obligaron a replantear el análisis:
- Los matriculados crecen de 783K (2014) a 1.06M (2024), pero el conteo de programas únicos sube de 7.724 a 11.820. Hay programas que entran y salen del mercado, así que comparar crecimiento sin filtrar por cobertura completa sería engañoso.
- Distribución muy sesgada: mediana de 9 matriculados por programa, percentil 99 en 170, máximo 10.645. Un crecimiento absoluto siempre favorecería al gigante. La métrica correcta es CAGR (tasa relativa).
Después vino la sorpresa: tildes y mayúsculas estaban contando entidades
distintas. Ingeniería y INGENIERIA eran dos programas. Una auditoría
sistemática reveló:
| Dimensión | IDs únicos | Nombres únicos | IDs con varias variantes |
|---|---|---|---|
CÓDIGO SNIES → PROGRAMA |
17.411 | 9.983 | 3.151 |
| MUNICIPIO PROGRAMA | 853 | 1.547 | 555 |
| CINE CAMPO DETALLADO | 106 | 207 | 101 |
3.151 SNIES con más de un nombre. Sin limpieza dimensional, cualquier ranking sería ruido.
El caso del carácter ¿
Algunos archivos del MEN tenían acentos mal codificados:
ADMINISTRACI¿N, BACTERIOLOGÏA, INGENIER¿A. La solución fue una lista
de patrones regex de alta confianza:
PATRONES_REPARAR = [
(re.compile(r"CI¿N\b"), "CIÓN"),
(re.compile(r"GESTI¿N"), "GESTIÓN"),
(re.compile(r"INGENIER¿A"), "INGENIERÍA"),
(re.compile(r"DISE¿O"), "DISEÑO"),
(re.compile(r"INFORM¿TIC"), "INFORMÁTIC"),
# ~100 más
(re.compile(r"¿"), ""), # fallback: borra ¿ huérfano
]
Para cada par (id, nombre) se eligió el nombre con menor «score malo»
(menos ¿, menos chars raros), desempate por frecuencia y luego longitud.
Después: MAYÚSCULAS y limpieza de dobles espacios.
Resultado: 0 nombres canónicos con ¿ o caracteres raros tras la
limpieza. 9.983 nombres → 7.193 claves canónicas.
Fase 4 — Modelo estrella en Power BI
Con las 18 dimensiones canónicas listas (un CSV por dimensión), tocaba
cargarlas a Power BI y construir el modelo. Para esto usé
pbi-cli, que permite operar el
modelo desde scripts.
pbi connect # autodetecta el puerto local de Power BI Desktop
El patrón por dimensión:
pbi table create dim_modalidad --mode Import --m-expression -
pbi column create id_modalidad --table dim_modalidad --data-type int64 --is-key
pbi column create nombre_modalidad --table dim_modalidad --data-type string
pbi table refresh dim_modalidad --type Full
pbi relationship create \
--from-table data_u_co_unificado --from-column "ID MODALIDAD" \
--to-table dim_modalidad --to-column id_modalidad \
--cross-filter OneDirection --active
Multiplicado por 17 dimensiones + dim_calendario. 18 relaciones, todas
OneDirection. Para ocultar las 42 columnas de IDs en la tabla de hechos
hubo que exportar a TMDL, editar la línea isHidden y reimportar
(la CLI no expone column hide directamente):
column 'CÓDIGO DE LA INSTITUCIÓN'
isHidden
dataType: int64
sourceColumn: CÓDIGO DE LA INSTITUCIÓN
Quedaron visibles solo 3 columnas de la fact: AÑO, SEMESTRE y
MATRICULADOS PRIMER CURSO. Todo lo demás se filtra y etiqueta desde las
dimensiones canónicas.
Fase 5 — 21 medidas DAX
El núcleo está en dos folders:
Crecimiento (11 medidas): Matriculados, Año Inicial, Año Final,
N Años con Datos, Matric Año Inicial, Matric Año Final, CAGR,
YoY Promedio, YoY Volatilidad, Coef Variación YoY, Score Estable.
El patrón YoY a nivel de programa:
VAR _aniosV =
FILTER(
ADDCOLUMNS(VALUES('data_u_co_unificado'[AÑO]), "_v", [Matriculados]),
[_v] > 0
)
VAR _yoys =
ADDCOLUMNS(
_aniosV, "_yoy",
VAR _year = 'data_u_co_unificado'[AÑO]
VAR _vAct = [_v]
VAR _vAnt = CALCULATE([Matriculados], 'data_u_co_unificado'[AÑO] = _year - 1)
RETURN IF(_vAnt > 0, DIVIDE(_vAct, _vAnt) - 1)
)
RETURN AVERAGEX(FILTER(_yoys, NOT ISBLANK([_yoy])), [_yoy])
Tiempo (10 medidas): YTD, año anterior, semestre anterior, dos años
atrás, deltas, porcentajes y RANKX de programa.
El dim_calendario es una tabla calculada con CALENDAR(2014, 2024) +
columnas en español hardcodeadas (no confío en el locale de FORMAT(...,
"MMMM")):
ADDCOLUMNS(
CALENDAR(DATE(2014, 1, 1), DATE(2024, 12, 31)),
"Anio", YEAR([Date]),
"Mes Nombre", SWITCH(MONTH([Date]), 1,"Enero", 2,"Febrero", ...),
"Semestre", IF(MONTH([Date]) <= 6, 1, 2),
...
)
Marcado como date table con pbi calendar mark y conectado a la fact por
una columna Fecha_InicioS que se construyó al unificar.
Trampas técnicas que costaron horas
Estas no salen en los tutoriales, vale la pena dejarlas escritas:
pandasguarda IDs como1.0,2.0en CSV. Power BI no relacionaInt64conFloat. Solución:_coerce_id()conInt64nullable antes de exportar.- CINE como
Stringen la fact peroInt64en la dim. Relación rota silenciosa. Hay que ajustar el tipo en el M de la dim. pbi table createcon M no expone columnas automáticamente. Trastable createhay que llamarpbi column createpor cada columna y despuéstable refresh --type Full.@v,@yoyse eliminan del DAX. La CLI los interpreta como parámetros. Renombrar a_v,_yoy._añocomo variable DAX rompe el parser. Las medidas y columnas sí aceptanñ/tildes; las VAR locales no. Renombrar a_year.- Locale de
FORMAT([Date], "MMMM")puede dar inglés según el modelo. Hardcodear meses conSWITCH. - Consola Windows imprime
?en lugar de tildes. SetearPYTHONIOENCODING=utf-8antes de correr scripts.
Lo que dicen los datos
Top 5 programas por CAGR (con cobertura ≥ 8 años, base inicial ≥ 30, vigentes en 2024):
| Programa | CAGR | Inicial → Final |
|---|---|---|
| LIC. EN EDUCACIÓN INFANTIL | +98,0 % | 37 → 8.743 |
| LIC. EN LENGUAS EXTRANJERAS C/ ÉNFASIS EN INGLÉS | +69,2 % | 146 → 5.805 |
| LIC. EN EDUCACIÓN FÍSICA, RECREACIÓN Y DEPORTES | +63,7 % | 90 → 2.835 |
| TÉC. PROFESIONAL EN SOPORTE DE SISTEMAS EN INFORMÁTICA | +54,5 % | 158 → 5.128 |
| TECNOLOGIA EN GESTION DEL TALENTO HUMANO | +52,6 % | 56 → 3.842 |
Crecimiento estable (CAGR alto + baja volatilidad): INGENIERÍA DE
SOFTWARE y TECNOLOGIA EN DESARROLLO DE SOFTWARE lideran. El bloque
tech crece rápido y de forma sostenida; las licenciaturas crecen más
rápido pero a saltos (probablemente por convenios masivos puntuales o
cambios regulatorios).
Cobertura global: matriculados de primer curso pasaron de 782.781 (2014) a 1.058.036 (2024). +35 % total, pero la media anual ponderada es solo +3 %. El sector es estable en agregado; las dinámicas interesantes están a nivel de programa.
Cuándo replicar este enfoque
Este pipeline aplica casi tal cual a cualquier dataset público fragmentado por año:
- Datos de salud (RIPS, ENSIN), trabajo (PILA), tributación (DIAN), etc.
- Cualquier conjunto donde la estructura cambie año a año y existan documentos de «metadatos» del propio organismo.
- Cualquier modelo donde haya que distinguir entre el ID (estable) y el nombre (con variantes ortográficas).
Las cuatro decisiones que más rendimiento dieron:
- Diccionario de mapeo desde los metadatos del propio MEN, no a ojo.
- Auditoría dimensional sistemática (IDs con varios nombres / nombres con varios IDs) antes de modelar.
- Reparación regex de alta confianza + clave canónica normalizada por programa.
- TMDL editable para todo lo que la CLI de Power BI no expone (ocultar columnas, anotaciones, propiedades finas).
El proyecto completo termina con 18 dimensiones, 18 relaciones, 42 columnas ocultas, 21 medidas DAX y un calendario marcado, todo reproducible desde scripts de Python. La diferencia entre un análisis que se publica y uno que se queda en draft es exactamente esto: un pipeline que se vuelve a correr en 30 minutos cuando el MEN suba el archivo de 2025.

Deja una respuesta