Spaces:
Sleeping
Sleeping
| # app.py — Flexible Marks Clustering Dashboard | |
| # Supports: | |
| # - Any combination of assessments | |
| # - Absent values like A / Absent -> treated as 0 | |
| # - Excel/CSV upload | |
| # - Student + Section clustering | |
| # - Manager graphs | |
| # - PCA plots | |
| # - Excel report download | |
| # - Hugging Face Spaces compatible launch | |
| # - Shows only uploaded assessment-related outputs | |
| import io | |
| import tempfile | |
| import numpy as np | |
| import pandas as pd | |
| import gradio as gr | |
| from sklearn.preprocessing import StandardScaler | |
| from sklearn.cluster import KMeans | |
| from sklearn.decomposition import PCA | |
| from sklearn.metrics import silhouette_score | |
| import plotly.express as px | |
| MAXIMA = { | |
| "Quiz": 10.0, | |
| "Mid": 20.0, | |
| "Lab Report": 20.0, | |
| "Lab Activity": 10.0, | |
| "Final": 40.0, | |
| } | |
| ASSESS_COLS = ["Quiz", "Mid", "Lab Report", "Lab Activity", "Final"] | |
| # ---------------------------- | |
| # Reading / mapping / cleaning | |
| # ---------------------------- | |
| def _normalize_colname(c: str) -> str: | |
| c = str(c).strip().lower() | |
| c = c.replace("_", " ").replace("-", " ") | |
| c = c.replace("0", "o") | |
| c = " ".join(c.split()) | |
| return c | |
| def _read_excel_safely(path: str) -> pd.DataFrame: | |
| df_try = pd.read_excel(path) | |
| if len(df_try.columns) > 0 and all(str(c).startswith("Unnamed") for c in df_try.columns): | |
| df_try = pd.read_excel(path, header=1) | |
| return df_try | |
| def _map_columns(df: pd.DataFrame): | |
| cols = list(df.columns) | |
| norm = {_normalize_colname(c): c for c in cols} | |
| candidates = { | |
| "studentid": [ | |
| "studentid", "student id", "student no", "student number", "id", | |
| "roll", "roll no", "roll number" | |
| ], | |
| "section": ["section", "sec", "class", "group", "batch", "tutorial", "lab section"], | |
| "quiz": ["quiz", "quiz (10)", "quiz(10)", "quiz out of 10", "quizzes", "qz"], | |
| "mid": ["mid", "mid (20)", "mid(20)", "mid out of 20", "midterm", "mid term"], | |
| "lab report": [ | |
| "lab report", "lab report (20)", "lab report(20)", "labreport", | |
| "report", "lab reports", "lab", "lab(20)", "lab (20)" | |
| ], | |
| "lab activity": [ | |
| "lab activity", "lab activity (10)", "lab activity(10)", "labactivity", | |
| "activity", "lab act", "lab octivity(10)", "lab 0ctivity(10)" | |
| ], | |
| "final": [ | |
| "final", "final (40)", "final(40)", "final out of 40", | |
| "final exam", "exam", "endterm", "end term" | |
| ], | |
| } | |
| mapped = {} | |
| for logical, opts in candidates.items(): | |
| for o in opts: | |
| o2 = _normalize_colname(o) | |
| if o2 in norm: | |
| mapped[logical] = norm[o2] | |
| break | |
| if "section" not in mapped: | |
| raise ValueError("Section column is required.") | |
| present_assessments = [k for k in ["quiz", "mid", "lab report", "lab activity", "final"] if k in mapped] | |
| if len(present_assessments) < 2: | |
| raise ValueError( | |
| "Please provide at least two assessment components. " | |
| "Example: Mid + Final, or Lab Report + Lab Activity." | |
| ) | |
| rename_map = {mapped["section"]: "Section"} | |
| if "studentid" in mapped: | |
| rename_map[mapped["studentid"]] = "StudentID" | |
| logical_to_real = { | |
| "quiz": "Quiz", | |
| "mid": "Mid", | |
| "lab report": "Lab Report", | |
| "lab activity": "Lab Activity", | |
| "final": "Final", | |
| } | |
| for k in present_assessments: | |
| rename_map[mapped[k]] = logical_to_real[k] | |
| df2 = df.rename(columns=rename_map).copy() | |
| if "StudentID" not in df2.columns: | |
| df2.insert(0, "StudentID", [f"S{i+1:03d}" for i in range(len(df2))]) | |
| active_cols = [logical_to_real[k] for k in present_assessments] | |
| return df2, active_cols | |
| def _clean(df: pd.DataFrame, active_cols): | |
| df = df.copy() | |
| df["StudentID"] = df["StudentID"].astype(str).str.strip() | |
| df["Section"] = df["Section"].astype(str).str.strip() | |
| absent_tokens = ["A", "a", "Absent", "ABSENT", "ab", "AB", ""] | |
| for c in active_cols: | |
| df[c] = df[c].replace(absent_tokens, 0) | |
| df[c] = pd.to_numeric(df[c], errors="coerce") | |
| df = df.dropna(subset=["Section"] + active_cols) | |
| df = df[(df["StudentID"] != "") & (df["Section"] != "")] | |
| for c in active_cols: | |
| df[c] = df[c].clip(lower=0, upper=MAXIMA[c]) | |
| return df | |
| # ---------------------------- | |
| # Feature engineering | |
| # ---------------------------- | |
| def _features(df: pd.DataFrame, active_cols): | |
| df = df.copy() | |
| for c in active_cols: | |
| df[f"{c}_pct"] = df[c] / MAXIMA[c] | |
| pct_cols = [f"{c}_pct" for c in active_cols] | |
| df["mean_score"] = df[pct_cols].mean(axis=1) | |
| df["std_score"] = df[pct_cols].std(axis=1).fillna(0) | |
| lab_cols = [c for c in ["Lab Report", "Lab Activity"] if c in active_cols] | |
| theory_cols = [c for c in ["Quiz", "Mid", "Final"] if c in active_cols] | |
| if lab_cols: | |
| df["lab_avg"] = df[[f"{c}_pct" for c in lab_cols]].mean(axis=1) | |
| else: | |
| df["lab_avg"] = df["mean_score"] | |
| if theory_cols: | |
| df["theory_avg"] = df[[f"{c}_pct" for c in theory_cols]].mean(axis=1) | |
| else: | |
| df["theory_avg"] = df["mean_score"] | |
| if "Final" in active_cols and any(c in active_cols for c in ["Quiz", "Mid"]): | |
| exam_ref = [f"{c}_pct" for c in ["Quiz", "Mid"] if c in active_cols] | |
| df["exam_gap"] = df["Final_pct"] - df[exam_ref].mean(axis=1) | |
| else: | |
| df["exam_gap"] = 0.0 | |
| coursework_cols = [c for c in ["Quiz", "Mid", "Lab Report", "Lab Activity"] if c in active_cols] | |
| if coursework_cols and "Final" in active_cols: | |
| df["coursework_avg"] = df[[f"{c}_pct" for c in coursework_cols]].mean(axis=1) | |
| df["final_vs_coursework_gap"] = df["Final_pct"] - df["coursework_avg"] | |
| else: | |
| df["coursework_avg"] = df["mean_score"] | |
| df["final_vs_coursework_gap"] = 0.0 | |
| if lab_cols and theory_cols: | |
| df["lab_theory_gap"] = df["lab_avg"] - df["theory_avg"] | |
| else: | |
| df["lab_theory_gap"] = 0.0 | |
| return df | |
| # ---------------------------- | |
| # Clustering + PCA plots | |
| # ---------------------------- | |
| def _kmeans(df_feat: pd.DataFrame, k=3): | |
| n_samples = len(df_feat) | |
| if n_samples < 3: | |
| raise ValueError("Not enough rows for clustering. Please upload more data.") | |
| k = min(k, n_samples) | |
| if k < 2: | |
| raise ValueError("Not enough rows for clustering.") | |
| scaler = StandardScaler() | |
| X = scaler.fit_transform(df_feat.values) | |
| model = KMeans(n_clusters=k, random_state=42, n_init=10) | |
| labels = model.fit_predict(X) | |
| sil = None | |
| try: | |
| if n_samples >= k + 1 and len(set(labels)) > 1: | |
| sil = float(silhouette_score(X, labels)) | |
| except Exception: | |
| sil = None | |
| return labels, X, sil, k | |
| def _pca_fig(X, labels, hover_df, title): | |
| if len(X) < 2: | |
| fig = px.scatter(title=title) | |
| fig.update_layout(height=420) | |
| return fig | |
| pca = PCA(n_components=2, random_state=42) | |
| coords = pca.fit_transform(X) | |
| plot_df = hover_df.copy() | |
| plot_df["PC1"] = coords[:, 0] | |
| plot_df["PC2"] = coords[:, 1] | |
| plot_df["Cluster"] = labels.astype(int).astype(str) | |
| fig = px.scatter( | |
| plot_df, x="PC1", y="PC2", | |
| color="Cluster", | |
| hover_data=list(hover_df.columns), | |
| title=title | |
| ) | |
| fig.update_layout(height=420) | |
| return fig | |
| # ---------------------------- | |
| # Reasons | |
| # ---------------------------- | |
| def _student_reason(r, has_final, has_lab_theory): | |
| s = float(r.get("std_score", 0)) | |
| eg = float(r.get("final_vs_coursework_gap", 0)) | |
| ltg = float(r.get("lab_theory_gap", 0)) | |
| bullets = [] | |
| if s <= 0.10: | |
| bullets.append("Low variation across available assessments.") | |
| elif s <= 0.18: | |
| bullets.append("Moderate variation across available assessments.") | |
| else: | |
| bullets.append("High variation across available assessments.") | |
| if has_final: | |
| if abs(eg) >= 0.25: | |
| bullets.append("Large mismatch between final and coursework trend.") | |
| elif abs(eg) >= 0.15: | |
| bullets.append("Noticeable final vs coursework difference.") | |
| if has_lab_theory: | |
| if abs(ltg) >= 0.20: | |
| bullets.append("Large mismatch between lab and theory performance.") | |
| elif abs(ltg) >= 0.12: | |
| bullets.append("Some lab vs theory mismatch.") | |
| flag = (s > 0.18) | |
| if has_final: | |
| flag = flag or (abs(eg) >= 0.25) | |
| if has_lab_theory: | |
| flag = flag or (abs(ltg) >= 0.20) | |
| if flag: | |
| rec = "Recommendation: Consider academic review for assessment alignment and/or student support." | |
| else: | |
| rec = "Recommendation: Pattern looks reasonable; routine moderation is sufficient." | |
| if not bullets: | |
| bullets.append("Assessment pattern looks stable.") | |
| return "Key observations:\n- " + "\n- ".join(bullets) + "\n\n" + rec | |
| def _section_reason(r, has_final, has_lab_theory): | |
| s = float(r.get("std_score", 0)) | |
| eg = float(r.get("final_vs_coursework_gap", 0)) | |
| ltg = float(r.get("lab_theory_gap", 0)) | |
| bullets = [] | |
| if s <= 0.08: | |
| bullets.append("Uniform distribution across available assessments.") | |
| elif s <= 0.14: | |
| bullets.append("Expected variation across students in this section.") | |
| else: | |
| bullets.append("Higher variability across section assessment pattern.") | |
| if has_final and abs(eg) >= 0.18: | |
| bullets.append("Final vs coursework mismatch at section level.") | |
| if has_lab_theory and abs(ltg) >= 0.15: | |
| bullets.append("Lab vs theory mismatch at section level.") | |
| flag = (s > 0.14) | |
| if has_final: | |
| flag = flag or (abs(eg) >= 0.18) | |
| if has_lab_theory: | |
| flag = flag or (abs(ltg) >= 0.15) | |
| if flag: | |
| rec = "Recommendation: Review assessment design/moderation consistency for this section." | |
| else: | |
| rec = "Recommendation: Distribution appears aligned; routine moderation is sufficient." | |
| if not bullets: | |
| bullets.append("Section pattern looks stable.") | |
| return "Key observations:\n- " + "\n- ".join(bullets) + "\n\n" + rec | |
| # ---------------------------- | |
| # Cluster meanings + manager text | |
| # ---------------------------- | |
| def _assign_cluster_meanings(profile_df: pd.DataFrame, cluster_col: str, has_final, has_lab_theory): | |
| dfp = profile_df.copy() | |
| cids = [int(x) for x in dfp[cluster_col].tolist()] | |
| if len(cids) == 0: | |
| return {} | |
| if has_final and has_lab_theory: | |
| cid_final = int(dfp.loc[dfp["final_vs_coursework_gap"].idxmin(), cluster_col]) | |
| cid_lab = int(dfp.loc[dfp["lab_theory_gap"].idxmax(), cluster_col]) | |
| meaning = {cid_final: "Final Exam Misalignment", cid_lab: "Lab–Theory Mismatch"} | |
| for cid in cids: | |
| if cid not in meaning: | |
| meaning[cid] = "Balanced / Acceptable Pattern" | |
| return meaning | |
| if has_final: | |
| cid_final = int(dfp.loc[dfp["final_vs_coursework_gap"].idxmin(), cluster_col]) | |
| meaning = {cid_final: "Final Exam Misalignment"} | |
| for cid in cids: | |
| if cid not in meaning: | |
| meaning[cid] = "Balanced / Acceptable Pattern" | |
| return meaning | |
| if has_lab_theory: | |
| cid_lab = int(dfp.loc[dfp["lab_theory_gap"].idxmax(), cluster_col]) | |
| meaning = {cid_lab: "Lab–Theory Mismatch"} | |
| for cid in cids: | |
| if cid not in meaning: | |
| meaning[cid] = "Balanced / Acceptable Pattern" | |
| return meaning | |
| return {cid: "Balanced / Acceptable Pattern" for cid in cids} | |
| def _traffic_light(meaning: str) -> str: | |
| return { | |
| "Balanced / Acceptable Pattern": "🟢 Green", | |
| "Lab–Theory Mismatch": "🟡 Amber", | |
| "Final Exam Misalignment": "🔴 Red", | |
| }.get(meaning, "⚪") | |
| def _manager_signal(meaning: str) -> str: | |
| if meaning == "Balanced / Acceptable Pattern": | |
| return "Components are aligned; routine moderation only." | |
| if meaning == "Lab–Theory Mismatch": | |
| return "Labs stronger than theory; consider theory support and alignment check." | |
| if meaning == "Final Exam Misalignment": | |
| return "Final lower than coursework; review exam difficulty/format/weighting." | |
| return "" | |
| def _brief_cluster_explanation(meaning: str) -> str: | |
| if meaning == "Balanced / Acceptable Pattern": | |
| return "No large gaps in the available assessments." | |
| if meaning == "Lab–Theory Mismatch": | |
| return "Lab/practical scores noticeably higher than theory." | |
| if meaning == "Final Exam Misalignment": | |
| return "Final exam performance lower than coursework trend." | |
| return "" | |
| def _balanced_two_lines_for_managers() -> str: | |
| return ( | |
| "Balanced / Acceptable Pattern: Marks are fairly consistent across the available assessments.\n" | |
| "This suggests assessment design is broadly aligned; only routine moderation is needed." | |
| ) | |
| def _build_distribution_table(labels: pd.Series, meaning_map: dict, entity_name: str) -> pd.DataFrame: | |
| total = int(labels.shape[0]) | |
| counts = labels.value_counts().sort_index() | |
| rows = [] | |
| for cid, cnt in counts.items(): | |
| cid = int(cid) | |
| meaning = meaning_map.get(cid, f"Cluster {cid}") | |
| rows.append({ | |
| "Cluster": cid, | |
| "Cluster Meaning": meaning, | |
| "Traffic Light": _traffic_light(meaning), | |
| "Count": int(cnt), | |
| f"% of {entity_name}": round((int(cnt) / total) * 100, 1) if total else 0.0, | |
| "Why this pattern": _brief_cluster_explanation(meaning), | |
| "Manager interpretation": _manager_signal(meaning), | |
| }) | |
| return pd.DataFrame(rows) | |
| # ---------------------------- | |
| # Manager graphs + recommendations | |
| # ---------------------------- | |
| def _manager_graphs_and_reco(df_students: pd.DataFrame, df_sections: pd.DataFrame, has_final): | |
| s_counts = df_students["StudentClusterMeaning"].value_counts().reset_index() | |
| s_counts.columns = ["Pattern", "Count"] | |
| fig_students = px.pie(s_counts, values="Count", names="Pattern", title="Students by Assessment Pattern") | |
| fig_students.update_layout(height=360) | |
| sec_plot = df_sections[["Section", "SectionClusterMeaning"]].copy() | |
| sec_plot["Count"] = 1 | |
| fig_sections = px.bar( | |
| sec_plot, | |
| x="Section", | |
| y="Count", | |
| color="SectionClusterMeaning", | |
| title="Section-wise Assessment Pattern (Each Bar = One Section)", | |
| hover_data=["Section", "SectionClusterMeaning"] | |
| ) | |
| fig_sections.update_layout( | |
| height=380, | |
| yaxis_title="Section", | |
| xaxis_title="Section Number", | |
| legend_title="Assessment Pattern" | |
| ) | |
| fig_sections.update_xaxes(tickangle=45) | |
| if has_final: | |
| avg_coursework = df_students["coursework_avg"].mean() * 100 | |
| avg_final = df_students["Final_pct"].mean() * 100 | |
| fig_exam = px.bar( | |
| x=["Average Coursework", "Average Final Exam"], | |
| y=[avg_coursework, avg_final], | |
| title="Overall Coursework vs Final Exam (Average %)", | |
| labels={"x": "Assessment", "y": "Average %"} | |
| ) | |
| else: | |
| overall_avg = df_students["mean_score"].mean() * 100 | |
| fig_exam = px.bar( | |
| x=["Average of Uploaded Assessments"], | |
| y=[overall_avg], | |
| title="Overall Average of Uploaded Assessments (%)", | |
| labels={"x": "Assessment", "y": "Average %"} | |
| ) | |
| fig_exam.update_layout(height=360) | |
| total_students = len(df_students) | |
| pct_bal = (df_students["StudentClusterMeaning"].eq("Balanced / Acceptable Pattern").mean() * 100) if total_students else 0 | |
| pct_lab = (df_students["StudentClusterMeaning"].eq("Lab–Theory Mismatch").mean() * 100) if total_students else 0 | |
| pct_final = (df_students["StudentClusterMeaning"].eq("Final Exam Misalignment").mean() * 100) if total_students else 0 | |
| lines = [f"• {pct_bal:.1f}% of students show a balanced assessment pattern."] | |
| if pct_lab > 0: | |
| lines.append(f"• {pct_lab:.1f}% show stronger labs than theory.") | |
| if has_final: | |
| lines.append(f"• {pct_final:.1f}% show final exam outcomes lower than coursework.") | |
| lines.append("• These are QA/moderation signals; they do not indicate student misconduct.") | |
| rec = "\n".join(lines) | |
| return fig_students, fig_sections, fig_exam, rec | |
| # ---------------------------- | |
| # Excel download | |
| # ---------------------------- | |
| def _make_excel(student_out, section_out, student_profile, section_profile, | |
| student_distribution, section_distribution, section_list) -> bytes: | |
| out = io.BytesIO() | |
| with pd.ExcelWriter(out, engine="openpyxl") as writer: | |
| student_out.to_excel(writer, index=False, sheet_name="Student_Level") | |
| section_out.to_excel(writer, index=False, sheet_name="Section_Level") | |
| student_profile.to_excel(writer, index=False, sheet_name="Student_Cluster_Profile") | |
| section_profile.to_excel(writer, index=False, sheet_name="Section_Cluster_Profile") | |
| student_distribution.to_excel(writer, index=False, sheet_name="Student_Distribution") | |
| section_distribution.to_excel(writer, index=False, sheet_name="Section_Distribution") | |
| section_list.to_excel(writer, index=False, sheet_name="Section_List_With_Meaning") | |
| out.seek(0) | |
| return out.getvalue() | |
| # ---------------------------- | |
| # Main analysis | |
| # ---------------------------- | |
| def run_analysis(file_obj): | |
| if file_obj is None: | |
| raise gr.Error("Please upload an Excel/CSV file first.") | |
| name = (getattr(file_obj, "name", "") or "").lower() | |
| try: | |
| if name.endswith(".csv"): | |
| raw = pd.read_csv(file_obj.name) | |
| else: | |
| raw = _read_excel_safely(file_obj.name) | |
| except Exception as e: | |
| raise gr.Error(f"Could not read the file. Details: {e}") | |
| try: | |
| df, active_cols = _map_columns(raw) | |
| except Exception as e: | |
| raise gr.Error(str(e)) | |
| df = _clean(df, active_cols) | |
| df = _features(df, active_cols) | |
| if df.empty: | |
| raise gr.Error("No valid rows after cleaning. Please check your file.") | |
| has_final = "Final" in active_cols | |
| has_lab_cols = any(c in active_cols for c in ["Lab Report", "Lab Activity"]) | |
| has_theory_cols = any(c in active_cols for c in ["Quiz", "Mid", "Final"]) | |
| has_lab_theory = has_lab_cols and has_theory_cols | |
| # Student-level clustering | |
| stud_feat = df[["mean_score", "std_score", "exam_gap", "lab_theory_gap", "final_vs_coursework_gap"]] | |
| stud_labels, Xs, stud_sil, stud_k = _kmeans(stud_feat, k=3) | |
| df["StudentCluster"] = stud_labels.astype(int) | |
| df["StudentReason"] = df.apply(lambda r: _student_reason(r, has_final, has_lab_theory), axis=1) | |
| profile_cols = [f"{c}_pct" for c in active_cols] + ["mean_score", "std_score"] | |
| if has_final: | |
| profile_cols.append("final_vs_coursework_gap") | |
| if has_lab_theory: | |
| profile_cols.append("lab_theory_gap") | |
| stud_profile = ( | |
| df.groupby("StudentCluster")[profile_cols] | |
| .mean() | |
| .reset_index() | |
| .sort_values("StudentCluster") | |
| ) | |
| # Section-level aggregation | |
| sec_cols = [f"{c}_pct" for c in active_cols] + ["mean_score", "std_score", "exam_gap"] | |
| if has_final: | |
| sec_cols.append("final_vs_coursework_gap") | |
| if has_lab_theory: | |
| sec_cols.append("lab_theory_gap") | |
| sec = df.groupby("Section")[sec_cols].mean().reset_index() | |
| sec_feat = sec[["mean_score", "std_score", "exam_gap"]].copy() | |
| sec_feat["lab_theory_gap"] = sec.get("lab_theory_gap", 0.0) | |
| sec_feat["final_vs_coursework_gap"] = sec.get("final_vs_coursework_gap", 0.0) | |
| sec_labels, Xsec, sec_sil, sec_k = _kmeans(sec_feat, k=min(3, len(sec))) | |
| sec["SectionReason"] = sec.apply(lambda r: _section_reason(r, has_final, has_lab_theory), axis=1) | |
| sec["SectionCluster"] = sec_labels.astype(int) | |
| sec_profile = ( | |
| sec.groupby("SectionCluster")[profile_cols] | |
| .mean() | |
| .reset_index() | |
| .sort_values("SectionCluster") | |
| ) | |
| # Meaning mapping | |
| student_meaning_map = _assign_cluster_meanings(stud_profile, "StudentCluster", has_final, has_lab_theory) | |
| section_meaning_map = _assign_cluster_meanings(sec_profile, "SectionCluster", has_final, has_lab_theory) | |
| df["StudentClusterMeaning"] = df["StudentCluster"].map(lambda x: student_meaning_map.get(int(x), f"Cluster {int(x)}")) | |
| df["StudentTrafficLight"] = df["StudentClusterMeaning"].map(_traffic_light) | |
| df["StudentManagerSignal"] = df["StudentClusterMeaning"].map(_manager_signal) | |
| sec["SectionClusterMeaning"] = sec["SectionCluster"].map(lambda x: section_meaning_map.get(int(x), f"Cluster {int(x)}")) | |
| sec["SectionTrafficLight"] = sec["SectionClusterMeaning"].map(_traffic_light) | |
| sec["SectionManagerSignal"] = sec["SectionClusterMeaning"].map(_manager_signal) | |
| # Manager tables | |
| student_distribution = _build_distribution_table(df["StudentCluster"], student_meaning_map, "Students") | |
| section_distribution = _build_distribution_table(sec["SectionCluster"], section_meaning_map, "Sections") | |
| # Manager graphs | |
| fig_students, fig_sections, fig_exam, reco = _manager_graphs_and_reco(df, sec, has_final) | |
| # PCA plots | |
| student_hover_cols = ["StudentID", "Section"] + active_cols + [ | |
| "StudentCluster", "StudentClusterMeaning", "StudentTrafficLight", | |
| "mean_score", "std_score" | |
| ] | |
| if has_final: | |
| student_hover_cols.append("final_vs_coursework_gap") | |
| if has_lab_theory: | |
| student_hover_cols.append("lab_theory_gap") | |
| hover_student = df[student_hover_cols].copy() | |
| fig_student_pca = _pca_fig(Xs, stud_labels, hover_student, "Student-Level Clustering (PCA)") | |
| section_hover_cols = [ | |
| "Section", "SectionCluster", "SectionClusterMeaning", "SectionTrafficLight", | |
| "mean_score", "std_score" | |
| ] | |
| if has_final: | |
| section_hover_cols.append("final_vs_coursework_gap") | |
| if has_lab_theory: | |
| section_hover_cols.append("lab_theory_gap") | |
| hover_section = sec[section_hover_cols].copy() | |
| fig_section_pca = _pca_fig(Xsec, sec_labels, hover_section, "Section-Level Clustering (PCA)") | |
| # Section list | |
| section_list_cols = [ | |
| "Section", "SectionCluster", "SectionClusterMeaning", "SectionTrafficLight", | |
| "SectionManagerSignal", "mean_score", "std_score" | |
| ] | |
| if has_final: | |
| section_list_cols.append("final_vs_coursework_gap") | |
| if has_lab_theory: | |
| section_list_cols.append("lab_theory_gap") | |
| section_list_cols.append("SectionReason") | |
| section_list = sec[section_list_cols].copy() | |
| for c in ["mean_score", "std_score", "final_vs_coursework_gap", "lab_theory_gap"]: | |
| if c in section_list.columns: | |
| section_list[c] = (section_list[c] * 100).round(1) | |
| # Student output | |
| student_out_cols = ["StudentID", "Section"] + active_cols + [ | |
| "StudentCluster", "StudentClusterMeaning", "StudentTrafficLight", | |
| "StudentManagerSignal", "mean_score", "std_score" | |
| ] | |
| if has_final: | |
| student_out_cols.append("final_vs_coursework_gap") | |
| if has_lab_theory: | |
| student_out_cols.append("lab_theory_gap") | |
| student_out_cols.append("StudentReason") | |
| student_out = df[student_out_cols].copy() | |
| for c in ["mean_score", "std_score", "final_vs_coursework_gap", "lab_theory_gap"]: | |
| if c in student_out.columns: | |
| student_out[c] = (student_out[c] * 100).round(1) | |
| # Section output | |
| section_out_cols = [ | |
| "Section", "SectionCluster", "SectionClusterMeaning", "SectionTrafficLight", | |
| "SectionManagerSignal", "mean_score", "std_score" | |
| ] | |
| if has_final: | |
| section_out_cols.append("final_vs_coursework_gap") | |
| if has_lab_theory: | |
| section_out_cols.append("lab_theory_gap") | |
| section_out_cols.append("SectionReason") | |
| section_out = sec[section_out_cols].copy() | |
| for c in ["mean_score", "std_score", "final_vs_coursework_gap", "lab_theory_gap"]: | |
| if c in section_out.columns: | |
| section_out[c] = (section_out[c] * 100).round(1) | |
| # Profiles as % | |
| stud_profile_show = stud_profile.copy() | |
| sec_profile_show = sec_profile.copy() | |
| for t in [stud_profile_show, sec_profile_show]: | |
| for c in t.columns: | |
| if c != t.columns[0]: | |
| t[c] = (t[c] * 100).round(1) | |
| sil_s = f"{stud_sil:.3f}" if stud_sil is not None else "N/A" | |
| sil_c = f"{sec_sil:.3f}" if sec_sil is not None else "N/A" | |
| active_text = ", ".join(active_cols) | |
| summary = ( | |
| f"Students analysed: {len(df)}\n" | |
| f"Sections analysed: {df['Section'].nunique()}\n" | |
| f"Assessments detected: {active_text}\n\n" | |
| f"Student-level silhouette: {sil_s}\n" | |
| f"Section-level silhouette: {sil_c}\n\n" | |
| f"{_balanced_two_lines_for_managers()}\n\n" | |
| "Cluster numbers are labels, not ranks.\n" | |
| "These clusters are for QA/moderation and academic support only." | |
| ) | |
| excel_bytes = _make_excel( | |
| student_out, section_out, | |
| stud_profile_show, sec_profile_show, | |
| student_distribution, section_distribution, | |
| section_list | |
| ) | |
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") | |
| tmp.write(excel_bytes) | |
| tmp.flush() | |
| tmp.close() | |
| return ( | |
| summary, | |
| fig_students, fig_sections, fig_exam, reco, | |
| student_distribution, section_distribution, section_list, | |
| student_out, fig_student_pca, stud_profile_show, | |
| section_out, fig_section_pca, sec_profile_show, | |
| tmp.name | |
| ) | |
| # ---------------------------- | |
| # UI | |
| # ---------------------------- | |
| with gr.Blocks(title="Marks Clustering Dashboard — Flexible Assessments") as demo: | |
| gr.Markdown( | |
| """ | |
| # Marks Clustering Dashboard (Flexible) | |
| Upload any **Excel/CSV** file. | |
| ### Required: | |
| - **Section** | |
| ### Optional assessments (at least 2 needed): | |
| - Quiz (out of 10) | |
| - Mid (out of 20) | |
| - Lab Report (out of 20) | |
| - Lab Activity (out of 10) | |
| - Final (out of 40) | |
| ### Notes: | |
| - The system automatically detects which assessments are included. | |
| - It supports different combinations such as: | |
| - Mid + Final | |
| - Lab Report + Lab Activity | |
| - Quiz + Mid + Final | |
| - All components | |
| - **A / Absent** is treated as **0** | |
| - The output shows only uploaded assessment-related analysis | |
| """.strip() | |
| ) | |
| file_in = gr.File(label="Upload Excel/CSV", file_types=[".xlsx", ".xls", ".csv"]) | |
| run_btn = gr.Button("Run Analysis", variant="primary") | |
| summary = gr.Textbox(label="Summary", lines=12) | |
| with gr.Tabs(): | |
| with gr.Tab("Manager Summary"): | |
| ms_fig_students = gr.Plot(label="Students by Pattern") | |
| ms_fig_sections = gr.Plot(label="Section-wise Pattern") | |
| ms_fig_exam = gr.Plot(label="Overall Assessment Comparison") | |
| ms_reco = gr.Textbox(label="Brief Recommendations", lines=6) | |
| ms_student_dist = gr.Dataframe(label="Student Distribution", wrap=True, interactive=False) | |
| ms_section_dist = gr.Dataframe(label="Section Distribution", wrap=True, interactive=False) | |
| ms_section_list = gr.Dataframe(label="Section List", wrap=True, interactive=False) | |
| with gr.Tab("Student-Level"): | |
| student_df = gr.Dataframe(label="Student-Level Results", wrap=True) | |
| student_plot = gr.Plot(label="Student PCA Plot") | |
| student_profile = gr.Dataframe(label="Student Cluster Profile (%)", wrap=True) | |
| with gr.Tab("Section-Level"): | |
| section_df = gr.Dataframe(label="Section-Level Results", wrap=True) | |
| section_plot = gr.Plot(label="Section PCA Plot") | |
| section_profile = gr.Dataframe(label="Section Cluster Profile (%)", wrap=True) | |
| with gr.Tab("Download"): | |
| dl = gr.File(label="Download Excel Report") | |
| run_btn.click( | |
| run_analysis, | |
| inputs=[file_in], | |
| outputs=[ | |
| summary, | |
| ms_fig_students, ms_fig_sections, ms_fig_exam, ms_reco, | |
| ms_student_dist, ms_section_dist, ms_section_list, | |
| student_df, student_plot, student_profile, | |
| section_df, section_plot, section_profile, | |
| dl | |
| ], | |
| ) | |
| print("Starting Gradio app...") | |
| if __name__ == "__main__": | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| show_error=True | |
| ) |