From 857f8f94d4abb1cf2144451d72df25c2565df8b7 Mon Sep 17 00:00:00 2001 From: AlexLara Date: Tue, 3 Mar 2026 13:05:51 -0600 Subject: [PATCH] Carga inicial --- .gitignore | 3 + .streamlit/config.toml | 29 +++ .streamlit/config_prod.toml | 49 ++++ .streamlit/secrets_template.toml | 7 + Dockerfile | 21 ++ README.md | 14 ++ app.py | 413 +++++++++++++++++++++++++++++++ assets/logo_lasalle.png | Bin 0 -> 13079 bytes requirements.txt | 7 + 9 files changed, 543 insertions(+) create mode 100644 .gitignore create mode 100644 .streamlit/config.toml create mode 100644 .streamlit/config_prod.toml create mode 100644 .streamlit/secrets_template.toml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 assets/logo_lasalle.png create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fe7ad8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv/ +test/ +.streamlit/secrets.toml \ No newline at end of file diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000..44ab21c --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,29 @@ +# for local development +[server] + +#no default browser +headless = true + +# for local development +runOnSave = true + +# Limit uploads (MB). Excel files here are small; adjust if needed +maxUploadSize = 5 + +# for local development +fileWatcherType="poll" + + +[theme] +base="light" +primaryColor = "#001d68" +backgroundColor = "#FFFFFF" +secondaryBackgroundColor = "#F4F6FA" +textColor = "#111827" +font = "sans serif" + +[client] +toolbarMode = "minimal" + +[ui] +hideTopBar = true diff --git a/.streamlit/config_prod.toml b/.streamlit/config_prod.toml new file mode 100644 index 0000000..5ec701d --- /dev/null +++ b/.streamlit/config_prod.toml @@ -0,0 +1,49 @@ +## Streamlit config — Production defaults + +[server] +# Run without launching a browser +headless = true + +# Listen on all interfaces (useful for containers/VMs) +address = "0.0.0.0" + +# Fixed port; override with STREAMLIT_SERVER_PORT if needed +port = 8501 + +# Behind a reverse proxy you typically want CORS disabled +enableCORS = false + +# Keep XSRF protection ON in production +enableXsrfProtection = true + +# Limit uploads (MB). Excel files here are small; adjust if needed +maxUploadSize = 5 + +# Reduce CPU usage by disabling file watching (no auto-reload) +fileWatcherType = "none" + +# Don’t auto-rerun on file save in production +runOnSave = false + +# Better network performance for many clients +enableWebsocketCompression = true + +# Optional: if serving behind a subpath (e.g., https://host/sgu) +# baseUrlPath = "sgu" + +[browser] +# Disable Streamlit telemetry in production +gatherUsageStats = false + +[client] +# Hide developer toolbar in production +toolbarMode = "viewer" + +[logger] +# Use info or warning for less noise +level = "info" + +## Secrets +# For production, set a strong cookie secret via environment variable: +# STREAMLIT_SERVER_COOKIE_SECRET="" +# Don’t store secrets in this file. \ No newline at end of file diff --git a/.streamlit/secrets_template.toml b/.streamlit/secrets_template.toml new file mode 100644 index 0000000..7901e93 --- /dev/null +++ b/.streamlit/secrets_template.toml @@ -0,0 +1,7 @@ +# Credenciales de PostgreSQL +[postgres] +host = "host" +port = "5432" +database = "database" +user = "usr" +password = "P4ssw0rd." diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..610ad5e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.10-slim + +WORKDIR /code + +RUN apt-get update && apt-get install -y \ + build-essential \ + curl \ + software-properties-common \ + git \ + && rm -rf /var/lib/apt/lists/* + +COPY . . + +RUN mkdir -p /code/.streamlit + +RUN pip install --upgrade pip +RUN pip install --no-cache-dir -r requirements.txt + +EXPOSE 8501 + +ENTRYPOINT ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0", "--server.runOnSave=true"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f5bab9 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +## Instalación (Windows PowerShell) + +```powershell +Activar el entorno +.\.venv\Scripts\Activate.ps1 # powershell +source .venv/Scripts/activate # git bash +call venv\Scripts\activate.bat +``` + +## Ejecución + +```powershell +streamlit run app.py +``` \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..a948b46 --- /dev/null +++ b/app.py @@ -0,0 +1,413 @@ +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("No se consideran calificaciones de 0 (SD, NP) en los promedios de los profesores.") + + + + 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') diff --git a/assets/logo_lasalle.png b/assets/logo_lasalle.png new file mode 100644 index 0000000000000000000000000000000000000000..02fc6086885645d044474301e65c289bc799f5fd GIT binary patch literal 13079 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