Files
Dashboard_alumnos/app.py
2026-03-05 10:01:18 -06:00

414 lines
21 KiB
Python

import streamlit as st
import pandas as pd
from sqlalchemy import create_engine
import json
import altair as alt
st.set_page_config(layout="wide")
st.logo("./assets/logo_lasalle.png")
SPACE_BETWEEN_SECTIONS = 45
# ---------------------------------------------------------
# LIMPIEZA DE DATAFRAMES
# ---------------------------------------------------------
def _limpia_info(df):
# Limpieza de columnas
df["promedio"] = pd.to_numeric(df["promedio"].replace("", 0).fillna(0), errors="coerce").fillna(0)
df["creditos_totales"] = pd.to_numeric(df["creditos_totales"].replace("", 0).fillna(0), errors="coerce").astype(int)
df["servicio_social"] = df["servicio_social"].fillna(False)
df["calificacion"] = (
df["calificacion"]
.replace({"SD": 0, "NP": 0})
.fillna(0)
)
def _iniciales_materia(materia_desc):
palabras = materia_desc.split()
iniciales = ''.join([palabra[0].upper() for palabra in palabras if palabra])
return iniciales
# ---------------------------------------------------------
# CARGA Y PROCESAMIENTO DE DATOS DESDE POSTGRESQL
# ---------------------------------------------------------
@st.cache_data(show_spinner="Cargando información de la base de datos, esto puede tardar unos segundos...")
def _crea_df(conn_str):
engine = create_engine(conn_str)
# --- Carga inicial desde SQL ---
df_info_completa = pd.read_sql(
'''
SELECT al."Usuario_claveULSA" as "claveULSA", al."Alumno_sexo" as sexo, al."Alumno_semestre" as semestre, al."Carrera_id" as carrera_clave, ca."Carrera_prefijo" as carrera_prefijo,
"Alumno_serviciosocial" as servicio_social, "Alumno_beca" as beca, "Alumno_promedioSalle" as promedio, "Alumno_creditos" as creditos_totales, mca."Carrera_prefijo" as carrera_mat_prefijo,
m."Materia_desc" as materia, m."Materia_id" as materia_clave, p."Periodo_shortname" as periodo, m."Materia_semestre" as materia_semestre,
amc."Calificacion_calif" as calificacion, tc."TipoCalificacion_desc_corta" as tipo_calificacion
FROM "Alumno_Materia_Calificacion" amc
INNER JOIN "Materia" m ON amc."Materia_id" = m."Materia_id"
INNER JOIN "Periodo" p ON amc."Periodo_id" = p."Periodo_id"
INNER JOIN "TipoCalificacion" tc ON amc."TipoCalificacion_id" = tc."TipoCalificacion_id"
INNER JOIN "Alumno_view" al ON amc."Usuario_claveULSA" = al."Usuario_claveULSA"
INNER JOIN "Carrera" ca ON al."Carrera_id" = ca."Carrera_id"
INNER JOIN "PlanEstudio" mpe ON m."PlanEstudio_id" = mpe."PlanEstudio_id"
INNER JOIN "Carrera" mca ON mpe."Carrera_id" = mca."Carrera_id"
WHERE al."EstadoAlumno_isActivo" = true and al."Nivel_id" = 1
''',
engine
)
# LIMPIAR DATAFRAMES
_limpia_info(df_info_completa)
df_info_profesores = pd.read_sql(
'''
SELECT * from fs_profesorespromedio(NULL, 1, NULL);
''',
engine
)
df_info_profesores.rename(columns={
"Usuario_claveULSA": "claveULSA",
"Carrera_prefijo": "carrera_prefijo",
"Periodo_shortname": "periodo",
"promedio_final": "promedio",
}, inplace=True)
#df_info_profesores["profesor_nombre"] = df_info_profesores["Usuario_nombre"]+" "+df_info_profesores["Usuario_apellidos"]
return df_info_completa, df_info_profesores
# ---------------------------------------------------------
# FUNCIÓN INICIAL (USA CACHE)
# ---------------------------------------------------------
def _init_datos():
secrets = st.secrets["postgres"]
conn_str = (
f"postgresql+psycopg2://{secrets['user']}:{secrets['password']}"
f"@{secrets['host']}:{secrets['port']}/{secrets['database']}"
)
df_info_completa, df_info_profesores = _crea_df(conn_str)
return df_info_completa, df_info_profesores
# ---------------------------------------------------------
# DASHBOARD
# ---------------------------------------------------------
st.title("📊 Dashboard de alumnos de Ingeniería")
df_info_completa, df_info_profesores = _init_datos()
tabInfo, tabMaterias, tabAlumnos, tabProfesores = st.tabs([ "❓Acerca de", "📒 Análisis por Materias", "🚻 Análisis por Alumnos", "🧑‍🎓 Análisis por Profesores"])
with tabInfo:
st.write("Los datos presentados en este dashboard corresponden a los alumnos de Ingeniería de la Universidad La Salle, con información obtenida en la extracción de datos.")
st.write("El objetivo es proporcionar una visión general del desempeño académico, distribución de becas, servicio social y otros aspectos relevantes de los estudiantes.")
st.write("Los datos se han obtenido directamente de la base de datos institucional y se han limpiado para asegurar su precisión y utilidad en el análisis. En las siguientes secciones podrás explorar diferentes perspectivas sobre el rendimiento académico tanto a nivel de materias, alumnos individuales y profesores.")
st.write("Debido a la naturaleza de la extracción y limpieza de datos, es posible que algunos registros no estén completos o que ciertos promedios se calculen solo con la información disponible. Se recomienda interpretar los resultados considerando estas limitaciones.")
with tabMaterias:
# csv_historial = df_historial.to_csv(index=False).encode('utf-8')
# st.download_button(
# label="Descargar historial completo (CSV)",
# data=csv_historial,
# file_name='historial.csv',
# mime='text/csv',
# icon="📥",
# )
df = df_info_completa.copy()
periodos_list = df["periodo"].unique().tolist()
periodos_list.sort()
tipo_calif_list = df["tipo_calificacion"].unique().tolist()
col1, col2, col3 = st.columns([2,1,2])
with col1:
# periodos = st.pills("Selecciona un periodo", periodos_list, help="Los periodos 1 son de Ago-Dic, los 2 de Ene-Jun,los I de Invierno y los V de Verano.", default=periodos_list[-1])
periodos = st.select_slider("Selecciona un periodo", periodos_list, help="Los periodos 1 son de Ago-Dic, los 2 de Ene-Jun,los I de Invierno y los V de Verano.", value=periodos_list[-3])
with col2:
tipo_calif = st.pills("Selecciona el tipo de calificación", tipo_calif_list, help="Tipo de calificación: Ordinaria, Extraordinaria, etc.", selection_mode="multi", default=tipo_calif_list)
with col3:
carrera_mat_list = df_info_completa["carrera_mat_prefijo"].unique().tolist()
carrera_mat_list.sort()
carrera_mat = st.pills("Selecciona la carrera", carrera_mat_list, help="Carrera del alumno.", selection_mode="multi", default=carrera_mat_list, key="carrera_mat")
if len(tipo_calif) > 0:
#df_periodo = df[df["periodo"] == periodos]
df_periodo = df[(df["periodo"] == periodos) & (df["tipo_calificacion"].isin(tipo_calif))]
df_periodo_filtrado = df_periodo[df_periodo["carrera_mat_prefijo"].isin(carrera_mat)]
alumnos_periodo = df_periodo_filtrado["claveULSA"].nunique()
df_prom= df_periodo_filtrado.groupby(["semestre"]).mean({"calificacion": "mean", }).reset_index()
# st.write(df_periodo.head())
if df_prom.empty:
st.info("No hay datos para los filtros seleccionados.")
else:
st.space(SPACE_BETWEEN_SECTIONS)
row = st.container(horizontal=True)
with row:
st.metric("Número de materias", f"{df_periodo_filtrado.shape[0]:,}/{df_periodo.shape[0]:,}", border=True, help="Número de materias registradas en el periodo filtrado respecto al total de materias en ese periodo.")
st.metric("Promedio de calificación en periodo", f"{df_prom['calificacion'].mean():.2f}", border=True)
st.metric("Materias reprobadas en periodo", f"{df_periodo_filtrado[df_periodo_filtrado['calificacion'] < 6].shape[0]} ({df_periodo_filtrado[df_periodo_filtrado['calificacion'] < 6].shape[0] / df_periodo_filtrado.shape[0] * 100:.2f}%)", border=True)
st.metric("Alumnos registrados en periodo", f"{alumnos_periodo:,}", border=True, help="Número de alumnos con calificaciones registradas en el periodo filtrado.")
st.space(SPACE_BETWEEN_SECTIONS)
col1, col2 = st.columns(2)
with col1:
st.subheader(f"Promedios del periodo")
st.bar_chart(data=df_prom, x="semestre", y="calificacion")
st.subheader(f"Promedios del periodo agrupado por sexo")
df_sexo = (
df_periodo_filtrado
.groupby(["semestre", "sexo"])["calificacion"]
.mean()
.reset_index()
)
chart = (
alt.Chart(df_sexo)
.mark_line(point=True) # 👈 activa puntos para ver cada valor
.encode(
x=alt.X("semestre:O", title="Semestre"),
y=alt.Y("calificacion:Q", title="Calificación promedio"),
color=alt.Color("sexo:N", title="Sexo"),
tooltip=["semestre", "sexo", "calificacion"]
)
.properties(height=400)
)
st.altair_chart(chart, width='stretch')
with col2:
st.subheader(f"Frecuencia de calificaciones por materia")
df_materias = df_periodo_filtrado.groupby(["calificacion"]).count().reset_index()
df_materias = df_materias.sort_values(by="calificacion", ascending=False)
df_materias["total"] = df_materias["materia_clave"]
st.bar_chart(data=df_materias, x="calificacion", y="total")
min_alumnos_materia = st.slider("Mínimo de alumnos para análisis de reprobación", min_value=1, max_value=40, value=10, help="Número mínimo de alumnos inscritos en una materia para que sea considerada en el análisis de reprobación.")
st.subheader(f"Materias con mayor reprobación")
# Crear columna con el nombre corto (iniciales + carrera)
df_base_reprobacion = df_periodo_filtrado.copy()
df_base_reprobacion["materia_corta"] = (
df_base_reprobacion["materia"].apply(_iniciales_materia)
+ " [" + df_base_reprobacion["carrera_mat_prefijo"] + "]"
)
# Totales
df_reprobadas = df_base_reprobacion[df_base_reprobacion["calificacion"] < 6].groupby(["materia_corta", "materia"]).size().reset_index(name="total_reprobadas")
df_total = df_base_reprobacion.groupby(["materia_corta", "materia"]).size().reset_index(name="total_registradas")
# Unir ambas
df_merge = df_total.merge(df_reprobadas, on=["materia_corta", "materia"], how="left")
# Llenar NaN en materias sin reprobados
df_merge["total_reprobadas"] = df_merge["total_reprobadas"].fillna(0)
# Crear porcentaje
df_merge["porcentaje_reprobacion"] = (
df_merge["total_reprobadas"] / df_merge["total_registradas"] * 100
)
df_merge = df_merge[(df_merge["total_registradas"] >= min_alumnos_materia) & (df_merge["porcentaje_reprobacion"] > 0) ]
# Top 10 según % reprobación
df_merge = df_merge.sort_values("porcentaje_reprobacion", ascending=False).head( 10)
chart = (
alt.Chart(df_merge)
.mark_bar()
.encode(
y=alt.Y("materia_corta:N", sort='-x', title="Materia"),
x=alt.X("porcentaje_reprobacion:Q", title="Porcentaje de reprobación (%)"),
tooltip=[
"materia:N",
"total_registradas:Q",
"total_reprobadas:Q",
alt.Tooltip("porcentaje_reprobacion:Q", format=".2f")
]
)
.properties(height=400)
)
st.altair_chart(chart, width='stretch')
# with st.expander("Ver datos filtrados"):
# st.dataframe(df_prom)
else:
st.warning("Por favor selecciona al menos un tipo de calificación para mostrar los datos.")
with tabAlumnos:
# csv_info = df_info_completa.to_csv(index=False).encode('utf-8')
# st.download_button(
# label="Descargar datos completos (CSV)",
# data=csv_info,
# file_name='info_completa.csv',
# mime='text/csv',
# icon="📥",
# )
# cambiar beca a numérico
df_alumnos = df_info_completa.copy()
df_alumnos = df_alumnos.drop_duplicates(subset=["claveULSA"])
total_alumnos = df_alumnos.shape[0]
df_alumnos["beca"] = (df_info_completa["beca"].replace("No tiene beca", "0"))
df_alumnos["beca"] = df_alumnos["beca"].fillna("0")
df_alumnos["beca"] = pd.to_numeric(df_alumnos["beca"].astype(str).str.replace("%", ""), errors="coerce").fillna(0)
col1, col2, col3 = st.columns([1,2,2])
with col1:
genero_list = df_alumnos["sexo"].unique().tolist()
genero = st.pills("Selecciona el género", genero_list, help="Género del alumno.", selection_mode="multi", default=genero_list)
with col2:
semestre_list = df_alumnos["semestre"].unique().tolist()
semestre_list.sort()
semestre = st.pills("Selecciona el semestre", semestre_list, help="Semestre actual del alumno.", selection_mode="multi", default=semestre_list)
with col3:
carrera_list = df_info_completa["carrera_prefijo"].unique().tolist()
carrera_list.sort()
carrera = st.pills("Selecciona la carrera", carrera_list, help="Carrera del alumno.", selection_mode="multi", default=carrera_list)
if len(genero) > 0 and len(semestre) > 0 and len(carrera) > 0:
df_info_filtrado = df_alumnos.copy()
df_info_filtrado = df_info_filtrado[
(df_info_filtrado["sexo"].isin(genero)) &
(df_info_filtrado["semestre"].isin(semestre)) &
(df_info_filtrado["carrera_prefijo"].isin(carrera))
]
servicio_social_count = df_info_filtrado[df_info_filtrado["servicio_social"] == True].shape[0]
servicio_social_perc = (servicio_social_count / df_info_filtrado.shape[0]) * 100 if df_info_filtrado.shape[0] > 0 else 0
beca_perc = (df_info_filtrado['beca'] > 0).sum()/df_info_filtrado.shape[0]*100 if df_info_filtrado.shape[0] > 0 else 0
promedio_alumnos = df_info_filtrado[df_info_filtrado["promedio"] > 0]["promedio"].mean()
st.space(SPACE_BETWEEN_SECTIONS)
row2 = st.container(horizontal=True)
with row2:
st.metric("Total de alumnos", f"{df_info_filtrado.shape[0]:,} ({df_info_filtrado.shape[0]/total_alumnos*100:.2f}%)", border=True, help="Número total y porcentaje de alumnos filtrados respecto al total de alumnos.")
st.metric("Alumnos con beca", f"{(df_info_filtrado['beca'] > 0).sum():,} ({beca_perc:.2f}%)", border=True, help="Número y porcentaje de alumnos que cuentan con algún tipo de beca.")
st.metric("Creditos promedio", f"{df_info_filtrado['creditos_totales'].mean():.2f}", border=True, help="Pomedio de créditos totales de formación")
st.metric("Alumnos con servicio social", f"{servicio_social_count:,} ({servicio_social_perc:.2f}%)", border=True)
st.metric("Promedio general", f"{promedio_alumnos:.2f}", border=True, help="Promedio general de los alumnos filtrados. No cuenta de nuevo ingreso")
st.space(SPACE_BETWEEN_SECTIONS)
col1, col2 = st.columns(2)
with col1:
st.subheader("Distribución de becas")
beca_counts = df_info_filtrado["beca"].value_counts().reset_index()
beca_counts.columns = ["Beca (%)", "Total"]
st.bar_chart(data=beca_counts, x="Beca (%)", y="Total")
with col2:
st.subheader("Promedio de alumnos por carrera")
carrera_prom = df_info_filtrado[df_info_filtrado["promedio"] > 0]
carrera_prom = carrera_prom.groupby("carrera_prefijo").mean({"promedio": "mean"}).reset_index()
st.bar_chart(data=carrera_prom, x="carrera_prefijo", y="promedio")
# st.write(f"Total de alumnos filtrados: {df_info_filtrado.shape[0]}")
#alumnos por carrera
else:
st.warning("Por favor selecciona al menos un valor en cada filtro para mostrar los datos.")
with tabProfesores:
df_prof = df_info_profesores.copy()
col1, col2 = st.columns(2)
with col1:
# periodos = st.pills("Selecciona un periodo", periodos_list, help="Los periodos 1 son de Ago-Dic, los 2 de Ene-Jun,los I de Invierno y los V de Verano.", default=periodos_list[-1])
periodos_prof = st.select_slider("Selecciona un periodo", periodos_list, help="Los periodos 1 son de Ago-Dic, los 2 de Ene-Jun,los I de Invierno y los V de Verano.", value=periodos_list[-3], key="periodo_prof")
with col2:
carrera_prof = st.pills("Selecciona la carrera", carrera_mat_list, help="Carrera en la que imparte el profesor.", selection_mode="multi", default=carrera_mat_list, key="carrera_prof")
df_prof_periodo = df_prof[(df_prof["periodo"] == periodos_prof) & (df_prof["carrera_prefijo"].isin(carrera_prof))]
st.subheader("Buscar profesor")
col1, col2, col3 = st.columns([1,1,2])
with col1:
profesor_buscar = st.selectbox("Selecciona un profesor", df_prof_periodo["profesor_nombre"].unique().tolist(), key="profesor_buscar")
with col2:
# 1. Calcular el promedio por profesor
df_rank = (df_prof_periodo.groupby("profesor_nombre", as_index=False).agg(promedio=("promedio", "mean")))
# 2. Ordenar por promedio (de mayor a menor) y resetear índice
df_rank = df_rank.sort_values(by="promedio",ascending=False).reset_index(drop=True)
# 3. Agregar ranking comenzando en 1
df_rank["ranking"] = df_rank.index + 1
# 4. Filtrar el profesor seleccionado
fila_prof = df_rank[df_rank["profesor_nombre"] == profesor_buscar]
if fila_prof.empty:
st.warning("El profesor seleccionado no tiene registros en este periodo.")
else:
ranking_profesor = int(fila_prof["ranking"].iloc[0])
promedio_profesor = float(fila_prof["promedio"].iloc[0])
st.metric("Promedio del profesor", f"{promedio_profesor:.2f}", border=True, delta=f"{ranking_profesor}/{df_rank.shape[0]}", help="Promedio de calificaciones del profesor en el periodo seleccionado.")
with col3:
st.info("En los promedios de los profesores solo se consideran calificaiones ordinarias y que no son 0 (SD, NP).")
col1, col2 = st.columns(2)
with col1:
st.subheader("Promedios más altos por profesor")
#df_prof_periodo_top = df_prof_periodo.groupby("profesor_nombre").mean({"promedio": "mean"}).reset_index()
df_prof_periodo_top = df_prof_periodo.groupby("profesor_nombre").agg(
promedio=("promedio", "mean"),
total_alumnos=("total_alumnos", "sum") # O "count" si viene repetido por alumno
).reset_index()
df_prof_periodo_top = df_prof_periodo_top.sort_values(by="promedio", ascending=False).head(10)
chart = (
alt.Chart(df_prof_periodo_top)
.mark_bar()
.encode(
y=alt.Y("profesor_nombre:N", sort='-x', title="Profesores", axis=alt.Axis(labelLimit=300)),
x=alt.X("promedio:Q", title="Promedio de calificaciones"),
tooltip=[
"profesor_nombre:N",
"promedio:Q",
"total_alumnos:Q"
]
)
.properties(height=400)
)
st.altair_chart(chart, width='stretch')
with col2:
st.subheader("Promedios más bajos por profesor")
#df_prof_periodo_top = df_prof_periodo.groupby("profesor_nombre").mean({"promedio": "mean"}).reset_index()
df_prof_periodo_top = df_prof_periodo.groupby("profesor_nombre").agg(
promedio=("promedio", "mean"),
total_alumnos=("total_alumnos", "sum") # O "count" si viene repetido por alumno
).reset_index()
df_prof_periodo_top = df_prof_periodo_top.sort_values(by="promedio", ascending=True).head(10)
chart = (
alt.Chart(df_prof_periodo_top)
.mark_bar()
.encode(
y=alt.Y("profesor_nombre:N", sort='x', title="Profesores", axis=alt.Axis(labelLimit=300)),
x=alt.X("promedio:Q", title="Promedio de calificaciones"),
tooltip=[
"profesor_nombre:N",
"promedio:Q",
"total_alumnos:Q"
]
)
.properties(height=400)
)
st.altair_chart(chart, width='stretch')