diff --git a/README.md b/README.md index eecad16..0496cb1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Learning Retention Analytics 🇬🇧 [🇮🇹](README_IT.md) +# Learning Retention Analytics 🇬🇧 [🇮🇹](it/README.md) [![Test & Coverage](https://github.com/aleattene/learning-retention-analytics/actions/workflows/test.yml/badge.svg)](https://github.com/aleattene/learning-retention-analytics/actions/workflows/test.yml) [![Code Quality](https://github.com/aleattene/learning-retention-analytics/actions/workflows/code_quality.yml/badge.svg)](https://github.com/aleattene/learning-retention-analytics/actions/workflows/code_quality.yml) @@ -83,7 +83,8 @@ project_root/ │ ├── pipeline/ │ │ ├── step_01_ingest.py # CSV OULAD → raw DuckDB tables │ │ ├── step_02_transform.py # Raw tables → analytical views -│ │ └── step_03_export.py # Views → CSV + optional Sheets push +│ │ ├── step_03_export.py # Views → CSV + optional Sheets push +│ │ └── step_04_stats.py # BQ2/BQ3 statistical tests → CSV │ ├── stats/tests.py # Statistical test wrappers │ ├── sheets/push.py # Google Sheets integration │ └── utils/ # Logging, runtime utilities @@ -211,7 +212,7 @@ In summary: | [Methodology](docs/METHODOLOGY.md) | Statistical approach, design choices, trade-offs | | [Transferability](docs/TRANSFERABILITY.md) | Pattern portability to SaaS, subscriptions, fitness | | [Cloud Migration](docs/MIGRATION.md) | DuckDB to BigQuery path, gaps and checklist | -| [ADR](docs/ADR.md) | 7 architectural decisions with rationale | +| [ADR](docs/ADR.md) | Architectural decisions with rationale | | [Testing](docs/TESTING.md) | Test architecture, strategy, and decisions | --- diff --git a/README_IT.md b/it/README.md similarity index 91% rename from README_IT.md rename to it/README.md index 83a951b..2f31023 100644 --- a/README_IT.md +++ b/it/README.md @@ -1,10 +1,10 @@ -# Analisi della Retention nell'Apprendimento 🇮🇹 [🇬🇧](README.md) +# Analisi della Retention nell'Apprendimento 🇮🇹 [🇬🇧](../README.md) [![Test & Coverage](https://github.com/aleattene/learning-retention-analytics/actions/workflows/test.yml/badge.svg)](https://github.com/aleattene/learning-retention-analytics/actions/workflows/test.yml) [![Code Quality](https://github.com/aleattene/learning-retention-analytics/actions/workflows/code_quality.yml/badge.svg)](https://github.com/aleattene/learning-retention-analytics/actions/workflows/code_quality.yml) [![codecov](https://codecov.io/gh/aleattene/learning-retention-analytics/graph/badge.svg?token=LS2ASS9Z6K)](https://codecov.io/gh/aleattene/learning-retention-analytics) [![Python 3.13+](https://img.shields.io/badge/python-3.13%2B-blue.svg)](https://www.python.org/downloads/) -[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) +[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](../LICENSE) [![Dataset: OULAD](https://img.shields.io/badge/dataset-OULAD-orange.svg)](https://analyse.kmi.open.ac.uk/open_dataset) --- @@ -83,7 +83,8 @@ project_root/ │ ├── pipeline/ │ │ ├── step_01_ingest.py # CSV OULAD → tabelle raw DuckDB │ │ ├── step_02_transform.py # Tabelle raw → viste analitiche -│ │ └── step_03_export.py # Viste → CSV + push opzionale su Sheets +│ │ ├── step_03_export.py # Viste → CSV + push opzionale su Sheets +│ │ └── step_04_stats.py # Test statistici BQ2/BQ3 → CSV │ ├── stats/tests.py # Wrapper per test statistici │ ├── sheets/push.py # Integrazione Google Sheets │ └── utils/ # Logging, utilità runtime @@ -185,7 +186,7 @@ binarizzata come Completato (Pass + Distinction) vs Non completato (Fail + Withd ## Risultati Principali -L'analisi completa è disponibile nel [Report Esecutivo](reports/REPORT_IT.md). +L'analisi completa è disponibile nel [Report Esecutivo](../reports/it/REPORT.md). In sintesi: - **BQ1**: circa 1 iscrizione su 3 termina con il ritiro esplicito; il dropout @@ -208,18 +209,18 @@ In sintesi: | Documento | Contenuto | |-----------|-----------| -| [Report Esecutivo](reports/REPORT_IT.md) | Analisi completa BQ1–BQ5 con figure e numeri | -| [Metodologia](docs/METHODOLOGY_IT.md) | Approccio statistico, scelte progettuali, trade-off | -| [Trasferibilità](docs/TRANSFERABILITY_IT.md) | Portabilità dei pattern a SaaS, abbonamenti, fitness | -| [Migrazione Cloud](docs/MIGRATION_IT.md) | Percorso da DuckDB a BigQuery, gap e checklist | -| [ADR](docs/ADR_IT.md) | 7 decisioni architetturali con razionale | -| [Testing](docs/TESTING_IT.md) | Architettura di test, strategia e decisioni | +| [Report Esecutivo](../reports/it/REPORT.md) | Analisi completa BQ1–BQ5 con figure e numeri | +| [Metodologia](../docs/it/METHODOLOGY.md) | Approccio statistico, scelte progettuali, trade-off | +| [Trasferibilità](../docs/it/TRANSFERABILITY.md) | Portabilità dei pattern a SaaS, abbonamenti, fitness | +| [Migrazione Cloud](../docs/it/MIGRATION.md) | Percorso da DuckDB a BigQuery, gap e checklist | +| [ADR](../docs/it/ADR.md) | Decisioni architetturali con razionale | +| [Testing](../docs/it/TESTING.md) | Architettura di test, strategia e decisioni | --- ## Licenza -Questo progetto è distribuito con [Licenza MIT](LICENSE). +Questo progetto è distribuito con [Licenza MIT](../LICENSE). Il dataset OULAD è distribuito con licenza [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/) - vedi citazione sopra. diff --git a/notebooks/04_bq2_early_signals.ipynb b/notebooks/04_bq2_early_signals.ipynb index c5bea4c..1683431 100644 --- a/notebooks/04_bq2_early_signals.ipynb +++ b/notebooks/04_bq2_early_signals.ipynb @@ -528,7 +528,7 @@ "id": "16", "metadata": {}, "source": [ - "> **Interpretation:** With a large dataset (~32K enrollments), most signals remain significant even after Bonferroni correction. This confirms that the differences are real — but statistical significance alone does not tell us which signals are *practically meaningful*. That is what the effect size ranking (next section) addresses." + "> **Interpretation:** With a large dataset (~32K enrollments), all 8 signals remain significant even after the conservative Bonferroni correction (8/8 under both Bonferroni and BH). This confirms that the differences are real — but statistical significance alone does not tell us which signals are *practically meaningful*. That is what the effect size ranking (next section) addresses." ] }, { @@ -607,7 +607,7 @@ "id": "19", "metadata": {}, "source": [ - "> **Key finding:** The forest plot reveals a clear ranking of early behavioral signals. The strongest predictors of completion are likely engagement-volume metrics (active days, total clicks), while assessment-based signals and registration timing provide complementary information.\n", + "> **Key finding:** The forest plot reveals a clear ranking of early behavioral signals. Engagement-volume metrics lead: within-course engagement decile (d = 0.97), active days (d = 0.90), and total clicks (d = 0.63); the remaining signals (last active day, click intensity, first score and timing) fall between |d| = 0.11 and |d| = 0.55 and provide complementary information.\n", ">\n", "> **Practical implication:** An early warning system should prioritize the top-ranked signals. These are the metrics most worth monitoring in the first 28 days to identify at-risk students." ] @@ -873,10 +873,12 @@ "id": "28", "metadata": {}, "source": [ - "> **Key finding:** The gap between ghost and active students is enormous and the 95% bootstrap confidence intervals do not overlap. Ghost students — those with zero VLE activity in the first 28 days — have a near-zero completion rate.\n", + "> **Key finding:** The gap between ghost and active students is enormous and the 95% bootstrap confidence intervals do not overlap: 4.8% completion for ghosts (CI 4.2-5.4%) versus 54.3% for active students (CI 53.7-54.9%). Ghost students — those with zero VLE activity in the first 28 days — have a near-zero completion rate.\n", ">\n", "> This is the **strongest single signal** in the dataset: if a student has not clicked on any VLE resource within the first 4 weeks, their probability of completing the course is negligible.\n", ">\n", + "> **Definition note:** in this notebook \"ghost\" means zero VLE activity in the first 28 days (n = 4685). The BQ5 segment sizing uses a broader operational threshold instead (at most 1 active day and fewer than 10 clicks), which yields a larger segment with a 7.7% completion rate: the strict definition isolates the pure signal, the broad one sizes the intervention target.\n", + ">\n", "> **Intervention priority:** Ghost students are the lowest-hanging fruit. They do not need better content — they need to be *activated*. In SaaS terms, this is the onboarding gap: users who signed up but never experienced the core product value." ] }, @@ -891,9 +893,9 @@ "\n", "1. **All 8 early signals show statistically significant differences** between completers and non-completers. With ~32K enrollments, even modest differences reach significance — which is why effect size (Cohen's d) is the primary ranking criterion.\n", "\n", - "2. **The strongest behavioral predictors** are engagement-volume metrics: active days, total clicks, and engagement decile. These signals capture both frequency and intensity of platform interaction in the first 28 days.\n", + "2. **The strongest behavioral predictors** are engagement-volume metrics: engagement decile (d = 0.97), active days (d = 0.90), and total clicks (d = 0.63). These signals capture both frequency and intensity of platform interaction in the first 28 days.\n", "\n", - "3. **Multiple comparison correction confirms robustness.** Most signals remain significant after both Bonferroni and Benjamini-Hochberg correction, indicating that the associations are real, not artifacts of multiple testing.\n", + "3. **Multiple comparison correction confirms robustness.** All 8 signals remain significant after both Bonferroni and Benjamini-Hochberg correction, indicating that the associations are real, not artifacts of multiple testing.\n", "\n", "4. **Dose-response relationships are monotonic.** More engagement consistently predicts higher completion rates across all signal quartiles. There are no obvious thresholds or diminishing returns — the relationship is graded.\n", "\n", @@ -901,7 +903,7 @@ "\n", "6. **Ghost students are the extreme case.** Zero VLE activity in the first 28 days predicts near-certain non-completion. This is the clearest actionable segment for intervention.\n", "\n", - "7. **Effect sizes are small-to-medium** by Cohen's conventions. This is typical for behavioral data: individual signals explain a modest share of outcome variance. The practical value comes from combining multiple signals into an engagement score (BQ5).\n", + "7. **Effect sizes span Cohen's full scale.** The two leading volume signals reach the \"large\" threshold (d = 0.90 and 0.97), most others sit in the medium band (|d| ≈ 0.5-0.6), and the timing signals remain small (|d| ≈ 0.1-0.2). Individual signals still explain only part of the outcome variance: the practical value comes from combining multiple signals into an engagement score (BQ5).\n", "\n", "8. **No causal claims.** All findings are associations. Motivated students may both engage more and complete more — engagement could be a proxy for motivation, not a cause of success.\n", "\n", diff --git a/notebooks/05_bq3_demographics_vs_behavior.ipynb b/notebooks/05_bq3_demographics_vs_behavior.ipynb index f480df2..204c332 100644 --- a/notebooks/05_bq3_demographics_vs_behavior.ipynb +++ b/notebooks/05_bq3_demographics_vs_behavior.ipynb @@ -536,7 +536,7 @@ "id": "13", "metadata": {}, "source": [ - "> **Part A Summary:** All 8 demographic features show statistically significant associations with completion after multiple comparison correction. However, the effect sizes are uniformly **small**: Cramér's V values are below 0.15 and |Cohen's d| values for numeric demographics are below 0.2. Demographics tell us *who is slightly more likely* to complete, but they lack the discriminative power to identify at-risk students with confidence." + "> **Part A Summary:** All 8 demographic features show statistically significant associations with completion after multiple comparison correction. However, the effect sizes remain **small to modest**: Cramér's V peaks at about 0.15 (highest education 0.150, IMD band 0.134, all others below 0.09) and |Cohen's d| for the numeric demographics stays below 0.3 (studied credits 0.28, previous attempts 0.21). Demographics tell us *who is slightly more likely* to complete, but they lack the discriminative power to identify at-risk students with confidence." ] }, { @@ -614,7 +614,7 @@ "id": "16", "metadata": {}, "source": [ - "> **Interpretation:** All 6 behavioral features show statistically significant associations with completion. More importantly, the **effect sizes are substantially larger** than the demographic ones. Several behavioral features reach medium effect sizes (|d| > 0.4), compared to the small demographic effects (|d| < 0.2, V < 0.15).\n", + "> **Interpretation:** All 6 behavioral features show statistically significant associations with completion. More importantly, the **effect sizes are substantially larger** than the demographic ones. Every behavioral feature reaches at least a medium effect size (|d| between 0.52 and 0.90), compared to the small-to-modest demographic effects (|d| up to 0.28, V up to 0.15).\n", ">\n", "> The strongest behavioral signals — engagement volume, activity frequency, and first assessment submission — provide far more discriminative information about eventual completion than any demographic variable.\n", ">\n", @@ -672,7 +672,7 @@ "id": "18", "metadata": {}, "source": [ - "> **Part B Summary:** Behavioral features consistently show medium effect sizes (|d| ≈ 0.3–0.6), with the strongest signals coming from engagement volume metrics and first assessment submission. These effects are 2–5× larger than the demographic effects in Part A." + "> **Part B Summary:** Behavioral features show medium-to-large effect sizes (|d| ≈ 0.5–0.9), with the strongest signals coming from engagement volume metrics and first assessment submission. These effects are 2 to 4 times larger than the demographic effects in Part A (2.7× on average across comparable metrics)." ] }, { @@ -823,9 +823,9 @@ "> **The Verdict: Behavior wins, decisively.**\n", ">\n", "> The comparison reveals a clear pattern:\n", - "> - **Behavioral features** (green) have effect sizes 2–5× larger than **demographic features** (blue)\n", - "> - Even the *weakest* behavioral signal is comparable to or stronger than the *strongest* demographic signal\n", - "> - Categorical demographics (Cramér's V) show uniformly small associations (V < 0.15)\n", + "> - **Behavioral features** (green) have effect sizes 2–4× larger than **demographic features** (blue)\n", + "> - Even the *weakest* behavioral signal (|d| = 0.52) is stronger than the *strongest* demographic signal (|d| = 0.28)\n", + "> - Categorical demographics (Cramér's V) show uniformly weak associations (V ≤ 0.15)\n", ">\n", "> **What this means for a platform operator:** Demographic profiling has limited predictive value. You cannot meaningfully identify at-risk students based on their age, gender, or education level alone. But monitoring their behavior in the first 28 days provides actionable early warning signals.\n", ">\n", @@ -963,9 +963,9 @@ "\n", "### What we learned\n", "\n", - "1. **All demographic features show statistically significant but weak associations** with completion. The largest Cramér's V values are below 0.15, and numeric demographic Cohen's d values are below 0.2. With ~32K enrollments, significance is easy to achieve — effect size is what matters.\n", + "1. **All demographic features show statistically significant but weak associations** with completion. The largest Cramér's V values peak at about 0.15 (education 0.150, IMD band 0.134), and numeric demographic Cohen's d values stay below 0.3. With ~32K enrollments, significance is easy to achieve — effect size is what matters.\n", "\n", - "2. **Behavioral features have 2–5× larger effect sizes** than demographic features. Engagement volume, activity frequency, and first assessment submission are far more informative about eventual completion.\n", + "2. **Behavioral features have 2–4× larger effect sizes** than demographic features. Engagement volume, activity frequency, and first assessment submission are far more informative about eventual completion.\n", "\n", "3. **Within every education level, engagement is the swing factor.** High-engagement students outperform low-engagement students regardless of their educational background. The within-group behavioral gap exceeds the between-group demographic gap.\n", "\n", diff --git a/notebooks/it/01_eda_student_base.ipynb b/notebooks/it/01_eda_student_base.ipynb deleted file mode 100644 index ed7e157..0000000 --- a/notebooks/it/01_eda_student_base.ipynb +++ /dev/null @@ -1,1236 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "# 01 — EDA: Base Studenti\n", - "\n", - "> **Notebook 01 di 7** | Learning Retention Analytics \n", - "> Prima analisi esplorativa: chi sono gli studenti, quali sono i loro esiti e come si presentano i dati?" - ] - }, - { - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "## Scopo e ambito\n", - "\n", - "Questo notebook è il **primo di due notebook EDA (Exploratory Data Analysis)** del progetto. Esamina la **base studenti**: dati demografici, pattern di iscrizione, distribuzione degli esiti e panoramica dei corsi.\n", - "\n", - "**Cosa copre questo notebook:**\n", - "- Dimensioni e scala del dataset\n", - "- Distribuzione degli esiti (4 classi e binarizzata)\n", - "- Profilo demografico della popolazione studentesca\n", - "- Pattern di iscrizione (tempistica di registrazione)\n", - "- Panoramica a livello di corso (iscrizioni, completamento, caratteristiche di design)\n", - "- Valutazione della qualità dei dati\n", - "- Baseline di engagement iniziale (anteprima)\n", - "\n", - "**Cosa viene dopo:**\n", - "- **Notebook 02** (`02_eda_engagement_patterns.ipynb`): analisi comportamentale dettagliata — pattern di clickstream giornalieri, segnali di engagement precoce, trend temporali\n", - "\n", - "**Collegamento alle business question:** \n", - "Questa EDA non risponde direttamente a BQ1–BQ5. Piuttosto, stabilisce il profilo della popolazione e la baseline di qualità dei dati da cui dipendono tutte le analisi successive. Pensalo come *comprendere il terreno prima di navigarlo*.\n", - "\n", - "> **Cos'è l'EDA?** L'Exploratory Data Analysis è la pratica di esaminare un dataset prima della modellazione formale o del test di ipotesi. L'obiettivo è scoprire pattern, individuare anomalie, verificare assunzioni e costruire intuizione sui dati. Per un'introduzione approfondita, vedi [`docs/eda-guide.md`](../docs/eda-guide.md)." - ] - }, - { - "cell_type": "markdown", - "id": "2", - "metadata": {}, - "source": [ - "## Indice\n", - "\n", - "1. [Setup dell'ambiente](#1.-Setup-dell'ambiente)\n", - "2. [Panoramica del dataset — Forma e scala](#2.-Panoramica-del-dataset-—-Forma-e-scala)\n", - "3. [Distribuzione degli esiti](#3.-Distribuzione-degli-esiti)\n", - "4. [Esiti per corso](#4.-Esiti-per-corso)\n", - "5. [Profilo demografico](#5.-Profilo-demografico)\n", - "6. [Pattern di iscrizione](#6.-Pattern-di-iscrizione)\n", - "7. [Panoramica dei corsi](#7.-Panoramica-dei-corsi)\n", - "8. [Valutazione della qualità dei dati](#8.-Valutazione-della-qualità-dei-dati)\n", - "9. [Baseline di engagement — Anteprima](#9.-Baseline-di-engagement-—-Anteprima)\n", - "10. [Conclusioni e prossimi passi](#10.-Conclusioni-e-prossimi-passi)\n", - "\n", - "---\n", - "\n", - "**Prerequisiti:**\n", - "- La pipeline ETL deve essere stata eseguita: `python -m run_pipeline`\n", - "- Il database DuckDB in `data/db/oulad.duckdb` deve contenere tutte e 5 le viste analitiche\n", - "\n", - "**Dataset:** Open University Learning Analytics Dataset (OULAD) — ~32K studenti, 7 corsi, clickstream comportamentale completo. Licenza: CC-BY 4.0." - ] - }, - { - "cell_type": "markdown", - "id": "3", - "metadata": {}, - "source": [ - "## 1. Setup dell'ambiente\n", - "\n", - "Configuriamo import, default di visualizzazione e funzioni helper riutilizzabili.\n", - "\n", - "**Note tecniche per il lettore:**\n", - "- I notebook risiedono in `notebooks/` ma i moduli del progetto sono in `src/` nella root del progetto. Aggiungiamo la project root a `sys.path` affinché `from src.config import ...` funzioni. La regola del linter `E402` (import non in cima al file) è soppressa per i notebook in `pyproject.toml`.\n", - "- Tutte le query al database passano da `src.db.connection.execute_query()` — il layer di astrazione DB del progetto. Questo restituisce un `pandas.DataFrame` e assicura che non si chiami mai `duckdb.connect()` direttamente (vedi [ADR-003](../docs/ADR.md)).\n", - "- Le figure vengono salvate in `reports/figures/` a 150 DPI. Poiché `nbstripout` rimuove gli output del notebook prima del commit, i PNG salvati sono il record visivo persistente." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Path setup ---\n", - "# Notebooks live in notebooks/ but project modules are at the project root.\n", - "# Adding the root to sys.path lets us import from src/ as if running from root.\n", - "# We search upward for pyproject.toml instead of assuming cwd is always notebooks/,\n", - "# so the notebook works regardless of where the kernel is launched from\n", - "# (JupyterLab, VS Code, Cursor, repo root, etc.).\n", - "import sys\n", - "from pathlib import Path\n", - "\n", - "\n", - "def _find_project_root(start: Path) -> Path:\n", - " \"\"\"Walk upward from start until we find pyproject.toml (the repo root marker).\"\"\"\n", - " for candidate in (start, *start.parents):\n", - " if (candidate / 'pyproject.toml').is_file():\n", - " return candidate\n", - " raise RuntimeError(\n", - " f\"Could not locate project root: no pyproject.toml found in '{start}' \"\n", - " \"or any parent directory. Run the notebook from within the repository.\"\n", - " )\n", - "\n", - "\n", - "PROJECT_ROOT = _find_project_root(Path.cwd())\n", - "if str(PROJECT_ROOT) not in sys.path:\n", - " sys.path.insert(0, str(PROJECT_ROOT))\n", - "\n", - "# --- Standard library ---\n", - "import logging\n", - "import warnings\n", - "\n", - "# --- Third-party ---\n", - "import matplotlib.pyplot as plt\n", - "import matplotlib.ticker as mticker\n", - "import pandas as pd\n", - "import seaborn as sns\n", - "\n", - "# --- Project modules ---\n", - "from src.config import FIGURES_DIR\n", - "from src.db.connection import execute_query\n", - "\n", - "# --- Configuration ---\n", - "# Suppress noisy warnings in notebook output; errors still surface\n", - "warnings.filterwarnings('ignore', category=FutureWarning)\n", - "logging.basicConfig(level=logging.WARNING)\n", - "\n", - "# --- Visualization defaults ---\n", - "# Consistent style across all project notebooks\n", - "sns.set_theme(style='whitegrid', font_scale=1.1)\n", - "\n", - "# Semantic color palette: one color per outcome category\n", - "PALETTE_OUTCOME = {\n", - " 'Pass': '#4C72B0', # blue — neutral positive\n", - " 'Distinction': '#55A868', # green — strong positive\n", - " 'Fail': '#C44E52', # red — negative\n", - " 'Withdrawn': '#8172B3', # purple — departed (distinct from failed)\n", - "}\n", - "# Binary version: completed (1) vs not completed (0)\n", - "PALETTE_BINARY = {1: '#55A868', 0: '#C44E52'}\n", - "LABEL_BINARY = {1: 'Completed', 0: 'Not completed'}\n", - "# Label-keyed palette for seaborn when x-axis uses mapped string categories\n", - "PALETTE_BINARY_LABELS = {'Completed': '#55A868', 'Not completed': '#C44E52'}\n", - "# Sequential palette for heatmaps and continuous scales\n", - "PALETTE_SEQUENTIAL = 'YlOrRd'\n", - "# Shared axis label — the unit of analysis is the enrollment, not the student\n", - "LABEL_NUM_ENROLLMENTS = 'Number of enrollments'\n", - "\n", - "FIG_DPI = 150\n", - "FIG_SIZE = (10, 6)\n", - "FIG_SIZE_WIDE = (16, 5)\n", - "\n", - "# Ensure figures output directory exists\n", - "FIGURES_DIR.mkdir(parents=True, exist_ok=True)\n", - "\n", - "\n", - "def save_fig(fig, name: str) -> None:\n", - " \"\"\"Save figure to reports/figures/ with consistent settings.\"\"\"\n", - " path = FIGURES_DIR / f'{name}.png'\n", - " fig.savefig(path, dpi=FIG_DPI, bbox_inches='tight', facecolor='white')\n", - " print(f' Saved: {path}')\n", - "\n", - "\n", - "def query_demo_distribution(column: str) -> pd.DataFrame:\n", - " \"\"\"Query count and completion rate by a demographic column.\n", - "\n", - " Returns DataFrame with columns: [category, n, completion_rate].\n", - " Column names are standardized so that plot_demo_completion() works\n", - " regardless of which demographic variable we are analyzing.\n", - "\n", - " COALESCE handles NULL values (e.g. imd_band) — SQL NULLs become\n", - " pandas NaN which matplotlib cannot use as categorical axis labels.\n", - " Converting to 'Unknown' at the SQL level keeps downstream code clean.\n", - " \"\"\"\n", - " return execute_query(f'''\n", - " SELECT\n", - " COALESCE(CAST({column} AS VARCHAR), 'Unknown') AS category,\n", - " COUNT(*) AS n,\n", - " ROUND(100.0 * SUM(completed) / COUNT(*), 1) AS completion_rate\n", - " FROM v_student_enriched\n", - " GROUP BY {column}\n", - " ORDER BY n DESC\n", - " ''')\n", - "\n", - "\n", - "def plot_demo_completion(\n", - " df: pd.DataFrame,\n", - " title: str,\n", - " figname: str,\n", - " horizontal: bool = False,\n", - ") -> None:\n", - " \"\"\"Bar chart with completion-rate annotations for a demographic variable.\n", - "\n", - " Parameters\n", - " ----------\n", - " df : DataFrame with columns [category, n, completion_rate]\n", - " title : chart title\n", - " figname : filename without extension (e.g. '01_demo_gender')\n", - " horizontal : if True, horizontal bars (better for long category labels)\n", - " \"\"\"\n", - " fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "\n", - " if horizontal:\n", - " bars = ax.barh(df['category'], df['n'], color='#4C72B0', edgecolor='white')\n", - " for bar, (_, row) in zip(bars, df.iterrows()):\n", - " # Annotate each bar with the completion rate\n", - " ax.text(\n", - " bar.get_width() + ax.get_xlim()[1] * 0.01,\n", - " bar.get_y() + bar.get_height() / 2,\n", - " f\"{row['completion_rate']:.1f}% completed\",\n", - " va='center', fontsize=10, color='#333333',\n", - " )\n", - " ax.set_xlabel(LABEL_NUM_ENROLLMENTS)\n", - " ax.invert_yaxis()\n", - " else:\n", - " bars = ax.bar(df['category'], df['n'], color='#4C72B0', edgecolor='white')\n", - " for bar, (_, row) in zip(bars, df.iterrows()):\n", - " ax.text(\n", - " bar.get_x() + bar.get_width() / 2,\n", - " bar.get_height() + ax.get_ylim()[1] * 0.01,\n", - " f\"{row['completion_rate']:.1f}%\",\n", - " ha='center', fontsize=10, color='#333333',\n", - " )\n", - " ax.set_ylabel(LABEL_NUM_ENROLLMENTS)\n", - " plt.xticks(rotation=45, ha='right')\n", - "\n", - " ax.set_title(title)\n", - " sns.despine()\n", - " fig.tight_layout()\n", - " save_fig(fig, figname)\n", - " plt.show()\n", - "\n", - "\n", - "# --- Prerequisite check ---\n", - "# Verify the database is populated before proceeding\n", - "try:\n", - " _check = execute_query('SELECT COUNT(*) AS n FROM v_student_enriched')\n", - " _n_rows = _check['n'].iloc[0]\n", - " if _n_rows == 0:\n", - " raise RuntimeError('v_student_enriched is empty')\n", - " print(f'Database OK — v_student_enriched has {_n_rows:,} rows')\n", - "except Exception as exc:\n", - " raise RuntimeError(\n", - " 'Cannot query v_student_enriched. '\n", - " \"Run 'python -m run_pipeline' first to populate the database.\"\n", - " ) from exc" - ] - }, - { - "cell_type": "markdown", - "id": "5", - "metadata": {}, - "source": [ - "## 2. Panoramica del dataset — Forma e scala\n", - "\n", - "Il primo passo in qualsiasi EDA è capire **quanti dati abbiamo** e **come si presentano ad alto livello**.\n", - "\n", - "**Concetto chiave: l'unità di analisi.** \n", - "In OULAD, uno studente può iscriversi a più corsi (moduli). L'unità di analisi è l'**iscrizione studente-modulo** — identificata dalla chiave composita `(id_student, code_module, code_presentation)` — non lo studente singolo.\n", - "\n", - "Questo significa che il numero totale di righe in `v_student_enriched` è il numero di **iscrizioni**, che è maggiore del numero di studenti unici. Uno studente che completa il Corso A ma si ritira dal Corso B appare come due righe: una *completata* e una *non completata*. Entrambe sono punti dati validi per l'analisi di retention." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Dataset dimensions ---\n", - "# How many enrollments, unique students, courses, and presentations?\n", - "df_dims = execute_query('''\n", - " SELECT\n", - " COUNT(*) AS n_enrollments,\n", - " COUNT(DISTINCT id_student) AS n_unique_students,\n", - " COUNT(DISTINCT code_module) AS n_modules,\n", - " COUNT(DISTINCT code_module || '-' || code_presentation) AS n_course_presentations\n", - " FROM v_student_enriched\n", - "''')\n", - "\n", - "n_enroll = df_dims['n_enrollments'].iloc[0]\n", - "n_students = df_dims['n_unique_students'].iloc[0]\n", - "n_modules = df_dims['n_modules'].iloc[0]\n", - "n_cp = df_dims['n_course_presentations'].iloc[0]\n", - "\n", - "print('=== Dataset Dimensions ===')\n", - "print(f' Total enrollments: {n_enroll:>8,}')\n", - "print(f' Unique students: {n_students:>8,}')\n", - "print(f' Modules (courses): {n_modules:>8}')\n", - "print(f' Course-presentations: {n_cp:>8}')\n", - "print(f'\\n → On average, each student enrolls in {n_enroll / n_students:.1f} modules')" - ] - }, - { - "cell_type": "markdown", - "id": "7", - "metadata": {}, - "source": [ - "> **Leggere i numeri:** Il conteggio delle iscrizioni è maggiore del conteggio degli studenti unici perché alcuni studenti si iscrivono a più moduli. Questo è normale in un contesto universitario dove gli studenti seguono diversi corsi per semestre.\n", - ">\n", - "> La distinzione tra *iscrizioni* e *studenti* è rilevante in tutta l'analisi: tutti i tassi di completamento, le suddivisioni demografiche e le metriche di engagement sono calcolati **per iscrizione**, non per persona unica." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Course summary table ---\n", - "# One row per course-presentation with key metrics from v_course_profile.\n", - "# We select all columns here because Section 7 (Course Landscape) reuses\n", - "# df_courses for scatter plots and correlation analysis.\n", - "df_courses = execute_query('''\n", - " SELECT\n", - " code_module,\n", - " code_presentation,\n", - " course_length_days,\n", - " n_enrolled,\n", - " n_completed,\n", - " completion_rate_pct,\n", - " n_withdrew,\n", - " withdrawal_rate_pct,\n", - " n_assessments,\n", - " assessments_per_30_days,\n", - " n_vle_resources,\n", - " n_activity_types\n", - " FROM v_course_profile\n", - " ORDER BY code_module, code_presentation\n", - "''')\n", - "\n", - "print(f'=== Course-Presentation Summary ({len(df_courses)} rows) ===\\n')\n", - "df_courses" - ] - }, - { - "cell_type": "markdown", - "id": "9", - "metadata": {}, - "source": [ - "> **Cosa ci dice questa tabella:**\n", - "> - Ogni riga è un **course-presentation** unico (un modulo specifico offerto in un semestre specifico, es. AAA-2013J).\n", - "> - Il **tasso di completamento** varia tra i corsi — alcuni sono notevolmente più alti o più bassi, cosa che BQ4 indagherà.\n", - "> - Anche il **design del corso** varia: alcuni corsi hanno più assessment, altri più risorse VLE.\n", - "> - La colonna `course_length_days` mostra che i corsi variano da brevi a lunghi — questo influisce sulla tempistica di ritiro (BQ1).\n", - ">\n", - "> Torneremo a questa tabella nella [Sezione 7 (Panoramica dei corsi)](#7.-Panoramica-dei-corsi) per l'analisi visiva." - ] - }, - { - "cell_type": "markdown", - "id": "10", - "metadata": {}, - "source": [ - "## 3. Distribuzione degli esiti\n", - "\n", - "La **variabile target** in questo progetto è `final_result`, che assume quattro valori:\n", - "\n", - "| Valore | Significato |\n", - "|--------|------------|\n", - "| **Pass** | Lo studente ha completato il corso e superato l'esame |\n", - "| **Distinction** | Lo studente ha completato con lode |\n", - "| **Fail** | Lo studente è rimasto iscritto ma non ha superato l'esame |\n", - "| **Withdrawn** | Lo studente si è ritirato attivamente prima della fine |\n", - "\n", - "Per la maggior parte delle analisi, **binarizziamo** questo in:\n", - "- **Completed** (= Pass + Distinction) → `completed = 1`\n", - "- **Not completed** (= Fail + Withdrawn) → `completed = 0`\n", - "\n", - "Questa binarizzazione è documentata in [ADR-006](../docs/ADR.md) e coerente con la letteratura OULAD. La distinzione chiave da tenere a mente: **Withdrawn ≠ Fail**. Gli studenti Withdrawn se ne sono andati attivamente (un segnale comportamentale); gli studenti Fail sono rimasti ma non hanno superato l'esame (un esito accademico). Questa distinzione è rilevante per il design degli interventi (BQ5)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "11", - "metadata": {}, - "outputs": [], - "source": [ - "# --- 4-class outcome distribution ---\n", - "df_outcome = execute_query('''\n", - " SELECT\n", - " final_result,\n", - " COUNT(*) AS n_enrollments,\n", - " ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct\n", - " FROM v_student_enriched\n", - " GROUP BY final_result\n", - " ORDER BY n_enrollments DESC\n", - "''')\n", - "\n", - "print('=== Outcome Distribution (4-class) ===\\n')\n", - "print(df_outcome.to_string(index=False))\n", - "\n", - "# --- Horizontal bar chart ---\n", - "# Ordered from largest to smallest category for visual hierarchy\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "colors = [PALETTE_OUTCOME[r] for r in df_outcome['final_result']]\n", - "bars = ax.barh(df_outcome['final_result'], df_outcome['n_enrollments'], color=colors,\n", - " edgecolor='white')\n", - "\n", - "# Annotate each bar with count and percentage\n", - "for bar, (_, row) in zip(bars, df_outcome.iterrows()):\n", - " ax.text(\n", - " bar.get_width() + ax.get_xlim()[1] * 0.01,\n", - " bar.get_y() + bar.get_height() / 2,\n", - " f\"{int(row['n_enrollments']):,} ({row['pct']:.1f}%)\",\n", - " va='center', fontsize=11,\n", - " )\n", - "\n", - "ax.set_xlabel(LABEL_NUM_ENROLLMENTS)\n", - "ax.set_title('How Did Students Finish? (4-class outcome)')\n", - "ax.invert_yaxis() # largest at top\n", - "sns.despine(left=True)\n", - "fig.tight_layout()\n", - "save_fig(fig, '01_outcome_distribution_4class')\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "12", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Binary outcome (completed vs not completed) ---\n", - "# This is the \"headline number\" for the project\n", - "df_binary = execute_query('''\n", - " SELECT\n", - " completed,\n", - " COUNT(*) AS n,\n", - " ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct\n", - " FROM v_student_enriched\n", - " GROUP BY completed\n", - " ORDER BY completed DESC\n", - "''')\n", - "\n", - "# Donut chart — simple, clean, conveys the key ratio at a glance\n", - "fig, ax = plt.subplots(figsize=(7, 7))\n", - "labels = [LABEL_BINARY[c] for c in df_binary['completed']]\n", - "colors = [PALETTE_BINARY[c] for c in df_binary['completed']]\n", - "wedges, texts, autotexts = ax.pie(\n", - " df_binary['n'], labels=labels, colors=colors, autopct='%1.1f%%',\n", - " startangle=90, pctdistance=0.75, textprops={'fontsize': 13},\n", - ")\n", - "# Create the donut hole\n", - "centre_circle = plt.Circle((0, 0), 0.50, fc='white')\n", - "ax.add_artist(centre_circle)\n", - "ax.set_title('Overall Completion Rate (binary)', fontsize=14, pad=20)\n", - "fig.tight_layout()\n", - "save_fig(fig, '01_outcome_distribution_binary')\n", - "plt.show()\n", - "\n", - "# Print the headline number\n", - "completion_rate = df_binary.loc[df_binary['completed'] == 1, 'pct'].iloc[0]\n", - "print(f'\\nHeadline: {completion_rate:.1f}% of enrollments result in completion.')" - ] - }, - { - "cell_type": "markdown", - "id": "13", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - **Withdrawn** è la categoria di non completamento più numerosa. Questi studenti se ne sono andati attivamente — sono il target primario per gli interventi di retention.\n", - "> - **Fail** rappresenta studenti che sono rimasti coinvolti ma non hanno superato l'esame. Hanno bisogno di un tipo diverso di supporto (accademico, non motivazionale).\n", - "> - Da una prospettiva di piattaforma: una quota significativa degli studenti che si iscrivono **non** completa. Questo è il problema centrale che questo progetto indaga.\n", - ">\n", - "> Le sezioni successive esploreranno *chi* sono questi studenti e *dove* gli esiti differiscono." - ] - }, - { - "cell_type": "markdown", - "id": "14", - "metadata": {}, - "source": [ - "## 4. Esiti per corso\n", - "\n", - "Prima di esaminare i dati demografici o il comportamento, verifichiamo se gli esiti variano sostanzialmente **tra i corsi**. Se un corso ha un tasso di completamento del 70% e un altro del 30%, allora qualsiasi media a livello di popolazione maschera differenze strutturali importanti.\n", - "\n", - "Questa è un'anteprima per **BQ4** (\"Come influiscono le caratteristiche del corso sulla retention?\"), che il Notebook 06 analizzerà in profondità." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "15", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Outcome proportions by module ---\n", - "# 100% stacked bar: normalizes for enrollment size, making courses comparable\n", - "df_by_module = execute_query('''\n", - " SELECT\n", - " code_module,\n", - " final_result,\n", - " COUNT(*) AS n\n", - " FROM v_student_enriched\n", - " GROUP BY code_module, final_result\n", - " ORDER BY code_module, final_result\n", - "''')\n", - "\n", - "# Pivot to get one column per outcome category\n", - "df_pivot = df_by_module.pivot(index='code_module', columns='final_result', values='n').fillna(0)\n", - "# Normalize each row to 100%\n", - "df_pct = df_pivot.div(df_pivot.sum(axis=1), axis=0) * 100\n", - "\n", - "# Reorder columns for visual consistency (positive outcomes first)\n", - "col_order = ['Distinction', 'Pass', 'Fail', 'Withdrawn']\n", - "df_pct = df_pct[[c for c in col_order if c in df_pct.columns]]\n", - "\n", - "# Sort modules by completion rate (Distinction + Pass) for easier reading\n", - "df_pct['_completion'] = df_pct.get('Distinction', 0) + df_pct.get('Pass', 0)\n", - "df_pct = df_pct.sort_values('_completion', ascending=True)\n", - "df_pct = df_pct.drop(columns='_completion')\n", - "\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "df_pct.plot.barh(\n", - " stacked=True, ax=ax,\n", - " color=[PALETTE_OUTCOME[c] for c in df_pct.columns],\n", - " edgecolor='white', linewidth=0.5,\n", - ")\n", - "ax.set_xlabel('Percentage of enrollments')\n", - "ax.set_title('Outcome Distribution by Module (normalized to 100%)')\n", - "ax.legend(title='Outcome', bbox_to_anchor=(1.02, 1), loc='upper left')\n", - "ax.xaxis.set_major_formatter(mticker.PercentFormatter())\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '01_outcome_by_module')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "16", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - Gli esiti variano **sostanzialmente** tra i moduli. Questo conferma che i fattori a livello di corso contano e giustifica BQ4.\n", - "> - I moduli in fondo al grafico hanno i tassi combinati Pass + Distinction più alti; quelli in cima hanno il maggior numero di ritiri.\n", - "> - Il segmento Withdrawn (viola) varia tra i corsi, suggerendo che alcuni design dei corsi possano essere più inclini a innescare abbandoni precoci.\n", - ">\n", - "> **Implicazione:** Qualsiasi analisi di retention che ignori l'identità del corso rischia il [paradosso di Simpson](https://en.wikipedia.org/wiki/Simpson%27s_paradox) — dove un trend aggregato si inverte all'interno dei sottogruppi. Manterremo la consapevolezza del corso in tutta l'analisi." - ] - }, - { - "cell_type": "markdown", - "id": "17", - "metadata": {}, - "source": [ - "## 5. Profilo demografico\n", - "\n", - "Capire **chi sono gli studenti** è essenziale prima di analizzare **cosa hanno fatto**. Questa sezione profila la popolazione studentesca attraverso le principali variabili demografiche ed esamina se i tassi di completamento differiscono per gruppo.\n", - "\n", - "**Variabili esaminate:**\n", - "- **Gender** — binario in OULAD (M/F)\n", - "- **Age band** — categorizzato come 0-35, 35-55, o 55<=\n", - "- **Highest education** — livello di qualifica precedente\n", - "- **IMD band** — Index of Multiple Deprivation (indicatore socio-economico, specifico UK)\n", - "- **Variabili numeriche** — `num_of_prev_attempts` e `studied_credits`\n", - "\n", - "**Avvertenza importante:** Osservare che un gruppo demografico ha un tasso di completamento più basso **non** significa che quel fattore demografico *causi* un completamento più basso. Correlazione non è causalità. BQ3 confronterà formalmente il potere esplicativo dei dati demografici rispetto al comportamento.\n", - "\n", - "Ogni grafico qui sotto mostra la **distribuzione della popolazione** (altezza della barra = numero di iscrizioni) e il **tasso di completamento** per gruppo (percentuale annotata). Questa doppia vista rivela contemporaneamente sia la dimensione del gruppo che il pattern degli esiti." - ] - }, - { - "cell_type": "markdown", - "id": "18", - "metadata": {}, - "source": [ - "### 5a. Genere" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "19", - "metadata": {}, - "outputs": [], - "source": [ - "df_gender = query_demo_distribution('gender')\n", - "print(df_gender.to_string(index=False))\n", - "plot_demo_completion(df_gender, 'Gender Distribution & Completion Rate', '01_demo_gender')" - ] - }, - { - "cell_type": "markdown", - "id": "20", - "metadata": {}, - "source": [ - "### 5b. Fascia d'età" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "21", - "metadata": {}, - "outputs": [], - "source": [ - "df_age = query_demo_distribution('age_band')\n", - "print(df_age.to_string(index=False))\n", - "plot_demo_completion(df_age, 'Age Band Distribution & Completion Rate', '01_demo_age_band')" - ] - }, - { - "cell_type": "markdown", - "id": "22", - "metadata": {}, - "source": [ - "### 5c. Titolo di studio più alto\n", - "\n", - "Questa variabile cattura la qualifica più alta che lo studente aveva **prima** di iscriversi. Le categorie vanno da 'No Formal quals' a 'Post Graduate Qualification'. Usiamo barre orizzontali perché le etichette delle categorie sono lunghe." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "23", - "metadata": {}, - "outputs": [], - "source": [ - "df_edu = query_demo_distribution('highest_education')\n", - "print(df_edu.to_string(index=False))\n", - "plot_demo_completion(\n", - " df_edu, 'Education Level Distribution & Completion Rate',\n", - " '01_demo_education', horizontal=True,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "24", - "metadata": {}, - "source": [ - "### 5d. IMD Band (Index of Multiple Deprivation)\n", - "\n", - "**Cos'è l'IMD?** L'Index of Multiple Deprivation è una misura governativa britannica della deprivazione relativa per piccole aree. Combina indicatori di reddito, occupazione, istruzione, salute, criminalità, alloggio e ambiente. In OULAD:\n", - "- **0-10%** = aree più deprivate (decile più basso)\n", - "- **90-100%** = aree meno deprivate (decile più alto)\n", - "\n", - "Questo è un proxy socio-economico, non una misura del reddito individuale. Ci dice dell'*area* in cui lo studente vive, non delle sue finanze personali.\n", - "\n", - "> **Nota:** L'IMD band ha valori mancanti noti in OULAD. Li quantificheremo nella sezione Qualità dei dati." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "25", - "metadata": {}, - "outputs": [], - "source": [ - "df_imd = query_demo_distribution('imd_band')\n", - "print(df_imd.to_string(index=False))\n", - "plot_demo_completion(\n", - " df_imd, 'IMD Band Distribution & Completion Rate',\n", - " '01_demo_imd_band',\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "26", - "metadata": {}, - "source": [ - "### 5e. Variabili numeriche: tentativi precedenti e crediti\n", - "\n", - "Due variabili demografiche numeriche meritano attenzione:\n", - "- **`num_of_prev_attempts`**: quante volte lo studente ha precedentemente tentato questo modulo (0 = primo tentativo)\n", - "- **`studied_credits`**: carico totale di crediti che lo studente sta portando in questa presentazione\n", - "\n", - "Usiamo box plot suddivisi per esito per confrontare le distribuzioni tra i gruppi Completed e Not completed." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "27", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Numeric demographics by outcome ---\n", - "df_numeric = execute_query('''\n", - " SELECT\n", - " num_of_prev_attempts,\n", - " studied_credits,\n", - " completed\n", - " FROM v_student_enriched\n", - "''')\n", - "df_numeric['outcome'] = df_numeric['completed'].map(LABEL_BINARY)\n", - "\n", - "fig, axes = plt.subplots(1, 2, figsize=FIG_SIZE_WIDE)\n", - "\n", - "# Previous attempts\n", - "sns.boxplot(\n", - " data=df_numeric, x='outcome', y='num_of_prev_attempts',\n", - " palette=PALETTE_BINARY_LABELS, ax=axes[0],\n", - ")\n", - "axes[0].set_title('Previous Attempts by Outcome')\n", - "axes[0].set_xlabel('')\n", - "axes[0].set_ylabel('Number of previous attempts')\n", - "\n", - "# Studied credits\n", - "sns.boxplot(\n", - " data=df_numeric, x='outcome', y='studied_credits',\n", - " palette=PALETTE_BINARY_LABELS, ax=axes[1],\n", - ")\n", - "axes[1].set_title('Studied Credits by Outcome')\n", - "axes[1].set_xlabel('')\n", - "axes[1].set_ylabel('Total studied credits')\n", - "\n", - "sns.despine()\n", - "fig.suptitle('Numeric Demographics by Completion Outcome', fontsize=14, y=1.02)\n", - "fig.tight_layout()\n", - "save_fig(fig, '01_demo_numeric_by_outcome')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "28", - "metadata": {}, - "source": [ - "### Riepilogo demografico\n", - "\n", - "**Osservazioni chiave da questa sezione:**\n", - "- I tassi di completamento **variano per gruppo demografico**, ma l'entità delle differenze varia.\n", - "- Titolo di studio e IMD band tendono a mostrare la maggiore dispersione nei tassi di completamento.\n", - "- Studenti con più tentativi precedenti possono avere tassi di completamento più bassi — ma questo potrebbe riflettere la difficoltà intrinseca del corso piuttosto che la capacità dello studente.\n", - "\n", - "> **Avvertenza sulla causalità:** Osservare che il Gruppo A completa a un tasso più alto del Gruppo B NON significa che *essere nel Gruppo A causi* un completamento più alto. Ci sono fattori confondenti (es. scelta del corso, motivazione, conoscenze pregresse) che non possiamo isolare dalla sola demografica. BQ3 (Notebook 05) confronterà formalmente il potere esplicativo dei dati demografici rispetto al comportamento — la risposta ha implicazioni dirette su dove una piattaforma dovrebbe investire le proprie risorse." - ] - }, - { - "cell_type": "markdown", - "id": "29", - "metadata": {}, - "source": [ - "## 6. Pattern di iscrizione\n", - "\n", - "Quando si registrano gli studenti rispetto all'inizio del corso?\n", - "\n", - "In OULAD, `date_registration` è misurato in **giorni relativi all'inizio del corso** (giorno 0). Valori negativi significano che lo studente si è registrato *prima* dell'inizio ufficiale del corso — questo è comune nei sistemi universitari dove le iscrizioni aprono settimane o mesi in anticipo.\n", - "\n", - "**Perché è importante:** La registrazione anticipata può segnalare maggiore motivazione o migliore pianificazione. Se chi si registra presto completa a tassi più alti, questo è un marker comportamentale (anche se non necessariamente causale) che potrebbe informare la tempistica degli interventi." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "30", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Registration day distribution by outcome ---\n", - "df_reg = execute_query('''\n", - " SELECT\n", - " date_registration,\n", - " completed\n", - " FROM v_student_enriched\n", - " WHERE date_registration IS NOT NULL\n", - "''')\n", - "df_reg['outcome'] = df_reg['completed'].map(LABEL_BINARY)\n", - "\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "# Overlapping histograms: one per outcome group\n", - "for outcome_val, label in LABEL_BINARY.items():\n", - " subset = df_reg[df_reg['completed'] == outcome_val]\n", - " ax.hist(\n", - " subset['date_registration'], bins=50, alpha=0.6,\n", - " label=label, color=PALETTE_BINARY[outcome_val],\n", - " )\n", - "\n", - "# Vertical line at day 0 (course start)\n", - "ax.axvline(x=0, color='black', linestyle='--', linewidth=1, label='Course start (day 0)')\n", - "\n", - "ax.set_xlabel('Registration day (relative to course start)')\n", - "ax.set_ylabel(LABEL_NUM_ENROLLMENTS)\n", - "ax.set_title('When Do Students Register? (by completion outcome)')\n", - "ax.legend()\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '01_enrollment_registration_day')\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "31", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Registration day summary statistics by outcome ---\n", - "# PERCENTILE_CONT is the ANSI SQL standard for computing medians —\n", - "# MEDIAN() is a DuckDB shortcut that would break on BigQuery migration\n", - "df_reg_stats = execute_query('''\n", - " SELECT\n", - " CASE WHEN completed = 1 THEN 'Completed' ELSE 'Not completed' END AS outcome,\n", - " COUNT(*) AS n,\n", - " ROUND(AVG(date_registration), 1) AS mean_day,\n", - " ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY date_registration), 1) AS median_day,\n", - " MIN(date_registration) AS min_day,\n", - " MAX(date_registration) AS max_day\n", - " FROM v_student_enriched\n", - " WHERE date_registration IS NOT NULL\n", - " GROUP BY completed\n", - " ORDER BY completed DESC\n", - "''')\n", - "\n", - "print('=== Registration Day Statistics by Outcome ===\\n')\n", - "df_reg_stats" - ] - }, - { - "cell_type": "markdown", - "id": "32", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - La maggior parte degli studenti si registra **prima** dell'inizio del corso (giorno di registrazione negativo), come previsto.\n", - "> - Se c'è una differenza tra completers e non-completers nella tempistica di registrazione, ciò suggerisce che la **tempistica di iscrizione** potrebbe essere un segnale precoce debole. Tuttavia, è un dato osservazionale — la registrazione anticipata potrebbe semplicemente essere un proxy di motivazione o supporto istituzionale, non causare direttamente il completamento.\n", - "> - Studenti che si registrano molto tardi (giorno di registrazione positivo, cioè dopo l'inizio del corso) potrebbero già essere in svantaggio per via dei contenuti persi." - ] - }, - { - "cell_type": "markdown", - "id": "33", - "metadata": {}, - "source": [ - "## 7. Panoramica dei corsi\n", - "\n", - "Passiamo ora da *chi sono gli studenti* a *come sono i corsi*.\n", - "\n", - "Ogni course-presentation ha caratteristiche di design diverse: durata, densità degli assessment, diversità delle risorse VLE. Qualcuna di queste è correlata ai tassi di completamento?\n", - "\n", - "**Avvertenza:** Con solo ~22 course-presentation, la dimensione campionaria è molto piccola per un'analisi di correlazione. I pattern qui sono **suggestivi**, non conclusivi. BQ4 (Notebook 06) analizzerà gli effetti del corso in modo più rigoroso." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "34", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Course design vs. outcomes: 1x3 scatter plot ---\n", - "# Each dot is a course-presentation\n", - "fig, axes = plt.subplots(1, 3, figsize=(18, 5))\n", - "\n", - "# 1. Assessment density vs. completion rate\n", - "axes[0].scatter(\n", - " df_courses['assessments_per_30_days'], df_courses['completion_rate_pct'],\n", - " s=df_courses['n_enrolled'] / 5, # bubble size proportional to enrollment\n", - " alpha=0.7, color='#4C72B0', edgecolor='white',\n", - ")\n", - "axes[0].set_xlabel('Assessments per 30 days')\n", - "axes[0].set_ylabel('Completion rate (%)')\n", - "axes[0].set_title('Assessment Density vs. Completion')\n", - "\n", - "# 2. VLE resources vs. completion rate\n", - "axes[1].scatter(\n", - " df_courses['n_vle_resources'], df_courses['completion_rate_pct'],\n", - " s=df_courses['n_enrolled'] / 5,\n", - " alpha=0.7, color='#55A868', edgecolor='white',\n", - ")\n", - "axes[1].set_xlabel('Number of VLE resources')\n", - "axes[1].set_ylabel('Completion rate (%)')\n", - "axes[1].set_title('VLE Resources vs. Completion')\n", - "\n", - "# 3. Course length vs. withdrawal rate\n", - "axes[2].scatter(\n", - " df_courses['course_length_days'], df_courses['withdrawal_rate_pct'],\n", - " s=df_courses['n_enrolled'] / 5,\n", - " alpha=0.7, color='#C44E52', edgecolor='white',\n", - ")\n", - "axes[2].set_xlabel('Course length (days)')\n", - "axes[2].set_ylabel('Withdrawal rate (%)')\n", - "axes[2].set_title('Course Length vs. Withdrawal')\n", - "\n", - "for ax in axes:\n", - " sns.despine(ax=ax)\n", - "\n", - "fig.suptitle('Course Design Characteristics vs. Student Outcomes\\n(bubble size = enrollment)',\n", - " fontsize=13, y=1.04)\n", - "fig.tight_layout()\n", - "save_fig(fig, '01_course_scatter_outcomes')\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "35", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Correlation heatmap of course-level numeric features ---\n", - "# Which course characteristics move together?\n", - "cols_numeric = [\n", - " 'course_length_days', 'n_enrolled', 'completion_rate_pct',\n", - " 'withdrawal_rate_pct', 'n_assessments', 'n_vle_resources',\n", - "]\n", - "# Shorter labels for readability in the heatmap\n", - "label_map = {\n", - " 'course_length_days': 'Length (days)',\n", - " 'n_enrolled': 'Enrolled',\n", - " 'completion_rate_pct': 'Completion %',\n", - " 'withdrawal_rate_pct': 'Withdrawal %',\n", - " 'n_assessments': 'Assessments',\n", - " 'n_vle_resources': 'VLE resources',\n", - "}\n", - "\n", - "df_corr = df_courses[cols_numeric].rename(columns=label_map).corr()\n", - "\n", - "fig, ax = plt.subplots(figsize=(8, 6))\n", - "sns.heatmap(\n", - " df_corr, annot=True, fmt='.2f', cmap=PALETTE_SEQUENTIAL,\n", - " vmin=-1, vmax=1, center=0, square=True, linewidths=0.5,\n", - " ax=ax,\n", - ")\n", - "ax.set_title('Correlation Between Course-Level Features')\n", - "fig.tight_layout()\n", - "save_fig(fig, '01_course_correlation_heatmap')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "36", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - Gli scatter plot mostrano se le caratteristiche di design del corso (frequenza degli assessment, quantità di risorse, durata) hanno qualche relazione visibile con gli esiti. La dimensione della bolla codifica il conteggio delle iscrizioni, dando un'idea di quali corsi contribuiscono più dati.\n", - "> - La heatmap di correlazione rivela **relazioni lineari** tra le caratteristiche dei corsi. Correlazioni forti (vicine a +1 o -1) suggeriscono caratteristiche che si muovono insieme; correlazioni deboli (vicine a 0) suggeriscono indipendenza.\n", - "> - Con solo ~22 data point (course-presentation), **i singoli outlier possono dominare** le correlazioni. Questi risultati sono ipotesi esplorative, non effetti confermati.\n", - ">\n", - "> BQ4 (Notebook 06) analizzerà gli effetti del corso in dettaglio, controllando per la demografica degli studenti." - ] - }, - { - "cell_type": "markdown", - "id": "37", - "metadata": {}, - "source": [ - "## 8. Valutazione della qualità dei dati\n", - "\n", - "**Perché verificare la qualità dei dati?** \n", - "Anche dataset pubblici ben curati possono riservare sorprese: valori mancanti, categorie inattese, outlier o lacune di copertura. Un'EDA professionale include sempre un controllo qualità — *fidarsi ma verificare*.\n", - "\n", - "Cosa verifichiamo:\n", - "1. **Valori mancanti** — quali colonne hanno NULL e quanti?\n", - "2. **Cardinalità** — quanti valori distinti ha ogni colonna categorica?\n", - "3. **Controlli di range** — i valori numerici sono entro i limiti previsti?\n", - "4. **Lacune di copertura** — tutte le viste contengono il numero atteso di studenti?" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "38", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Missing value audit ---\n", - "# Count NULLs per column in v_student_enriched\n", - "df_nulls = execute_query('''\n", - " SELECT\n", - " COUNT(*) AS total_rows,\n", - " SUM(CASE WHEN gender IS NULL THEN 1 ELSE 0 END) AS null_gender,\n", - " SUM(CASE WHEN region IS NULL THEN 1 ELSE 0 END) AS null_region,\n", - " SUM(CASE WHEN highest_education IS NULL THEN 1 ELSE 0 END) AS null_education,\n", - " SUM(CASE WHEN imd_band IS NULL THEN 1 ELSE 0 END) AS null_imd_band,\n", - " SUM(CASE WHEN age_band IS NULL THEN 1 ELSE 0 END) AS null_age_band,\n", - " SUM(CASE WHEN disability IS NULL THEN 1 ELSE 0 END) AS null_disability,\n", - " SUM(CASE WHEN date_registration IS NULL THEN 1 ELSE 0 END) AS null_registration,\n", - " SUM(CASE WHEN final_result IS NULL THEN 1 ELSE 0 END) AS null_final_result\n", - " FROM v_student_enriched\n", - "''')\n", - "\n", - "total = df_nulls['total_rows'].iloc[0]\n", - "# Transpose for readability: one row per column\n", - "null_cols = [c for c in df_nulls.columns if c.startswith('null_')]\n", - "null_summary = pd.DataFrame({\n", - " 'column': [c.replace('null_', '') for c in null_cols],\n", - " 'null_count': [int(df_nulls[c].iloc[0]) for c in null_cols],\n", - "})\n", - "null_summary['null_pct'] = (null_summary['null_count'] / total * 100).round(2)\n", - "\n", - "print(f'=== Missing Value Audit ({total:,} total rows) ===\\n')\n", - "null_summary" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "39", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Cardinality check ---\n", - "# How many distinct values does each categorical column have?\n", - "df_card = execute_query('''\n", - " SELECT\n", - " COUNT(DISTINCT gender) AS distinct_gender,\n", - " COUNT(DISTINCT region) AS distinct_region,\n", - " COUNT(DISTINCT highest_education) AS distinct_education,\n", - " COUNT(DISTINCT imd_band) AS distinct_imd,\n", - " COUNT(DISTINCT age_band) AS distinct_age,\n", - " COUNT(DISTINCT disability) AS distinct_disability,\n", - " COUNT(DISTINCT final_result) AS distinct_result,\n", - " COUNT(DISTINCT code_module) AS distinct_module\n", - " FROM v_student_enriched\n", - "''')\n", - "\n", - "print('=== Cardinality (distinct values per column) ===\\n')\n", - "card_summary = df_card.T.reset_index()\n", - "card_summary.columns = ['column', 'n_distinct']\n", - "card_summary['column'] = card_summary['column'].str.replace('distinct_', '')\n", - "card_summary" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "40", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Range checks for numeric columns ---\n", - "df_ranges = execute_query('''\n", - " SELECT\n", - " MIN(num_of_prev_attempts) AS min_prev_attempts,\n", - " MAX(num_of_prev_attempts) AS max_prev_attempts,\n", - " MIN(studied_credits) AS min_credits,\n", - " MAX(studied_credits) AS max_credits,\n", - " MIN(date_registration) AS min_reg_day,\n", - " MAX(date_registration) AS max_reg_day,\n", - " MIN(dropout_day) AS min_dropout_day,\n", - " MAX(dropout_day) AS max_dropout_day\n", - " FROM v_student_enriched\n", - "''')\n", - "\n", - "print('=== Numeric Range Checks ===\\n')\n", - "# Reshape for readability\n", - "ranges = {\n", - " 'num_of_prev_attempts': (df_ranges['min_prev_attempts'].iloc[0], df_ranges['max_prev_attempts'].iloc[0]),\n", - " 'studied_credits': (df_ranges['min_credits'].iloc[0], df_ranges['max_credits'].iloc[0]),\n", - " 'date_registration': (df_ranges['min_reg_day'].iloc[0], df_ranges['max_reg_day'].iloc[0]),\n", - " 'dropout_day': (df_ranges['min_dropout_day'].iloc[0], df_ranges['max_dropout_day'].iloc[0]),\n", - "}\n", - "pd.DataFrame(ranges, index=['min', 'max']).T" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "41", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Coverage check: \"ghost students\" ---\n", - "# v_engagement_early only contains enrollments with at least one VLE click\n", - "# in days 0-28. Enrollments with ZERO activity are absent from that view.\n", - "# The gap between v_student_enriched and v_engagement_early = ghost students.\n", - "df_coverage = execute_query('''\n", - " SELECT\n", - " (SELECT COUNT(*) FROM v_student_enriched) AS n_total,\n", - " (SELECT COUNT(*) FROM v_engagement_early) AS n_with_activity\n", - "''')\n", - "\n", - "n_total = df_coverage['n_total'].iloc[0]\n", - "n_active = df_coverage['n_with_activity'].iloc[0]\n", - "n_ghosts = n_total - n_active\n", - "ghost_pct = 100 * n_ghosts / n_total\n", - "\n", - "print('=== Engagement Coverage Check ===')\n", - "print(f' Enrollments in v_student_enriched: {n_total:>8,}')\n", - "print(f' Enrollments in v_engagement_early: {n_active:>8,}')\n", - "print(f' \"Ghost students\" (zero activity): {n_ghosts:>8,} ({ghost_pct:.1f}%)')\n", - "print(f'\\n → {n_ghosts:,} enrollments had zero VLE activity in the first 28 days.')\n", - "print(' These enrollments are ABSENT from v_engagement_early by design.')\n", - "print(' This is not a data quality issue — it is an analytical finding (BQ5 segment).')" - ] - }, - { - "cell_type": "markdown", - "id": "42", - "metadata": {}, - "source": [ - "> **Verdetto sulla qualità dei dati:**\n", - "> - **Valori mancanti**: `imd_band` ha NULL noti (documentati in OULAD). Le altre colonne sono generalmente complete.\n", - "> - **Cardinalità**: tutte le colonne categoriche hanno il numero atteso di valori distinti — nessuna categoria inattesa.\n", - "> - **Range**: i valori numerici sono entro limiti plausibili. I giorni di registrazione negativi sono previsti (iscrizione pre-corso). I giorni di dropout sono positivi (relativi all'inizio del corso).\n", - "> - **Ghost student**: una quota misurabile di iscrizioni ha zero attività VLE nei primi 28 giorni. Questi non sono dati mancanti — questi studenti semplicemente non hanno mai interagito con la piattaforma. Rappresentano un segmento distinto per gli interventi (BQ5).\n", - ">\n", - "> **Conclusione:** Il dataset è sufficientemente pulito per l'analisi. L'unica avvertenza riguarda i null di `imd_band`, che gestiamo includendo la categoria NULL nelle analisi o escludendola dove indicato." - ] - }, - { - "cell_type": "markdown", - "id": "43", - "metadata": {}, - "source": [ - "## 9. Baseline di engagement — Anteprima\n", - "\n", - "Prima di chiudere questo notebook, diamo un **breve sguardo** all'engagement precoce. L'analisi comportamentale completa è nel Notebook 02 — qui stabiliamo solo una baseline.\n", - "\n", - "Usiamo `v_engagement_early`, che aggrega l'attività clickstream per i primi 28 giorni di ciascun corso. **Importante:** questa vista include solo studenti con almeno un click — i \"ghost student\" identificati nella Sezione 8 non sono in questa vista." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "44", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Early engagement summary + outcome split ---\n", - "df_engage = execute_query('''\n", - " SELECT\n", - " ee.total_clicks_first_28,\n", - " ee.active_days_first_28,\n", - " se.completed\n", - " FROM v_engagement_early ee\n", - " JOIN v_student_enriched se\n", - " USING (id_student, code_module, code_presentation)\n", - "''')\n", - "df_engage['outcome'] = df_engage['completed'].map(LABEL_BINARY)\n", - "\n", - "# Summary statistics\n", - "print('=== Early Engagement (first 28 days) ===\\n')\n", - "print(df_engage.groupby('outcome')[['total_clicks_first_28', 'active_days_first_28']]\n", - " .describe().round(1))\n", - "\n", - "# Violin plot: total clicks by outcome\n", - "# Violins show the full distribution shape, not just quartiles\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "sns.violinplot(\n", - " data=df_engage, x='outcome', y='total_clicks_first_28',\n", - " palette=PALETTE_BINARY_LABELS, inner='quartile', ax=ax,\n", - ")\n", - "ax.set_xlabel('')\n", - "ax.set_ylabel('Total clicks in first 28 days')\n", - "ax.set_title('Early Engagement by Completion Outcome')\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '01_engagement_preview_by_outcome')\n", - "plt.show()\n", - "\n", - "print('\\nNote: \"ghost students\" (zero activity) are excluded from this view.')\n", - "print('The full engagement analysis is in Notebook 02.')" - ] - }, - { - "cell_type": "markdown", - "id": "45", - "metadata": {}, - "source": [ - "## 10. Conclusioni e prossimi passi\n", - "\n", - "### Cosa abbiamo imparato\n", - "\n", - "1. **Scala**: Il dataset OULAD contiene ~32K iscrizioni di ~28K studenti unici in 22 course-presentation. L'unità di analisi è l'iscrizione, non lo studente.\n", - "\n", - "2. **La sfida del completamento**: Una quota significativa delle iscrizioni non si traduce in completamento. Gli studenti ritirati (che se ne sono andati attivamente) superano in numero quelli che sono rimasti ma non hanno superato l'esame — suggerendo che la **retention**, non il supporto accademico, è la leva principale.\n", - "\n", - "3. **Variazione tra corsi**: I tassi di completamento variano sostanzialmente tra i moduli. Qualsiasi analisi che ignori l'identità del corso rischia il paradosso di Simpson.\n", - "\n", - "4. **La demografica conta — ma quanto?** I tassi di completamento differiscono per titolo di studio, IMD band e altre variabili demografiche. BQ3 testerà se queste differenze sono abbastanza grandi da essere azionabili, o se il comportamento è un segnale più forte.\n", - "\n", - "5. **Segnale di engagement precoce**: Anche in questa breve anteprima, gli studenti che completano mostrano un'attività VLE notevolmente più alta nei primi 28 giorni. BQ2 quantificherà la forza predittiva di questi segnali precoci.\n", - "\n", - "6. **Ghost student**: Un segmento misurabile di studenti si iscrive ma non interagisce mai con il VLE. Sono un target naturale per interventi precoci (BQ5).\n", - "\n", - "7. **Qualità dei dati**: Il dataset è pulito. Le avvertenze note (null di imd_band, copertura ghost student) sono documentate e gestite.\n", - "\n", - "### Cosa viene dopo\n", - "\n", - "| Notebook | Business Question | Focus |\n", - "|----------|------------------|-------|\n", - "| **02** | — | EDA: pattern di engagement (clickstream giornaliero, trend temporali) |\n", - "| **03** | BQ1 | Dove e quando abbandonano gli studenti? |\n", - "| **04** | BQ2 | Quali segnali comportamentali precoci predicono l'abbandono? |\n", - "| **05** | BQ3 | Demografica vs. comportamento — cosa predice meglio l'esito? |\n", - "| **06** | BQ4 | Come influiscono le caratteristiche del corso sulla retention? |\n", - "| **07** | BQ5 | Top 3 interventi azionabili |\n", - "\n", - "---\n", - "\n", - "**Riproducibilità:** Tutte le figure sono salvate in `reports/figures/`. Per riprodurre questo notebook, esegui prima `python -m run_pipeline`, poi esegui tutte le celle in ordine." - ] - }, - { - "cell_type": "markdown", - "id": "46", - "metadata": {}, - "source": [ - "> **Anteprima del risultato:** Gli studenti che completano hanno un **engagement precoce visibilmente più alto** rispetto ai non-completers — sia nel totale dei click che nella forma della distribuzione (coda più lunga verso l'alto engagement).\n", - ">\n", - "> Questa è un'**osservazione descrittiva**, non un'affermazione causale. Sarà quantificata con test statistici (t-test, effect size) nel Notebook 04 (BQ2: segnali comportamentali precoci).\n", - ">\n", - "> Continua al **Notebook 02** (`02_eda_engagement_patterns.ipynb`) per l'analisi comportamentale completa." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "version": "3.13.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/it/02_eda_engagement_patterns.ipynb b/notebooks/it/02_eda_engagement_patterns.ipynb deleted file mode 100644 index 829bc1c..0000000 --- a/notebooks/it/02_eda_engagement_patterns.ipynb +++ /dev/null @@ -1,1445 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "# 02 — EDA: Pattern di Engagement\n", - "\n", - "> **Notebook 02 di 7** | Learning Retention Analytics \n", - "> Seconda analisi esplorativa: come interagiscono gli studenti con la piattaforma e cosa rivelano i pattern di engagement?" - ] - }, - { - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "## Scopo e ambito\n", - "\n", - "Questo notebook è il **secondo di due notebook EDA**. Il Notebook 01 ha profilato la *base studenti* (dati demografici, esiti, panoramica dei corsi). Qui ci spostiamo sull'**engagement comportamentale** — i pattern di clickstream che riflettono cosa gli studenti effettivamente *fanno* sulla piattaforma.\n", - "\n", - "**Cosa copre questo notebook:**\n", - "- Ghost student — iscrizioni con zero attività VLE\n", - "- Distribuzione delle metriche di engagement precoce (primi 28 giorni)\n", - "- Relazione dose-risposta tra decile di engagement ed esito\n", - "- Traiettoria di engagement giornaliero — quando si apre il divario?\n", - "- Tipologia di engagement — binge vs. steady\n", - "- Diversità dei tipi di attività — quali risorse usano gli studenti?\n", - "- Profili di engagement a livello di corso\n", - "- Riepilogo della correlazione engagement-esito\n", - "\n", - "**Cosa viene dopo:**\n", - "- **Notebook 03** (`03_bq1_dropout_timing.ipynb`): dove e quando abbandonano gli studenti? (BQ1)\n", - "\n", - "**Collegamento alle business question:** \n", - "Questa EDA non risponde direttamente a BQ1–BQ5. Piuttosto, costruisce la **base comportamentale** per BQ2 (segnali precoci che predicono l'abbandono), BQ3 (demografica vs. comportamento) e BQ5 (interventi azionabili). Pensalo come *mappare il panorama comportamentale prima di testare ipotesi specifiche*.\n", - "\n", - "> **Trasferibilità metodologica:** I pattern di engagement esplorati qui — ghost user, segnali della finestra di onboarding, utilizzo binge vs. steady, diversità delle attività — sono direttamente portabili alla retention SaaS, al churn da abbonamento e all'engagement delle app fitness. Il *dominio* è l'istruzione; il *framework analitico* è la product analytics." - ] - }, - { - "cell_type": "markdown", - "id": "2", - "metadata": {}, - "source": [ - "## Indice\n", - "\n", - "1. [Setup dell'ambiente](#1.-Setup-dell'ambiente)\n", - "2. [Ghost Student — Zero Engagement](#2.-Ghost-Student-—-Zero-Engagement)\n", - "3. [Distribuzioni dell'engagement precoce](#3.-Distribuzioni-dell'engagement-precoce)\n", - "4. [Decili di engagement vs. esito](#4.-Decili-di-engagement-vs.-esito)\n", - "5. [Traiettoria di engagement giornaliero](#5.-Traiettoria-di-engagement-giornaliero)\n", - "6. [Tipologia di engagement — Binge vs. Steady](#6.-Tipologia-di-engagement-—-Binge-vs.-Steady)\n", - "7. [Diversità dei tipi di attività](#7.-Diversità-dei-tipi-di-attività)\n", - "8. [Profili di engagement a livello di corso](#8.-Profili-di-engagement-a-livello-di-corso)\n", - "9. [Matrice di correlazione engagement-esito](#9.-Matrice-di-correlazione-engagement-esito)\n", - "10. [Conclusioni e prossimi passi](#10.-Conclusioni-e-prossimi-passi)\n", - "\n", - "---\n", - "\n", - "**Prerequisiti:**\n", - "- La pipeline ETL deve essere stata eseguita: `python -m run_pipeline`\n", - "- Il database DuckDB in `data/db/oulad.duckdb` deve contenere tutte e 5 le viste analitiche\n", - "\n", - "**Dataset:** Open University Learning Analytics Dataset (OULAD) — ~32K studenti, 7 corsi, clickstream comportamentale completo. Licenza: CC-BY 4.0." - ] - }, - { - "cell_type": "markdown", - "id": "3", - "metadata": {}, - "source": [ - "## 1. Setup dell'ambiente\n", - "\n", - "Configuriamo import, default di visualizzazione e funzioni helper riutilizzabili.\n", - "\n", - "**Note tecniche per il lettore:**\n", - "- I notebook risiedono in `notebooks/` ma i moduli del progetto sono in `src/` nella root del progetto. Aggiungiamo la project root a `sys.path` affinché `from src.config import ...` funzioni. La regola del linter `E402` (import non in cima al file) è soppressa per i notebook in `pyproject.toml`.\n", - "- Tutte le query al database passano da `src.db.connection.execute_query()` — il layer di astrazione DB del progetto. Questo restituisce un `pandas.DataFrame` e assicura che non si chiami mai `duckdb.connect()` direttamente (vedi ADR-003).\n", - "- Le figure vengono salvate in `reports/figures/` a 150 DPI. Poiché `nbstripout` rimuove gli output del notebook prima del commit, i PNG salvati sono il record visivo persistente." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Path setup ---\n", - "# Notebooks live in notebooks/ but project modules are at the project root.\n", - "# Adding the root to sys.path lets us import from src/ as if running from root.\n", - "# We search upward for pyproject.toml instead of assuming cwd is always notebooks/,\n", - "# so the notebook works regardless of where the kernel is launched from\n", - "# (JupyterLab, VS Code, Cursor, repo root, etc.).\n", - "import sys\n", - "from pathlib import Path\n", - "\n", - "\n", - "def _find_project_root(start: Path) -> Path:\n", - " \"\"\"Walk upward from start until we find pyproject.toml (the repo root marker).\"\"\"\n", - " for candidate in (start, *start.parents):\n", - " if (candidate / 'pyproject.toml').is_file():\n", - " return candidate\n", - " raise RuntimeError(\n", - " f\"Could not locate project root: no pyproject.toml found in '{start}' \"\n", - " \"or any parent directory. Run the notebook from within the repository.\"\n", - " )\n", - "\n", - "\n", - "PROJECT_ROOT = _find_project_root(Path.cwd())\n", - "if str(PROJECT_ROOT) not in sys.path:\n", - " sys.path.insert(0, str(PROJECT_ROOT))\n", - "\n", - "# --- Standard library ---\n", - "import logging\n", - "import warnings\n", - "\n", - "# --- Third-party ---\n", - "import matplotlib.pyplot as plt\n", - "import matplotlib.ticker as mticker\n", - "import numpy as np\n", - "import seaborn as sns\n", - "\n", - "# --- Project modules ---\n", - "from src.config import FIGURES_DIR\n", - "from src.db.connection import execute_query\n", - "\n", - "# --- Configuration ---\n", - "# Suppress noisy warnings in notebook output; errors still surface\n", - "warnings.filterwarnings('ignore', category=FutureWarning)\n", - "logging.basicConfig(level=logging.WARNING)\n", - "\n", - "# --- Visualization defaults ---\n", - "# Consistent style across all project notebooks\n", - "sns.set_theme(style='whitegrid', font_scale=1.1)\n", - "\n", - "# Semantic color palette: one color per outcome category\n", - "PALETTE_OUTCOME = {\n", - " 'Pass': '#4C72B0', # blue — neutral positive\n", - " 'Distinction': '#55A868', # green — strong positive\n", - " 'Fail': '#C44E52', # red — negative\n", - " 'Withdrawn': '#8172B3', # purple — departed (distinct from failed)\n", - "}\n", - "# Outcome label constants — single source of truth for the binary labels\n", - "# used in SQL output mapping, palette keys, and plot iterations\n", - "LABEL_COMPLETED = 'Completed'\n", - "LABEL_NOT_COMPLETED = 'Not completed'\n", - "# Binary version: completed (1) vs not completed (0)\n", - "PALETTE_BINARY = {1: '#55A868', 0: '#C44E52'}\n", - "LABEL_BINARY = {1: LABEL_COMPLETED, 0: LABEL_NOT_COMPLETED}\n", - "# Label-keyed palette for seaborn when x-axis uses mapped string categories\n", - "PALETTE_BINARY_LABELS = {LABEL_COMPLETED: '#55A868', LABEL_NOT_COMPLETED: '#C44E52'}\n", - "# Sequential palette for heatmaps and continuous scales\n", - "PALETTE_SEQUENTIAL = 'YlOrRd'\n", - "# Shared axis labels — avoids cross-cell string literal duplication\n", - "LABEL_NUM_ENROLLMENTS = 'Number of enrollments'\n", - "LABEL_ACTIVE_DAYS = 'Active days (first 28)'\n", - "LABEL_COMPLETION_RATE = 'Completion rate (%)'\n", - "\n", - "FIG_DPI = 150\n", - "FIG_SIZE = (10, 6)\n", - "FIG_SIZE_WIDE = (16, 5)\n", - "\n", - "# Ensure figures output directory exists\n", - "FIGURES_DIR.mkdir(parents=True, exist_ok=True)\n", - "\n", - "\n", - "def save_fig(fig, name: str) -> None:\n", - " \"\"\"Save figure to reports/figures/ with consistent settings.\"\"\"\n", - " path = FIGURES_DIR / f'{name}.png'\n", - " fig.savefig(path, dpi=FIG_DPI, bbox_inches='tight', facecolor='white')\n", - " print(f' Saved: {path}')\n", - "\n", - "\n", - "# --- Prerequisite check ---\n", - "# Verify the database is populated before proceeding.\n", - "# We check both engagement views since this notebook depends on them.\n", - "try:\n", - " _check_daily = execute_query('SELECT COUNT(*) AS n FROM v_engagement_daily')\n", - " _check_early = execute_query('SELECT COUNT(*) AS n FROM v_engagement_early')\n", - " _check_student = execute_query('SELECT COUNT(*) AS n FROM v_student_enriched')\n", - " _n_daily = _check_daily['n'].iloc[0]\n", - " _n_early = _check_early['n'].iloc[0]\n", - " _n_student = _check_student['n'].iloc[0]\n", - " if _n_daily == 0 or _n_early == 0 or _n_student == 0:\n", - " raise RuntimeError('One or more views are empty')\n", - " print('Database OK')\n", - " print(f' v_engagement_daily: {_n_daily:>12,} rows')\n", - " print(f' v_engagement_early: {_n_early:>12,} rows')\n", - " print(f' v_student_enriched: {_n_student:>12,} rows')\n", - "except Exception as exc:\n", - " raise RuntimeError(\n", - " 'Cannot query engagement views. '\n", - " \"Run 'python -m run_pipeline' first to populate the database.\"\n", - " ) from exc" - ] - }, - { - "cell_type": "markdown", - "id": "5", - "metadata": {}, - "source": [ - "## 2. Ghost Student — Zero Engagement\n", - "\n", - "Il segnale comportamentale più estremo è **nessun comportamento**. Un \"ghost student\" è un'iscrizione presente in `v_student_enriched` ma assente da `v_engagement_early` — ovvero zero click VLE nei primi 28 giorni.\n", - "\n", - "**Perché partire da qui?** I ghost student stabiliscono il *pavimento* dell'engagement. Prima di analizzare quanto cliccano gli studenti attivi, dobbiamo sapere quanti studenti non hanno mai cliccato affatto, e cosa è successo loro.\n", - "\n", - "**Parallelo SaaS:** I ghost student sono l'equivalente educativo degli *utenti dormienti* — persone che si sono registrate ma non si sono mai attivate. Nella product analytics, il tasso di attivazione è la prima leva di retention: non puoi trattenere utenti che non hanno mai iniziato a usare il prodotto." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Ghost student identification ---\n", - "# v_engagement_early only includes enrollments with >= 1 VLE click in days 0-28.\n", - "# Enrollments absent from that view had zero activity — these are \"ghost students\".\n", - "# The LEFT JOIN + IS NULL pattern identifies them precisely per enrollment\n", - "# (a student can be a ghost in one course but active in another).\n", - "df_ghost_summary = execute_query('''\n", - " SELECT\n", - " COUNT(*) AS n_total,\n", - " SUM(CASE WHEN ee.id_student IS NULL THEN 1 ELSE 0 END) AS n_ghost,\n", - " SUM(CASE WHEN ee.id_student IS NOT NULL THEN 1 ELSE 0 END) AS n_active\n", - " FROM v_student_enriched se\n", - " LEFT JOIN v_engagement_early ee\n", - " ON se.id_student = ee.id_student\n", - " AND se.code_module = ee.code_module\n", - " AND se.code_presentation = ee.code_presentation\n", - "''')\n", - "\n", - "n_total = df_ghost_summary['n_total'].iloc[0]\n", - "n_ghost = df_ghost_summary['n_ghost'].iloc[0]\n", - "n_active = df_ghost_summary['n_active'].iloc[0]\n", - "\n", - "print('=== Ghost Student Overview ===\\n')\n", - "print(f' Total enrollments: {n_total:>8,}')\n", - "print(f' Active (>= 1 click): {n_active:>8,} ({100 * n_active / n_total:.1f}%)')\n", - "print(f' Ghost (zero activity): {n_ghost:>8,} ({100 * n_ghost / n_total:.1f}%)')\n", - "\n", - "# --- Completion rate comparison ---\n", - "df_ghost_outcome = execute_query('''\n", - " SELECT\n", - " CASE WHEN ee.id_student IS NULL THEN 'Ghost' ELSE 'Active' END AS segment,\n", - " COUNT(*) AS n,\n", - " ROUND(100.0 * SUM(se.completed) / COUNT(*), 1) AS completion_rate_pct\n", - " FROM v_student_enriched se\n", - " LEFT JOIN v_engagement_early ee\n", - " ON se.id_student = ee.id_student\n", - " AND se.code_module = ee.code_module\n", - " AND se.code_presentation = ee.code_presentation\n", - " GROUP BY (ee.id_student IS NULL)\n", - " ORDER BY segment\n", - "''')\n", - "\n", - "print('\\n=== Completion Rate: Ghost vs. Active ===\\n')\n", - "print(df_ghost_outcome.to_string(index=False))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Ghost students by course ---\n", - "# Are ghost students concentrated in specific courses, or distributed evenly?\n", - "df_ghost_by_course = execute_query('''\n", - " SELECT\n", - " se.code_module,\n", - " COUNT(*) AS n_enrolled,\n", - " SUM(CASE WHEN ee.id_student IS NULL THEN 1 ELSE 0 END) AS n_ghost,\n", - " ROUND(\n", - " 100.0 * SUM(CASE WHEN ee.id_student IS NULL THEN 1 ELSE 0 END)\n", - " / COUNT(*), 1\n", - " ) AS ghost_pct,\n", - " ROUND(\n", - " 100.0 * SUM(CASE\n", - " WHEN ee.id_student IS NOT NULL THEN se.completed ELSE 0\n", - " END)\n", - " / NULLIF(SUM(CASE WHEN ee.id_student IS NOT NULL THEN 1 ELSE 0 END), 0),\n", - " 1\n", - " ) AS active_completion_pct,\n", - " ROUND(\n", - " 100.0 * SUM(CASE\n", - " WHEN ee.id_student IS NULL THEN se.completed ELSE 0\n", - " END)\n", - " / NULLIF(SUM(CASE WHEN ee.id_student IS NULL THEN 1 ELSE 0 END), 0),\n", - " 1\n", - " ) AS ghost_completion_pct\n", - " FROM v_student_enriched se\n", - " LEFT JOIN v_engagement_early ee\n", - " ON se.id_student = ee.id_student\n", - " AND se.code_module = ee.code_module\n", - " AND se.code_presentation = ee.code_presentation\n", - " GROUP BY se.code_module\n", - " ORDER BY ghost_pct DESC\n", - "''')\n", - "\n", - "# --- Visualization: 1x2 panel ---\n", - "# Left: ghost count per module. Right: completion rate comparison (ghost vs active).\n", - "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=FIG_SIZE_WIDE)\n", - "\n", - "# Left panel: ghost student count by module\n", - "modules = df_ghost_by_course['code_module']\n", - "ax1.barh(modules, df_ghost_by_course['n_ghost'], color='#8172B3', edgecolor='white')\n", - "for i, (_, row) in enumerate(df_ghost_by_course.iterrows()):\n", - " ax1.text(\n", - " row['n_ghost'] + ax1.get_xlim()[1] * 0.01, i,\n", - " f\"{row['ghost_pct']:.1f}% of enrolled\",\n", - " va='center', fontsize=9, color='#333333',\n", - " )\n", - "ax1.set_xlabel('Number of ghost enrollments')\n", - "ax1.set_title('Ghost Students by Module')\n", - "ax1.invert_yaxis()\n", - "sns.despine(ax=ax1)\n", - "\n", - "# Right panel: completion rate — ghost vs active per module\n", - "x = np.arange(len(modules))\n", - "bar_width = 0.35\n", - "ax2.barh(x - bar_width / 2, df_ghost_by_course['active_completion_pct'],\n", - " bar_width, label='Active', color='#55A868', edgecolor='white')\n", - "ax2.barh(x + bar_width / 2, df_ghost_by_course['ghost_completion_pct'],\n", - " bar_width, label='Ghost', color='#C44E52', edgecolor='white')\n", - "ax2.set_yticks(x)\n", - "ax2.set_yticklabels(modules)\n", - "ax2.set_xlabel(LABEL_COMPLETION_RATE)\n", - "ax2.set_title('Completion Rate: Active vs. Ghost')\n", - "ax2.legend(loc='lower right')\n", - "ax2.invert_yaxis()\n", - "sns.despine(ax=ax2)\n", - "\n", - "fig.suptitle('Ghost Students Across Modules', fontsize=14, y=1.02)\n", - "fig.tight_layout()\n", - "save_fig(fig, '02_ghost_students_by_course')\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "# --- 4-class outcome distribution for ghost students ---\n", - "# What happens to students who never engage? Are they all Withdrawn,\n", - "# or do some manage to Pass/Fail through other channels (e.g. exams only)?\n", - "df_ghost_4class = execute_query('''\n", - " SELECT\n", - " se.final_result,\n", - " COUNT(*) AS n,\n", - " ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct\n", - " FROM v_student_enriched se\n", - " LEFT JOIN v_engagement_early ee\n", - " ON se.id_student = ee.id_student\n", - " AND se.code_module = ee.code_module\n", - " AND se.code_presentation = ee.code_presentation\n", - " WHERE ee.id_student IS NULL\n", - " GROUP BY se.final_result\n", - " ORDER BY n DESC\n", - "''')\n", - "\n", - "print('=== Ghost Student Outcome Distribution ===\\n')\n", - "print(df_ghost_4class.to_string(index=False))\n", - "\n", - "# --- Horizontal bar chart ---\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "colors = [PALETTE_OUTCOME.get(r, '#999999') for r in df_ghost_4class['final_result']]\n", - "bars = ax.barh(df_ghost_4class['final_result'], df_ghost_4class['n'], color=colors,\n", - " edgecolor='white')\n", - "\n", - "for bar, (_, row) in zip(bars, df_ghost_4class.iterrows()):\n", - " ax.text(\n", - " bar.get_width() + ax.get_xlim()[1] * 0.01,\n", - " bar.get_y() + bar.get_height() / 2,\n", - " f\"{int(row['n']):,} ({row['pct']:.1f}%)\",\n", - " va='center', fontsize=11,\n", - " )\n", - "\n", - "ax.set_xlabel(LABEL_NUM_ENROLLMENTS)\n", - "ax.set_title('Outcome Distribution — Ghost Students Only (zero VLE activity)')\n", - "ax.invert_yaxis()\n", - "sns.despine(left=True)\n", - "fig.tight_layout()\n", - "save_fig(fig, '02_ghost_outcome_distribution')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "9", - "metadata": {}, - "source": [ - "> **Risultato chiave:** I ghost student hanno un tasso di completamento prossimo allo zero. La stragrande maggioranza sono **Withdrawn** — si sono iscritti, non hanno mai interagito con il VLE e alla fine se ne sono andati.\n", - ">\n", - "> - I ghost student sono presenti in tutti i moduli, non concentrati in un singolo corso.\n", - "> - Il divario nel tasso di completamento tra studenti attivi e ghost è enorme — questo è il segnale engagement-esito più chiaro nel dataset.\n", - "> - Un piccolo numero di ghost student potrebbe comunque ottenere Pass o Fail (es. tramite esami in presenza o crediti accumulati), ma sono una piccola minoranza.\n", - ">\n", - "> **Implicazione per BQ5:** I ghost student sono il frutto più a portata di mano per gli interventi. Hanno bisogno di *attivazione*, non di supporto accademico. In termini SaaS, questo è il \"gap di onboarding\" — l'utente si è registrato ma non ha mai sperimentato il valore core del prodotto.\n", - ">\n", - "> **Da qui in poi**, le Sezioni 3–9 analizzano solo **studenti attivi** (quelli con almeno un click VLE nei primi 28 giorni), salvo diversa indicazione esplicita." - ] - }, - { - "cell_type": "markdown", - "id": "10", - "metadata": {}, - "source": [ - "## 3. Distribuzioni dell'engagement precoce\n", - "\n", - "Per gli studenti che **hanno** interagito (non-ghost), com'è la loro attività precoce? Questa sezione profila le quattro metriche chiave da `v_engagement_early`:\n", - "\n", - "| Metrica | Cosa misura |\n", - "|---------|------------|\n", - "| `active_days_first_28` | Numero di giorni distinti con almeno un click (0–28) |\n", - "| `total_clicks_first_28` | Click VLE totali su tutte le risorse |\n", - "| `avg_clicks_per_active_day` | Intensità: click per giorno quando presente |\n", - "| `last_active_day_in_window` | Ultimo giorno di attività nella finestra di 28 giorni |\n", - "\n", - "Confrontiamo queste distribuzioni tra iscrizioni **Completed** e **Not completed** per costruire intuizione visiva prima dei test statistici formali nel Notebook 04 (BQ2).\n", - "\n", - "> **Nota:** I ghost student (zero attività) sono esclusi da `v_engagement_early` per costruzione. Le distribuzioni qui rappresentano solo la popolazione *attiva*." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "11", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Summary statistics by outcome ---\n", - "# Mean vs median gap reveals skewness (common in clickstream data:\n", - "# a few power users generate disproportionate click volumes).\n", - "df_early_stats = execute_query('''\n", - " SELECT\n", - " se.completed,\n", - " COUNT(*) AS n,\n", - " ROUND(AVG(ee.active_days_first_28), 1) AS mean_active_days,\n", - " ROUND(\n", - " PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY ee.active_days_first_28), 1\n", - " ) AS median_active_days,\n", - " ROUND(AVG(ee.total_clicks_first_28), 0) AS mean_total_clicks,\n", - " ROUND(\n", - " PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY ee.total_clicks_first_28), 0\n", - " ) AS median_total_clicks,\n", - " ROUND(AVG(ee.avg_clicks_per_active_day), 1) AS mean_clicks_per_day,\n", - " ROUND(AVG(ee.last_active_day_in_window), 1) AS mean_last_active_day\n", - " FROM v_engagement_early ee\n", - " JOIN v_student_enriched se\n", - " USING (id_student, code_module, code_presentation)\n", - " GROUP BY se.completed\n", - " ORDER BY se.completed DESC\n", - "''')\n", - "\n", - "# Map binary outcome to readable labels (constants from setup cell)\n", - "df_early_stats.insert(0, 'outcome', df_early_stats.pop('completed').map(LABEL_BINARY))\n", - "\n", - "print('=== Early Engagement Summary (first 28 days, active students only) ===\\n')\n", - "df_early_stats" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "12", - "metadata": {}, - "outputs": [], - "source": [ - "# --- 2x2 violin plot grid: all 4 early metrics split by outcome ---\n", - "# Violins show the full distribution shape — more informative than box plots\n", - "# for detecting bimodality or heavy tails in engagement data.\n", - "df_early = execute_query('''\n", - " SELECT\n", - " ee.active_days_first_28,\n", - " ee.total_clicks_first_28,\n", - " ee.avg_clicks_per_active_day,\n", - " ee.last_active_day_in_window,\n", - " se.completed\n", - " FROM v_engagement_early ee\n", - " JOIN v_student_enriched se\n", - " USING (id_student, code_module, code_presentation)\n", - "''')\n", - "df_early['outcome'] = df_early['completed'].map(LABEL_BINARY)\n", - "\n", - "fig, axes = plt.subplots(2, 2, figsize=(14, 10))\n", - "\n", - "metrics = [\n", - " ('active_days_first_28', LABEL_ACTIVE_DAYS, axes[0, 0]),\n", - " ('total_clicks_first_28', 'Total clicks (first 28)', axes[0, 1]),\n", - " ('avg_clicks_per_active_day', 'Avg clicks per active day', axes[1, 0]),\n", - " ('last_active_day_in_window', 'Last active day in window', axes[1, 1]),\n", - "]\n", - "\n", - "for col, ylabel, ax in metrics:\n", - " sns.violinplot(\n", - " data=df_early, x='outcome', y=col,\n", - " palette=PALETTE_BINARY_LABELS, inner='quartile', ax=ax,\n", - " )\n", - " ax.set_xlabel('')\n", - " ax.set_ylabel(ylabel)\n", - " sns.despine(ax=ax)\n", - "\n", - "fig.suptitle(\n", - " 'Early Engagement Metrics by Completion Outcome (first 28 days)',\n", - " fontsize=14, y=1.02,\n", - ")\n", - "fig.tight_layout()\n", - "save_fig(fig, '02_early_engagement_violins')\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "13", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Overlapping histograms: active days and total clicks ---\n", - "# Histograms complement violins by showing raw frequency counts,\n", - "# making it easier to see where the bulk of enrollments falls.\n", - "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=FIG_SIZE_WIDE)\n", - "\n", - "# Left: active days\n", - "for outcome_val, label in LABEL_BINARY.items():\n", - " subset = df_early[df_early['completed'] == outcome_val]\n", - " ax1.hist(\n", - " subset['active_days_first_28'], bins=28, alpha=0.6,\n", - " label=label, color=PALETTE_BINARY[outcome_val],\n", - " )\n", - "ax1.set_xlabel(LABEL_ACTIVE_DAYS)\n", - "ax1.set_ylabel(LABEL_NUM_ENROLLMENTS)\n", - "ax1.set_title('Active Days Distribution by Outcome')\n", - "ax1.legend()\n", - "sns.despine(ax=ax1)\n", - "\n", - "# Right: total clicks (cap at 99th percentile to avoid outlier compression)\n", - "p99 = df_early['total_clicks_first_28'].quantile(0.99)\n", - "for outcome_val, label in LABEL_BINARY.items():\n", - " subset = df_early[df_early['completed'] == outcome_val]\n", - " ax2.hist(\n", - " subset['total_clicks_first_28'].clip(upper=p99), bins=50, alpha=0.6,\n", - " label=label, color=PALETTE_BINARY[outcome_val],\n", - " )\n", - "ax2.set_xlabel(f'Total clicks (first 28 days, capped at p99 = {p99:,.0f})')\n", - "ax2.set_ylabel(LABEL_NUM_ENROLLMENTS)\n", - "ax2.set_title('Total Clicks Distribution by Outcome')\n", - "ax2.legend()\n", - "sns.despine(ax=ax2)\n", - "\n", - "fig.suptitle('Early Engagement Distributions', fontsize=14, y=1.02)\n", - "fig.tight_layout()\n", - "save_fig(fig, '02_early_engagement_histograms')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "14", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - Tutte e quattro le metriche mostrano una **separazione visibile** tra completers e non-completers. I completers tendono ad avere più giorni attivi, più click totali e il loro ultimo giorno attivo è più avanti nella finestra di 28 giorni.\n", - "> - La distribuzione dei **click totali** è fortemente asimmetrica a destra — pochi power user generano volumi di click sproporzionati. La media è sostanzialmente più alta della mediana, confermando questa asimmetria.\n", - "> - La metrica **ultimo giorno attivo** è particolarmente rivelatrice: l'attività dei non-completers tende a diminuire prima nella finestra, mentre i completers rimangono attivi più vicino al giorno 28.\n", - "> - L'istogramma dei **giorni attivi** mostra che molti non-completers sono attivi per soli 1–5 giorni su 28 — un breve engagement prima di scomparire.\n", - ">\n", - "> **Avvertenza sulla causalità:** Un engagement più alto è *associato al* completamento, ma non possiamo dire che l'engagement *causi* il completamento solo da questi dati. Studenti motivati potrebbero sia interagire di più che completare di più — l'engagement potrebbe essere un proxy della motivazione, non una causa del successo. Il Notebook 04 (BQ2) quantificherà queste associazioni con test statistici formali ed effect size." - ] - }, - { - "cell_type": "markdown", - "id": "15", - "metadata": {}, - "source": [ - "## 4. Decili di engagement vs. esito\n", - "\n", - "Esiste una relazione **dose-risposta** tra engagement e completamento? Se più engagement predice monotonicamente esiti migliori, questo rafforza il caso per interventi basati sull'engagement.\n", - "\n", - "`v_engagement_early` include una colonna `engagement_decile_in_course`: ogni studente è classificato 1–10 all'interno del proprio course-presentation usando `NTILE(10)` su `total_clicks_first_28`. Questa normalizzazione è critica — assicura che il decile 1 in un corso ad alto engagement e il decile 1 in un corso a basso engagement significhino entrambi \"ultimo 10% di *quel* corso.\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "16", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Completion rate by engagement decile ---\n", - "# If the relationship is dose-response, we expect a monotonic increase\n", - "# from decile 1 (lowest engagement) to decile 10 (highest engagement).\n", - "df_decile = execute_query('''\n", - " SELECT\n", - " ee.engagement_decile_in_course AS decile,\n", - " COUNT(*) AS n,\n", - " ROUND(100.0 * SUM(se.completed) / COUNT(*), 1) AS completion_rate_pct\n", - " FROM v_engagement_early ee\n", - " JOIN v_student_enriched se\n", - " USING (id_student, code_module, code_presentation)\n", - " GROUP BY ee.engagement_decile_in_course\n", - " ORDER BY ee.engagement_decile_in_course\n", - "''')\n", - "\n", - "print('=== Completion Rate by Engagement Decile ===\\n')\n", - "print(df_decile.to_string(index=False))\n", - "\n", - "# --- Bar chart with gradient coloring ---\n", - "# Color gradient from red (low decile) to green (high decile) reinforces\n", - "# the dose-response visual pattern and matches the binary palette semantics.\n", - "# Weighted by decile size — unweighted mean would misstate the rate if NTILE\n", - "# produces groups of slightly different size.\n", - "overall_rate = (df_decile['completion_rate_pct'] * df_decile['n']).sum() / df_decile['n'].sum()\n", - "n_deciles = len(df_decile)\n", - "gradient_colors = [\n", - " plt.cm.RdYlGn(i / (n_deciles - 1)) for i in range(n_deciles)\n", - "]\n", - "\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "bars = ax.bar(\n", - " df_decile['decile'], df_decile['completion_rate_pct'],\n", - " color=gradient_colors, edgecolor='white',\n", - ")\n", - "\n", - "# Annotate each bar with the exact percentage\n", - "for bar, (_, row) in zip(bars, df_decile.iterrows()):\n", - " ax.text(\n", - " bar.get_x() + bar.get_width() / 2, bar.get_height() + 1,\n", - " f\"{row['completion_rate_pct']:.1f}%\",\n", - " ha='center', fontsize=9, color='#333333',\n", - " )\n", - "\n", - "# Reference line: overall average (across active students only)\n", - "ax.axhline(y=overall_rate, color='gray', linestyle='--', linewidth=1,\n", - " label=f'Average: {overall_rate:.1f}%')\n", - "\n", - "ax.set_xlabel('Engagement decile (1 = lowest, 10 = highest)')\n", - "ax.set_ylabel(LABEL_COMPLETION_RATE)\n", - "ax.set_title('Completion Rate by Engagement Decile (within-course normalized)')\n", - "ax.set_xticks(range(1, 11))\n", - "ax.legend(loc='upper left')\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '02_decile_completion_rate')\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "17", - "metadata": {}, - "outputs": [], - "source": [ - "# --- 4-class outcome breakdown by decile (100% stacked bar) ---\n", - "# Shows how the mix of Pass/Distinction/Fail/Withdrawn changes across deciles.\n", - "# Complements the binary view above with the full outcome granularity.\n", - "df_decile_4class = execute_query('''\n", - " SELECT\n", - " ee.engagement_decile_in_course AS decile,\n", - " se.final_result,\n", - " COUNT(*) AS n\n", - " FROM v_engagement_early ee\n", - " JOIN v_student_enriched se\n", - " USING (id_student, code_module, code_presentation)\n", - " GROUP BY ee.engagement_decile_in_course, se.final_result\n", - " ORDER BY ee.engagement_decile_in_course, se.final_result\n", - "''')\n", - "\n", - "# Pivot and normalize to 100%\n", - "df_pivot = df_decile_4class.pivot(\n", - " index='decile', columns='final_result', values='n',\n", - ").fillna(0)\n", - "df_pct = df_pivot.div(df_pivot.sum(axis=1), axis=0) * 100\n", - "\n", - "# Order columns: positive outcomes first, then negative\n", - "col_order = ['Distinction', 'Pass', 'Fail', 'Withdrawn']\n", - "df_pct = df_pct[[c for c in col_order if c in df_pct.columns]]\n", - "\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "df_pct.plot.bar(\n", - " stacked=True, ax=ax,\n", - " color=[PALETTE_OUTCOME[c] for c in df_pct.columns],\n", - " edgecolor='white', linewidth=0.5,\n", - ")\n", - "ax.set_xlabel('Engagement decile (1 = lowest, 10 = highest)')\n", - "ax.set_ylabel('Percentage of enrollments')\n", - "ax.set_title('Outcome Mix by Engagement Decile')\n", - "ax.legend(title='Outcome', bbox_to_anchor=(1.02, 1), loc='upper left')\n", - "ax.yaxis.set_major_formatter(mticker.PercentFormatter())\n", - "ax.set_xticklabels(ax.get_xticklabels(), rotation=0)\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '02_decile_outcome_stacked')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "18", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - Il pattern dose-risposta è chiaro: il tasso di completamento aumenta in modo monotono (o quasi monotono) dal decile di engagement più basso a quello più alto.\n", - "> - I 2–3 decili più bassi sono un **segmento a rischio estremo** — i loro tassi di completamento sono molto sotto la media.\n", - "> - Nel grafico a barre impilate, il segmento Withdrawn (viola) si riduce drasticamente all'aumentare dell'engagement, mentre Distinction (verde) cresce. Questo suggerisce che un engagement più alto è associato sia a un minor dropout che a un miglior rendimento accademico.\n", - "> - Poiché i decili sono **normalizzati all'interno del corso**, questo pattern è robusto alle differenze a livello di corso nel volume di click. Uno studente nel decile 1 di qualsiasi corso è ad alto rischio.\n", - ">\n", - "> **Insight azionabile (anteprima BQ5):** Un sistema di early warning basato sull'engagement potrebbe segnalare gli studenti nei 2–3 decili più bassi entro i primi 28 giorni. In termini SaaS, questo equivale a un *health score* che attiva un contatto proattivo per gli utenti a rischio." - ] - }, - { - "cell_type": "markdown", - "id": "19", - "metadata": {}, - "source": [ - "## 5. Traiettoria di engagement giornaliero\n", - "\n", - "Le Sezioni 3–4 hanno riassunto l'engagement come un singolo numero su 28 giorni. Qui aggiungiamo la **dimensione temporale**: come evolve l'engagement giorno per giorno?\n", - "\n", - "La domanda chiave: **a che punto divergono le traiettorie di completers e non-completers?** Se il divario si apre presto (es. entro la prima settimana), la finestra di intervento è stretta ma azionabile. Se si apre gradualmente, gli interventi hanno più tempo ma potrebbero dover essere più persistenti.\n", - "\n", - "Usiamo `v_engagement_daily`, che fornisce granularità per-giorno (click totali, risorse distinte, tipi di attività) per ogni giorno-studente con almeno un click." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "20", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Average daily clicks by outcome group (days 0-28) ---\n", - "# We compute the mean clicks per day for each outcome group.\n", - "# Students who did not click on a given day are NOT in v_engagement_daily\n", - "# for that day — so this average is \"clicks per active student on that day\",\n", - "# not \"clicks per enrolled student\". The active student count plot below\n", - "# provides the complementary view.\n", - "df_trajectory = execute_query('''\n", - " SELECT\n", - " ed.date AS day,\n", - " se.completed,\n", - " ROUND(AVG(ed.total_clicks), 2) AS avg_clicks,\n", - " COUNT(*) AS n_active_enrollments\n", - " FROM v_engagement_daily ed\n", - " JOIN v_student_enriched se\n", - " USING (id_student, code_module, code_presentation)\n", - " WHERE ed.date BETWEEN 0 AND 28\n", - " GROUP BY ed.date, se.completed\n", - " ORDER BY ed.date, se.completed\n", - "''')\n", - "\n", - "# Map binary outcome to readable labels (constants from setup cell)\n", - "df_trajectory['outcome'] = df_trajectory['completed'].map(LABEL_BINARY)\n", - "\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "for outcome in [LABEL_COMPLETED, LABEL_NOT_COMPLETED]:\n", - " subset = df_trajectory[df_trajectory['outcome'] == outcome]\n", - " ax.plot(\n", - " subset['day'], subset['avg_clicks'],\n", - " label=outcome, color=PALETTE_BINARY_LABELS[outcome],\n", - " linewidth=2,\n", - " )\n", - "\n", - "# Shade the area between the two lines to emphasize divergence\n", - "df_comp = df_trajectory[df_trajectory['outcome'] == LABEL_COMPLETED].set_index('day')\n", - "df_notc = df_trajectory[df_trajectory['outcome'] == LABEL_NOT_COMPLETED].set_index('day')\n", - "common_days = df_comp.index.intersection(df_notc.index)\n", - "ax.fill_between(\n", - " common_days,\n", - " df_comp.loc[common_days, 'avg_clicks'],\n", - " df_notc.loc[common_days, 'avg_clicks'],\n", - " alpha=0.1, color='gray',\n", - ")\n", - "\n", - "ax.set_xlabel('Day (relative to course start)')\n", - "ax.set_ylabel('Average clicks per active student')\n", - "ax.set_title('Daily Engagement Trajectory (first 28 days)')\n", - "ax.legend()\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '02_daily_trajectory_first_28')\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "21", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Active enrollment count per day by outcome ---\n", - "# This contextualizes the trajectory above: does the gap widen because\n", - "# non-completers click less, or because they stop clicking entirely?\n", - "# A declining line for non-completers means students are dropping out\n", - "# of activity — not just reducing their intensity.\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "for outcome in [LABEL_COMPLETED, LABEL_NOT_COMPLETED]:\n", - " subset = df_trajectory[df_trajectory['outcome'] == outcome]\n", - " ax.plot(\n", - " subset['day'], subset['n_active_enrollments'],\n", - " label=outcome, color=PALETTE_BINARY_LABELS[outcome],\n", - " linewidth=2,\n", - " )\n", - "\n", - "ax.set_xlabel('Day (relative to course start)')\n", - "ax.set_ylabel('Number of active enrollments')\n", - "ax.set_title('Active Enrollments per Day (first 28 days)')\n", - "ax.legend()\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '02_daily_active_students_first_28')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "22", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - Il grafico della traiettoria mostra un **divario persistente** tra completers e non-completers durante tutti i primi 28 giorni. I completers cliccano costantemente di più nei giorni in cui sono attivi.\n", - "> - Il conteggio delle iscrizioni attive rivela un **doppio segnale**: i non-completers non solo cliccano meno quando sono presenti, ma anche *smettono di presentarsi* prima. La linea rossa in calo rappresenta studenti che si sono effettivamente disimpegnati dalla piattaforma.\n", - "> - La combinazione di questi due grafici è potente: il divario di engagement è guidato sia dall'**intensità** (meno click per visita) che dalla **frequenza** (meno visite, abbandono più precoce dell'attività).\n", - ">\n", - "> **Implicazione per gli interventi:** La divergenza diventa probabilmente visibile entro i primi 7–10 giorni. Questo restringe la finestra di intervento: entro la fine del primo mese, molti non-completers hanno già smesso di interagire. I sistemi di rilevamento precoce devono agire entro le prime due settimane per avere il massimo impatto." - ] - }, - { - "cell_type": "markdown", - "id": "23", - "metadata": {}, - "source": [ - "## 6. Tipologia di engagement — Binge vs. Steady\n", - "\n", - "Non tutto l'engagement è uguale. Due studenti con 200 click totali possono avere pattern molto diversi:\n", - "- **Binge**: 200 click in 2 giorni (burst concentrati, comportamento da cramming)\n", - "- **Steady**: 200 click distribuiti su 14 giorni (engagement costante e distribuito)\n", - "\n", - "Le dimensioni `active_days_first_28` e `total_clicks_first_28` catturano questa distinzione. Uno scatter plot di queste due variabili, colorato per esito, rivela se la *costanza* o il *volume* contano di più per il completamento.\n", - "\n", - "**Parallelo SaaS:** Questo corrisponde alla differenza tra DAU (daily active user) e profondità di sessione. Un utente che fa login brevemente ogni giorno è comportamentalmente diverso da uno che ha una sessione maratona a settimana — anche se il loro tempo di utilizzo totale è simile." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "24", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Scatter plot: active days vs total clicks, colored by outcome ---\n", - "# We reuse df_early (loaded in Section 3) which already contains all 4 metrics\n", - "# plus the outcome column.\n", - "# Cap total_clicks at the 99th percentile to prevent a few extreme outliers\n", - "# from compressing the visual range for the majority of students.\n", - "p99_clicks = df_early['total_clicks_first_28'].quantile(0.99)\n", - "\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "# Plot non-completers first (background) so completers are visible on top\n", - "for outcome_val in [0, 1]:\n", - " label = LABEL_BINARY[outcome_val]\n", - " subset = df_early[df_early['completed'] == outcome_val]\n", - " ax.scatter(\n", - " subset['active_days_first_28'],\n", - " subset['total_clicks_first_28'].clip(upper=p99_clicks),\n", - " alpha=0.15, s=10, label=label,\n", - " color=PALETTE_BINARY[outcome_val],\n", - " )\n", - "\n", - "# Annotate quadrant labels to guide interpretation\n", - "ax.text(2, p99_clicks * 0.90, 'Binge\\n(few days, many clicks)',\n", - " fontsize=10, color='#666666', style='italic')\n", - "ax.text(22, p99_clicks * 0.90, 'Power User\\n(many days, many clicks)',\n", - " fontsize=10, color='#666666', style='italic')\n", - "ax.text(2, p99_clicks * 0.05, 'Minimal\\n(few days, few clicks)',\n", - " fontsize=10, color='#666666', style='italic')\n", - "ax.text(22, p99_clicks * 0.05, 'Steady\\n(many days, moderate clicks)',\n", - " fontsize=10, color='#666666', style='italic')\n", - "\n", - "ax.set_xlabel(LABEL_ACTIVE_DAYS)\n", - "ax.set_ylabel(f'Total clicks (first 28 days, capped at p99 = {p99_clicks:,.0f})')\n", - "ax.set_title('Engagement Typology: Active Days vs. Total Clicks')\n", - "ax.legend(markerscale=5)\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '02_engagement_typology_scatter')\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "25", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Typology heatmap: median-split quadrants → completion rate ---\n", - "# Split students into 4 quadrants using the median of active_days and\n", - "# the median of avg_clicks_per_active_day. This quantifies whether\n", - "# consistency (high active days) or intensity (high clicks per day) is\n", - "# more strongly associated with completion.\n", - "df_thresholds = execute_query('''\n", - " SELECT\n", - " PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY active_days_first_28)\n", - " AS median_active_days,\n", - " PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY avg_clicks_per_active_day)\n", - " AS median_intensity\n", - " FROM v_engagement_early\n", - "''')\n", - "\n", - "med_days = df_thresholds['median_active_days'].iloc[0]\n", - "med_intensity = df_thresholds['median_intensity'].iloc[0]\n", - "\n", - "print(f'Median active days: {med_days:.0f}')\n", - "print(f'Median clicks per active day: {med_intensity:.1f}')\n", - "\n", - "# Classify each enrollment into a quadrant\n", - "df_early['frequency'] = np.where(\n", - " df_early['active_days_first_28'] >= med_days, 'High frequency', 'Low frequency'\n", - ")\n", - "df_early['intensity'] = np.where(\n", - " df_early['avg_clicks_per_active_day'] >= med_intensity, 'High intensity', 'Low intensity'\n", - ")\n", - "\n", - "# Compute completion rate per quadrant\n", - "df_typology = (\n", - " df_early.groupby(['frequency', 'intensity'])\n", - " .agg(n=('completed', 'count'), completion_rate=('completed', 'mean'))\n", - " .reset_index()\n", - ")\n", - "df_typology['completion_rate'] = (df_typology['completion_rate'] * 100).round(1)\n", - "\n", - "# Pivot for heatmap\n", - "heatmap_data = df_typology.pivot(\n", - " index='intensity', columns='frequency', values='completion_rate',\n", - ")\n", - "heatmap_counts = df_typology.pivot(\n", - " index='intensity', columns='frequency', values='n',\n", - ")\n", - "\n", - "# Reorder for intuitive reading: high on top, low on bottom\n", - "row_order = ['High intensity', 'Low intensity']\n", - "col_order = ['Low frequency', 'High frequency']\n", - "heatmap_data = heatmap_data.loc[row_order, col_order]\n", - "heatmap_counts = heatmap_counts.loc[row_order, col_order]\n", - "\n", - "# Annotate with both completion rate and count\n", - "annot = heatmap_data.copy().astype(str)\n", - "for r in row_order:\n", - " for c in col_order:\n", - " rate = heatmap_data.loc[r, c]\n", - " count = heatmap_counts.loc[r, c]\n", - " annot.loc[r, c] = f'{rate:.1f}%\\n(n={int(count):,})'\n", - "\n", - "fig, ax = plt.subplots(figsize=(8, 5))\n", - "sns.heatmap(\n", - " heatmap_data, annot=annot, fmt='', cmap='RdYlGn',\n", - " vmin=0, vmax=100, linewidths=1, linecolor='white',\n", - " cbar_kws={'label': LABEL_COMPLETION_RATE}, ax=ax,\n", - ")\n", - "ax.set_title('Engagement Typology: Completion Rate by Quadrant\\n'\n", - " f'(split at median: {med_days:.0f} active days, '\n", - " f'{med_intensity:.1f} clicks/day)')\n", - "ax.set_xlabel('')\n", - "ax.set_ylabel('')\n", - "fig.tight_layout()\n", - "save_fig(fig, '02_engagement_typology_heatmap')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "26", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - Lo scatter plot mostra che i **completers si raggruppano in alto a destra** (molti giorni attivi, molti click totali), mentre i non-completers dominano in basso a sinistra (pochi giorni, pochi click).\n", - "> - Il quadrante \"binge\" (pochi giorni, molti click) è relativamente sparso — la maggior parte degli studenti con volumi di click alti li distribuisce anche su più giorni.\n", - "> - La heatmap 2×2 quantifica questo: **l'alta frequenza è il predittore più forte**. Passare da bassa ad alta frequenza (più giorni attivi) aumenta il tasso di completamento più che passare da bassa ad alta intensità (più click per sessione). La costanza batte il cramming.\n", - "> - Il quadrante \"minimale\" (bassa frequenza, bassa intensità) ha il tasso di completamento più basso — sono studenti che hanno interagito a malapena anche quando si sono presentati.\n", - ">\n", - "> **Implicazione per BQ5:** Gli interventi dovrebbero dare priorità alla *costanza* rispetto al *volume*. Incoraggiare gli studenti a fare login regolarmente (anche brevemente) potrebbe essere più efficace che incoraggiare sessioni più lunghe. Questo è analogo alla meccanica del \"daily streak\" usata nelle app consumer per costruire la formazione dell'abitudine." - ] - }, - { - "cell_type": "markdown", - "id": "27", - "metadata": {}, - "source": [ - "## 7. Diversità dei tipi di attività\n", - "\n", - "Le Sezioni 3–6 si sono concentrate su *quanto* gli studenti interagiscono (click, giorni, intensità). Questa sezione chiede *con cosa* interagiscono.\n", - "\n", - "Il VLE OULAD contiene diversi tipi di attività: pagine di contenuto (`oucontent`), forum (`forumng`), quiz, risorse, homepage, sottopagine e altro. La tabella `vle` mappa ogni risorsa (`id_site`) al suo `activity_type`.\n", - "\n", - "**Ipotesi della diversità:** Studenti che interagiscono con una varietà più ampia di tipi di risorse potrebbero essere più profondamente coinvolti nel corso — accedendo non solo ai contenuti core ma anche a forum, quiz e materiali supplementari. Se la diversità correla con il completamento, suggerisce che l'*ampiezza* dell'engagement conta insieme al *volume*." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "28", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Activity type popularity: which resource types get the most clicks? ---\n", - "# Filtering to first 28 days to match the engagement window used throughout.\n", - "df_activity_pop = execute_query('''\n", - " SELECT\n", - " v.activity_type,\n", - " SUM(sv.sum_click) AS total_clicks,\n", - " ROUND(100.0 * SUM(sv.sum_click) / SUM(SUM(sv.sum_click)) OVER (), 1)\n", - " AS pct_of_clicks\n", - " FROM studentVle sv\n", - " JOIN vle v\n", - " ON sv.id_site = v.id_site\n", - " AND sv.code_module = v.code_module\n", - " AND sv.code_presentation = v.code_presentation\n", - " WHERE sv.date BETWEEN 0 AND 28\n", - " GROUP BY v.activity_type\n", - " ORDER BY total_clicks DESC\n", - "''')\n", - "\n", - "print('=== Activity Type Popularity (first 28 days) ===\\n')\n", - "print(df_activity_pop.to_string(index=False))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "29", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Activity type usage by outcome ---\n", - "# Which activity types show the largest gap between completers and non-completers?\n", - "# CTE computes per-enrollment click totals first, then averages — this avoids\n", - "# COUNT(DISTINCT concat(...)), a pattern that is not portable across SQL engines.\n", - "df_activity_outcome = execute_query('''\n", - " WITH enrollment_activity_clicks AS (\n", - " SELECT\n", - " v.activity_type,\n", - " se.completed,\n", - " se.id_student,\n", - " se.code_module,\n", - " se.code_presentation,\n", - " SUM(sv.sum_click) AS enrollment_clicks\n", - " FROM studentVle sv\n", - " JOIN vle v\n", - " ON sv.id_site = v.id_site\n", - " AND sv.code_module = v.code_module\n", - " AND sv.code_presentation = v.code_presentation\n", - " JOIN v_student_enriched se\n", - " ON sv.id_student = se.id_student\n", - " AND sv.code_module = se.code_module\n", - " AND sv.code_presentation = se.code_presentation\n", - " WHERE sv.date BETWEEN 0 AND 28\n", - " GROUP BY\n", - " v.activity_type,\n", - " se.completed,\n", - " se.id_student,\n", - " se.code_module,\n", - " se.code_presentation\n", - " )\n", - " SELECT\n", - " activity_type,\n", - " completed,\n", - " ROUND(AVG(enrollment_clicks), 1) AS avg_clicks_per_enrollment\n", - " FROM enrollment_activity_clicks\n", - " GROUP BY activity_type, completed\n", - " ORDER BY activity_type, completed\n", - "''')\n", - "\n", - "# Map binary outcome to readable labels (constants from setup cell)\n", - "df_activity_outcome['outcome'] = df_activity_outcome['completed'].map(LABEL_BINARY)\n", - "\n", - "# Pivot for grouped bar chart\n", - "df_act_pivot = df_activity_outcome.pivot(\n", - " index='activity_type', columns='outcome', values='avg_clicks_per_enrollment',\n", - ").fillna(0)\n", - "\n", - "# Sort by the gap between Completed and Not completed (largest gap first)\n", - "df_act_pivot['_gap'] = (\n", - " df_act_pivot.get(LABEL_COMPLETED, 0) - df_act_pivot.get(LABEL_NOT_COMPLETED, 0)\n", - ")\n", - "df_act_pivot = df_act_pivot.sort_values('_gap', ascending=True)\n", - "df_act_pivot = df_act_pivot.drop(columns='_gap')\n", - "\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "df_act_pivot.plot.barh(\n", - " ax=ax,\n", - " color=[PALETTE_BINARY_LABELS.get(c, '#999999') for c in df_act_pivot.columns],\n", - " edgecolor='white',\n", - ")\n", - "ax.set_xlabel('Average clicks per enrollment (first 28 days)')\n", - "ax.set_ylabel('')\n", - "ax.set_title('Activity Type Usage by Outcome')\n", - "ax.legend(title='Outcome')\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '02_activity_type_by_outcome')\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "30", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Activity type diversity per student vs completion ---\n", - "# Count how many distinct activity types each student used in first 28 days,\n", - "# then compute completion rate per diversity level.\n", - "df_diversity = execute_query('''\n", - " SELECT\n", - " sub.n_activity_types,\n", - " COUNT(*) AS n_enrollments,\n", - " ROUND(100.0 * SUM(se.completed) / COUNT(*), 1) AS completion_rate_pct\n", - " FROM (\n", - " SELECT\n", - " sv.id_student,\n", - " sv.code_module,\n", - " sv.code_presentation,\n", - " COUNT(DISTINCT v.activity_type) AS n_activity_types\n", - " FROM studentVle sv\n", - " JOIN vle v\n", - " ON sv.id_site = v.id_site\n", - " AND sv.code_module = v.code_module\n", - " AND sv.code_presentation = v.code_presentation\n", - " WHERE sv.date BETWEEN 0 AND 28\n", - " GROUP BY sv.id_student, sv.code_module, sv.code_presentation\n", - " ) sub\n", - " JOIN v_student_enriched se\n", - " USING (id_student, code_module, code_presentation)\n", - " GROUP BY sub.n_activity_types\n", - " ORDER BY sub.n_activity_types\n", - "''')\n", - "\n", - "print('=== Activity Type Diversity vs. Completion Rate ===\\n')\n", - "print(df_diversity.to_string(index=False))" - ] - }, - { - "cell_type": "markdown", - "id": "31", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - Pochi tipi di attività dominano il volume di click — `oucontent` (pagine di contenuto del corso) e `homepage`/`subpage` probabilmente rappresentano la maggioranza dei click.\n", - "> - Il **divario tra completers e non-completers** varia per tipo di attività. Alcuni tipi di risorse (es. forum, quiz) possono mostrare un divario proporzionalmente maggiore, suggerendo che sono segnali di engagement più \"discriminanti\".\n", - "> - La **diversità** dei tipi di attività mostra un'associazione positiva con il completamento: studenti che usano più tipi di risorse tendono a completare a tassi più alti. Questo è un altro pattern dose-risposta, che complementa l'analisi dei decili nella Sezione 4.\n", - ">\n", - "> **Avvertenza:** La diversità dei tipi di attività è in parte confusa con il volume totale di engagement — studenti che cliccano di più hanno più probabilità di incontrare tipi di risorse diversi. Il segnale di diversità potrebbe non essere indipendente dal segnale di volume. BQ2 lo testerà più formalmente.\n", - ">\n", - "> **Parallelo SaaS:** L'ampiezza delle funzionalità (numero di feature distinte usate) è un predittore di retention comune nella product analytics. Gli utenti che esplorano oltre il set di funzionalità core tendono a essere più coinvolti e meno propensi al churn." - ] - }, - { - "cell_type": "markdown", - "id": "32", - "metadata": {}, - "source": [ - "## 8. Profili di engagement a livello di corso\n", - "\n", - "Il Notebook 01 (Sezione 7) ha esaminato le caratteristiche di *design* dei corsi — densità degli assessment, conteggio risorse VLE, durata. Qui aggiungiamo la **dimensione dell'engagement**: quanto cliccano effettivamente gli studenti in ciascun corso?\n", - "\n", - "Questo è importante perché corsi con più risorse possono naturalmente generare più click. Un livello di engagement \"basso\" in un corso ricco di risorse potrebbe essere \"normale\" in uno più leggero. BQ4 (Notebook 06) formalizzerà questa analisi; qui stabiliamo la baseline." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "33", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Course-level engagement summary ---\n", - "df_course_engagement = execute_query('''\n", - " SELECT\n", - " ee.code_module,\n", - " COUNT(*) AS n_active_enrollments,\n", - " ROUND(AVG(ee.total_clicks_first_28), 0) AS mean_clicks,\n", - " ROUND(\n", - " PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY ee.total_clicks_first_28), 0\n", - " ) AS median_clicks,\n", - " ROUND(AVG(ee.active_days_first_28), 1) AS mean_active_days,\n", - " ROUND(\n", - " PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY ee.active_days_first_28), 0\n", - " ) AS median_active_days,\n", - " ROUND(AVG(ee.avg_clicks_per_active_day), 1) AS mean_intensity\n", - " FROM v_engagement_early ee\n", - " GROUP BY ee.code_module\n", - " ORDER BY median_clicks DESC\n", - "''')\n", - "\n", - "print('=== Course-Level Engagement Summary (first 28 days) ===\\n')\n", - "df_course_engagement" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "34", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Box plots of total clicks by module ---\n", - "# Shows within-course engagement variation: some courses may have tight\n", - "# distributions (all students click similarly) while others have wide spread.\n", - "df_course_clicks = execute_query('''\n", - " SELECT\n", - " ee.code_module,\n", - " ee.total_clicks_first_28\n", - " FROM v_engagement_early ee\n", - "''')\n", - "\n", - "# Cap at 99th percentile to manage visual outliers\n", - "p99_course = df_course_clicks['total_clicks_first_28'].quantile(0.99)\n", - "df_course_clicks['clicks_capped'] = df_course_clicks['total_clicks_first_28'].clip(\n", - " upper=p99_course,\n", - ")\n", - "\n", - "# Order modules by median clicks for consistent reading\n", - "module_order = (\n", - " df_course_clicks.groupby('code_module')['total_clicks_first_28']\n", - " .median()\n", - " .sort_values(ascending=False)\n", - " .index.tolist()\n", - ")\n", - "\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "sns.boxplot(\n", - " data=df_course_clicks, x='code_module', y='clicks_capped',\n", - " order=module_order, color='#4C72B0', ax=ax,\n", - ")\n", - "ax.set_xlabel('Module')\n", - "ax.set_ylabel(f'Total clicks (first 28 days, capped at p99 = {p99_course:,.0f})')\n", - "ax.set_title('Engagement Distribution by Module')\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '02_course_engagement_boxplot')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "35", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - I livelli di engagement variano **sostanzialmente** tra i moduli. Alcuni corsi generano volumi di click molto più alti di altri, riflettendo probabilmente differenze nel design del corso (numero di risorse VLE, struttura degli assessment, formato dei contenuti).\n", - "> - All'interno di ciascun corso, c'è anche una variazione considerevole — i baffi e gli outlier del box plot mostrano che alcuni studenti cliccano molte volte più dei loro pari nello stesso corso.\n", - "> - Il divario media-mediana nella tabella riepilogativa conferma distribuzioni asimmetriche a destra nella maggior parte dei corsi: pochi studenti molto attivi tirano la media sopra la mediana.\n", - ">\n", - "> **Implicazione:** Le metriche di engagement vanno interpretate **relative al corso**, non in termini assoluti. È per questo che il decile di engagement (Sezione 4) normalizza all'interno di ciascun course-presentation. Uno studente con 100 click può essere nel decile più alto di un corso e nel decile più basso di un altro. BQ4 (Notebook 06) analizzerà come le caratteristiche di design del corso si relazionano ai livelli di engagement e alla retention." - ] - }, - { - "cell_type": "markdown", - "id": "36", - "metadata": {}, - "source": [ - "## 9. Matrice di correlazione engagement-esito\n", - "\n", - "Sintetizziamo ora tutte le metriche di engagement precoce in una singola vista: quali metriche hanno la **più forte associazione lineare** con il completamento?\n", - "\n", - "Calcoliamo le correlazioni di Pearson tra tutte le variabili di engagement e la variabile binaria `completed`. Quando una variabile è binaria (0/1) e l'altra è continua, la correlazione di Pearson equivale alla correlazione punto-biseriale — una misura standard di associazione tra una variabile continua e una dicotomica.\n", - "\n", - "**Importante:** Questa analisi usa un `LEFT JOIN` per includere i ghost student (con valori di engagement impostati a 0). Questo dà la prospettiva sull'intera popolazione: i ghost student sono l'estremo inferiore dell'engagement, e includerli rafforza le correlazioni perché quasi tutti non completano." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "37", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Full engagement + outcome data for correlation analysis ---\n", - "# LEFT JOIN includes ghost students (COALESCE to 0) for the full population view.\n", - "# Using -1 for last_active_day when NULL to indicate \"never active\",\n", - "# keeping it numerically distinct from day 0.\n", - "df_corr_data = execute_query('''\n", - " SELECT\n", - " COALESCE(ee.active_days_first_28, 0) AS active_days,\n", - " COALESCE(ee.total_clicks_first_28, 0) AS total_clicks,\n", - " COALESCE(ee.avg_clicks_per_active_day, 0) AS clicks_per_day,\n", - " COALESCE(ee.last_active_day_in_window, -1) AS last_active_day,\n", - " COALESCE(ee.engagement_decile_in_course, 0) AS engagement_decile,\n", - " se.completed\n", - " FROM v_student_enriched se\n", - " LEFT JOIN v_engagement_early ee\n", - " ON se.id_student = ee.id_student\n", - " AND se.code_module = ee.code_module\n", - " AND se.code_presentation = ee.code_presentation\n", - "''')\n", - "\n", - "# Shorter column labels for readability in the heatmap\n", - "label_map = {\n", - " 'active_days': 'Active days',\n", - " 'total_clicks': 'Total clicks',\n", - " 'clicks_per_day': 'Clicks/day',\n", - " 'last_active_day': 'Last active day',\n", - " 'engagement_decile': 'Engagement decile',\n", - " 'completed': 'Completed',\n", - "}\n", - "df_corr_renamed = df_corr_data.rename(columns=label_map)\n", - "corr_matrix = df_corr_renamed.corr()\n", - "\n", - "fig, ax = plt.subplots(figsize=(8, 6))\n", - "sns.heatmap(\n", - " corr_matrix, annot=True, fmt='.2f', cmap=PALETTE_SEQUENTIAL,\n", - " vmin=-1, vmax=1, center=0, square=True, linewidths=0.5,\n", - " ax=ax,\n", - ")\n", - "ax.set_title('Engagement-Outcome Correlation Matrix\\n(includes ghost students as zeros)')\n", - "fig.tight_layout()\n", - "save_fig(fig, '02_engagement_correlation_matrix')\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "38", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Ranked correlations with 'completed' ---\n", - "# Extract the correlation of each engagement metric with the binary outcome\n", - "# and rank by absolute strength.\n", - "corr_with_completed = (\n", - " corr_matrix['Completed']\n", - " .drop('Completed')\n", - " .reset_index()\n", - ")\n", - "corr_with_completed.columns = ['metric', 'correlation']\n", - "corr_with_completed['abs_correlation'] = corr_with_completed['correlation'].abs()\n", - "corr_with_completed = corr_with_completed.sort_values('abs_correlation', ascending=False)\n", - "corr_with_completed = corr_with_completed.drop(columns='abs_correlation')\n", - "\n", - "print('=== Engagement Metrics Ranked by Correlation with Completion ===\\n')\n", - "print(corr_with_completed.to_string(index=False))" - ] - }, - { - "cell_type": "markdown", - "id": "39", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - Le metriche di engagement sono **inter-correlate** — giorni attivi, click totali e decile di engagement tendono a muoversi insieme. Questo è previsto: studenti che cliccano di più tendono anche a cliccare in più giorni.\n", - "> - I correlati più forti con il completamento sono probabilmente **giorni attivi** e **click totali** — le misure di engagement più semplici sono anche le più predittive.\n", - "> - **Click per giorno attivo** (intensità) potrebbe avere una correlazione più debole con il completamento rispetto ai giorni attivi (frequenza), rafforzando il risultato della Sezione 6 che la costanza conta più dell'intensità.\n", - "> - Includere i ghost student (come zeri) rafforza tutte le correlazioni perché rappresentano il caso estremo: zero engagement, completamento prossimo allo zero.\n", - ">\n", - "> **Avvertenza:** Le correlazioni di Pearson catturano relazioni **lineari**. Pattern non lineari (es. rendimenti decrescenti ad alti livelli di engagement, o un effetto soglia) sarebbero sottostimati. L'analisi dei decili nella Sezione 4 affronta parzialmente questo problema esaminando la forma della curva dose-risposta.\n", - ">\n", - "> **Guardando avanti:** BQ2 (Notebook 04) formalizzerà queste associazioni con t-test, effect size (Cohen's d) e intervalli di confidenza — passando dalla correlazione alla valutazione di segnali azionabili." - ] - }, - { - "cell_type": "markdown", - "id": "40", - "metadata": {}, - "source": [ - "## 10. Conclusioni e prossimi passi\n", - "\n", - "### Cosa abbiamo imparato\n", - "\n", - "1. **I ghost student sono il segnale più chiaro.** Una quota misurabile di iscrizioni ha zero attività VLE nei primi 28 giorni. Il loro tasso di completamento è prossimo allo zero. Sono il frutto più a portata di mano per gli interventi — hanno bisogno di *attivazione*, non di supporto accademico.\n", - "\n", - "2. **L'engagement separa gli esiti su tutte le metriche.** I completers mostrano giorni attivi sostanzialmente più alti, più click totali e un ultimo giorno attivo più avanzato nella finestra di 28 giorni. La separazione è visibile in ogni grafico di distribuzione.\n", - "\n", - "3. **Relazione dose-risposta.** Il tasso di completamento aumenta in modo monotono con il decile di engagement. I 2–3 decili più bassi sono segmenti a rischio estremo con tassi di completamento molto sotto la media.\n", - "\n", - "4. **Divergenza precoce.** La traiettoria giornaliera mostra che il divario di engagement tra completers e non-completers si apre entro i primi 7–10 giorni. I non-completers sia cliccano meno *che* smettono di presentarsi prima — un doppio segnale di intensità e frequenza.\n", - "\n", - "5. **Costanza batte intensità.** L'analisi tipologica rivela che la *frequenza* (numero di giorni attivi) predice il completamento più fortemente dell'*intensità* (click per giorno attivo). L'engagement steady supera quello binge.\n", - "\n", - "6. **La diversità dei tipi di attività conta.** Studenti che usano più tipi di risorse completano a tassi più alti. L'ampiezza dell'engagement è un segnale positivo, sebbene parzialmente confuso con il volume totale.\n", - "\n", - "7. **Variazione a livello di corso.** I livelli di engagement differiscono sostanzialmente tra i moduli, rafforzando la necessità di un'analisi course-aware. La normalizzazione all'interno del corso (decili di engagement) affronta questo problema.\n", - "\n", - "8. **Correlati più forti.** Giorni attivi e click totali hanno la correlazione più alta con il completamento, seguiti dal decile di engagement e dall'ultimo giorno attivo. Questi sono candidati per un sistema di early warning.\n", - "\n", - "### Cosa viene dopo\n", - "\n", - "| Notebook | Business Question | Focus |\n", - "|----------|------------------|-------|\n", - "| **03** | BQ1 | Dove e quando abbandonano gli studenti? |\n", - "| **04** | BQ2 | Quali segnali comportamentali precoci predicono l'abbandono? |\n", - "| **05** | BQ3 | Demografica vs. comportamento — cosa predice meglio l'esito? |\n", - "| **06** | BQ4 | Come influiscono le caratteristiche del corso sulla retention? |\n", - "| **07** | BQ5 | Top 3 interventi azionabili |\n", - "\n", - "---\n", - "\n", - "**Riproducibilità:** Tutte le figure sono salvate in `reports/figures/`. Per riprodurre questo notebook, esegui prima `python -m run_pipeline`, poi esegui tutte le celle in ordine." - ] - }, - { - "cell_type": "markdown", - "id": "41", - "metadata": {}, - "source": [ - "> **Dall'EDA all'analisi:** Questi due notebook EDA (01 + 02) hanno stabilito il profilo della popolazione, il panorama dell'engagement e le ipotesi chiave. Da qui in poi, i notebook rimanenti rispondono a specifiche business question con rigore statistico.\n", - ">\n", - "> Continua al **Notebook 03** (`03_bq1_dropout_timing.ipynb`) per BQ1: dove e quando abbandonano gli studenti?" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "version": "3.13.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/it/03_bq1_dropout_timing.ipynb b/notebooks/it/03_bq1_dropout_timing.ipynb deleted file mode 100644 index cade081..0000000 --- a/notebooks/it/03_bq1_dropout_timing.ipynb +++ /dev/null @@ -1,980 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "# 03 — BQ1: Dove e quando abbandonano gli studenti?\n", - "\n", - "> **Notebook 03 di 7** | Learning Retention Analytics \n", - "> Analisi della prima business question: pattern temporali di abbandono degli studenti nei vari corsi." - ] - }, - { - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "## Scopo e ambito\n", - "\n", - "Questo notebook risponde a **BQ1: Dove e quando abbandonano gli studenti?** — la prima delle cinque business question che guidano il progetto.\n", - "\n", - "**Cosa copre questo notebook:**\n", - "- Dimensione del problema dropout: quanti studenti si ritirano e da quali corsi\n", - "- Curve cumulative di dropout — analisi survival-style che mostra quando gli studenti se ne vanno\n", - "- Rilevamento dei cliff — identificazione dei momenti critici di abbandono di massa\n", - "- Timeline normalizzata — confronto dei tempi di dropout tra corsi di durata diversa\n", - "- Ritiri pre-corso — studenti che se ne vanno prima ancora che il corso inizi\n", - "- Segmentazione demografica dei tempi di dropout\n", - "- Caratteristiche di progettazione del corso e relazione con i pattern di dropout\n", - "\n", - "**Cosa viene dopo:**\n", - "- **Notebook 04** (`04_bq2_early_signals.ipynb`): quali segnali comportamentali precoci predicono il dropout? (BQ2)\n", - "\n", - "**Collegamento con le altre business question:** \n", - "BQ1 stabilisce *quando* gli studenti se ne vanno. BQ2 (Notebook 04) identificherà i segnali comportamentali che *predicono* l'abbandono. Insieme, definiscono sia la finestra di intervento sia i trigger per i sistemi di early warning.\n", - "\n", - "> **Trasferibilità metodologica:** L'analisi survival-style del dropout, il rilevamento dei cliff e il confronto su timeline normalizzata utilizzati qui sono direttamente applicabili all'analisi del churn SaaS (quando cancellano gli abbonati?), al decadimento delle coorti di abbonamento (quali coorti trattengono meglio?) e al calo di engagement nelle app (quando gli utenti smettono di tornare?). Il dominio è l'istruzione; il framework analitico è la retention analytics." - ] - }, - { - "cell_type": "markdown", - "id": "2", - "metadata": {}, - "source": [ - "## Indice\n", - "\n", - "1. [Configurazione ambiente](#1.-Configurazione-ambiente)\n", - "2. [Panoramica dropout — Dimensione e tasso](#2.-Panoramica-dropout-—-Dimensione-e-tasso)\n", - "3. [Curve cumulative di dropout](#3.-Curve-cumulative-di-dropout)\n", - "4. [Identificazione dei cliff di dropout](#4.-Identificazione-dei-cliff-di-dropout)\n", - "5. [Timeline normalizzata — Percentuale di avanzamento nel corso](#5.-Timeline-normalizzata-—-Percentuale-di-avanzamento-nel-corso)\n", - "6. [Ritiri pre-corso](#6.-Ritiri-pre-corso)\n", - "7. [Tempi di dropout per dati demografici](#7.-Tempi-di-dropout-per-dati-demografici)\n", - "8. [Progettazione del corso e pattern di dropout](#8.-Progettazione-del-corso-e-pattern-di-dropout)\n", - "9. [Conclusioni chiave e prossimi passi](#9.-Conclusioni-chiave-e-prossimi-passi)\n", - "\n", - "---\n", - "\n", - "**Prerequisiti:**\n", - "- La pipeline ETL deve essere stata eseguita: `python -m run_pipeline`\n", - "- Il database DuckDB in `data/db/oulad.duckdb` deve contenere tutte e 5 le viste analitiche\n", - "\n", - "**Dataset:** Open University Learning Analytics Dataset (OULAD) — ~32K studenti, 7 corsi, clickstream comportamentale completo. Licenza: CC-BY 4.0." - ] - }, - { - "cell_type": "markdown", - "id": "3", - "metadata": {}, - "source": [ - "## 1. Configurazione ambiente\n", - "\n", - "Configuriamo gli import, i parametri di visualizzazione e le funzioni helper riutilizzabili.\n", - "\n", - "**Note tecniche per il lettore:**\n", - "- I notebook si trovano in `notebooks/` ma i moduli del progetto sono in `src/` nella root. Aggiungiamo la root del progetto a `sys.path` affinché `from src.config import ...` funzioni.\n", - "- Tutte le query al database passano attraverso `src.db.connection.execute_query()` — il livello di astrazione DB del progetto (ADR-003).\n", - "- La query SQL principale di BQ1 risiede in `sql/queries/q_bq1_dropout_curves.sql` e viene caricata a runtime dal disco. Query aggiuntive in questo notebook possono essere definite inline, ma vengono comunque eseguite attraverso lo stesso livello di astrazione DB (ADR-003).\n", - "- Le figure vengono salvate in `reports/figures/` a 150 DPI. Poiché `nbstripout` rimuove gli output dei notebook prima del commit, i PNG salvati sono il record visivo persistente." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Path setup ---\n", - "# Notebooks live in notebooks/ but project modules are at the project root.\n", - "# We search upward for pyproject.toml so the notebook works regardless of\n", - "# where the kernel is launched from (JupyterLab, VS Code, Cursor, repo root).\n", - "import sys\n", - "from pathlib import Path\n", - "\n", - "\n", - "def _find_project_root(start: Path) -> Path:\n", - " \"\"\"Walk upward from start until we find pyproject.toml (the repo root marker).\"\"\"\n", - " for candidate in (start, *start.parents):\n", - " if (candidate / 'pyproject.toml').is_file():\n", - " return candidate\n", - " raise RuntimeError(\n", - " f\"Could not locate project root: no pyproject.toml found in '{start}' \"\n", - " \"or any parent directory. Run the notebook from within the repository.\"\n", - " )\n", - "\n", - "\n", - "PROJECT_ROOT = _find_project_root(Path.cwd())\n", - "if str(PROJECT_ROOT) not in sys.path:\n", - " sys.path.insert(0, str(PROJECT_ROOT))\n", - "\n", - "# --- Standard library ---\n", - "import logging\n", - "import warnings\n", - "\n", - "# --- Third-party ---\n", - "import matplotlib.pyplot as plt\n", - "import seaborn as sns\n", - "\n", - "# --- Project modules ---\n", - "from src.config import FIGURES_DIR, QUERIES_DIR\n", - "from src.db.connection import execute_query\n", - "\n", - "# --- Configuration ---\n", - "# Suppress noisy warnings in notebook output; errors still surface\n", - "warnings.filterwarnings('ignore', category=FutureWarning)\n", - "logging.basicConfig(level=logging.WARNING)\n", - "\n", - "# --- Visualization defaults ---\n", - "# Consistent style across all project notebooks\n", - "sns.set_theme(style='whitegrid', font_scale=1.1)\n", - "\n", - "# Semantic color palette: one color per outcome category\n", - "PALETTE_OUTCOME = {\n", - " 'Pass': '#4C72B0', # blue — neutral positive\n", - " 'Distinction': '#55A868', # green — strong positive\n", - " 'Fail': '#C44E52', # red — negative\n", - " 'Withdrawn': '#8172B3', # purple — departed (distinct from failed)\n", - "}\n", - "# Outcome label constants — single source of truth for binary labels\n", - "LABEL_COMPLETED = 'Completed'\n", - "LABEL_NOT_COMPLETED = 'Not completed'\n", - "PALETTE_BINARY = {1: '#55A868', 0: '#C44E52'}\n", - "LABEL_BINARY = {1: LABEL_COMPLETED, 0: LABEL_NOT_COMPLETED}\n", - "PALETTE_BINARY_LABELS = {LABEL_COMPLETED: '#55A868', LABEL_NOT_COMPLETED: '#C44E52'}\n", - "PALETTE_SEQUENTIAL = 'YlOrRd'\n", - "\n", - "# Course-level palette: one color per module for consistent identification\n", - "# across all dropout curve and comparison charts. The 7 OULAD modules use\n", - "# tab10 for maximum visual distinction between course lines.\n", - "_MODULE_ORDER = ['AAA', 'BBB', 'CCC', 'DDD', 'EEE', 'FFF', 'GGG']\n", - "_TAB10 = plt.cm.tab10.colors\n", - "PALETTE_COURSE = {m: _TAB10[i] for i, m in enumerate(_MODULE_ORDER)}\n", - "\n", - "# Shared axis labels — avoids cross-cell string literal duplication\n", - "LABEL_DROPOUT_DAY = 'Dropout day (relative to course start)'\n", - "LABEL_CUMULATIVE_DROPOUT = 'Cumulative dropout rate (%)'\n", - "LABEL_COMPLETION_RATE = 'Completion rate (%)'\n", - "LABEL_MEDIAN_DROPOUT_DAY = 'Median dropout day'\n", - "\n", - "FIG_DPI = 150\n", - "FIG_SIZE = (10, 6)\n", - "FIG_SIZE_WIDE = (16, 5)\n", - "\n", - "# Ensure figures output directory exists\n", - "FIGURES_DIR.mkdir(parents=True, exist_ok=True)\n", - "\n", - "\n", - "def save_fig(fig, name: str) -> None:\n", - " \"\"\"Save figure to reports/figures/ with consistent settings.\"\"\"\n", - " path = FIGURES_DIR / f'{name}.png'\n", - " fig.savefig(path, dpi=FIG_DPI, bbox_inches='tight', facecolor='white')\n", - " print(f' Saved: {path}')\n", - "\n", - "\n", - "# --- Load BQ1 query from SQL file ---\n", - "# The primary query lives in sql/queries/ as a standalone file (ADR-003).\n", - "# Loading it once here avoids re-reading the file in multiple cells.\n", - "_bq1_sql_path = QUERIES_DIR / 'q_bq1_dropout_curves.sql'\n", - "BQ1_SQL = _bq1_sql_path.read_text(encoding='utf-8')\n", - "print(f'Loaded BQ1 query from: {_bq1_sql_path.name} ({len(BQ1_SQL):,} chars)')\n", - "\n", - "# --- Prerequisite check ---\n", - "# Verify the database is populated before proceeding.\n", - "try:\n", - " _check_student = execute_query('SELECT COUNT(*) AS n FROM v_student_enriched')\n", - " _check_dropout = execute_query('SELECT COUNT(*) AS n FROM v_dropout_timing')\n", - " _n_student = _check_student['n'].iloc[0]\n", - " _n_dropout = _check_dropout['n'].iloc[0]\n", - " if _n_student == 0 or _n_dropout == 0:\n", - " raise RuntimeError('One or more views are empty')\n", - " print('Database OK')\n", - " print(f' v_student_enriched: {_n_student:>12,} rows')\n", - " print(f' v_dropout_timing: {_n_dropout:>12,} rows')\n", - "except Exception as exc:\n", - " raise RuntimeError(\n", - " 'Cannot query analytical views. '\n", - " \"Run 'python -m run_pipeline' first to populate the database.\"\n", - " ) from exc" - ] - }, - { - "cell_type": "markdown", - "id": "5", - "metadata": {}, - "source": [ - "## 2. Panoramica dropout — Dimensione e tasso\n", - "\n", - "Prima di esaminare *quando* gli studenti abbandonano, stabiliamo **quanti** abbandonano e da quali corsi. Questa sezione fornisce i numeri di base che contestualizzano tutte le analisi successive.\n", - "\n", - "**Distinzione chiave:** Nel dataset OULAD, \"withdrawn\" significa che lo studente si è esplicitamente disiscritto dal corso (`date_unregistration IS NOT NULL`). Gli studenti che sono rimasti iscritti ma non hanno superato l'esame sono classificati come \"Fail\", non \"Withdrawn\". Questo notebook si concentra sugli **abbandoni espliciti** — gli studenti che hanno attivamente scelto di andarsene." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Dropout headline numbers ---\n", - "# Count explicit withdrawals (withdrew_explicit = 1) vs total enrollments\n", - "# per module to establish the scale of the dropout problem.\n", - "df_overview = execute_query('''\n", - " SELECT\n", - " code_module,\n", - " COUNT(*) AS n_enrolled,\n", - " SUM(withdrew_explicit) AS n_withdrew,\n", - " ROUND(100.0 * SUM(withdrew_explicit) / COUNT(*), 1) AS withdrawal_rate_pct\n", - " FROM v_student_enriched\n", - " GROUP BY code_module\n", - " ORDER BY withdrawal_rate_pct DESC\n", - "''')\n", - "\n", - "# Overall numbers\n", - "total_enrolled = df_overview['n_enrolled'].sum()\n", - "total_withdrew = df_overview['n_withdrew'].sum()\n", - "overall_rate = 100.0 * total_withdrew / total_enrolled\n", - "\n", - "print('=== Dropout Overview ===\\n')\n", - "print(f' Total enrollments: {total_enrolled:>8,}')\n", - "print(f' Explicit withdrawals: {total_withdrew:>8,} ({overall_rate:.1f}%)')\n", - "print(f' Stayed enrolled: {total_enrolled - total_withdrew:>8,} ({100 - overall_rate:.1f}%)')\n", - "print('\\n=== Withdrawal Rate by Module ===\\n')\n", - "print(df_overview.to_string(index=False))\n", - "\n", - "# --- Horizontal bar chart: withdrawal count + rate per module ---\n", - "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=FIG_SIZE_WIDE)\n", - "\n", - "modules = df_overview['code_module']\n", - "colors = [PALETTE_COURSE[m] for m in modules]\n", - "\n", - "# Left: absolute withdrawal count\n", - "ax1.barh(modules, df_overview['n_withdrew'], color=colors, edgecolor='white')\n", - "for i, (_, row) in enumerate(df_overview.iterrows()):\n", - " ax1.text(\n", - " row['n_withdrew'] + ax1.get_xlim()[1] * 0.01, i,\n", - " f\"{int(row['n_withdrew']):,}\",\n", - " va='center', fontsize=9, color='#333333',\n", - " )\n", - "ax1.set_xlabel('Number of withdrawals')\n", - "ax1.set_title('Withdrawal Count by Module')\n", - "ax1.invert_yaxis()\n", - "sns.despine(ax=ax1)\n", - "\n", - "# Right: withdrawal rate percentage\n", - "ax2.barh(modules, df_overview['withdrawal_rate_pct'], color=colors, edgecolor='white')\n", - "for i, (_, row) in enumerate(df_overview.iterrows()):\n", - " ax2.text(\n", - " row['withdrawal_rate_pct'] + 0.5, i,\n", - " f\"{row['withdrawal_rate_pct']:.1f}%\",\n", - " va='center', fontsize=9, color='#333333',\n", - " )\n", - "# Reference line: overall withdrawal rate across all modules\n", - "ax2.axvline(x=overall_rate, color='gray', linestyle='--', linewidth=1,\n", - " label=f'Overall: {overall_rate:.1f}%')\n", - "ax2.set_xlabel('Withdrawal rate (%)')\n", - "ax2.set_title('Withdrawal Rate by Module')\n", - "ax2.legend(loc='lower right')\n", - "ax2.invert_yaxis()\n", - "sns.despine(ax=ax2)\n", - "\n", - "fig.suptitle('Dropout Overview by Module', fontsize=14, y=1.02)\n", - "fig.tight_layout()\n", - "save_fig(fig, '03_dropout_overview_by_course')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "7", - "metadata": {}, - "source": [ - "> **Risultato chiave:** Circa un terzo di tutte le iscrizioni si conclude con un ritiro esplicito. Il tasso di ritiro varia tra i moduli — alcuni corsi perdono una quota significativamente maggiore di studenti rispetto ad altri.\n", - ">\n", - "> Questa variazione è importante: BQ4 (Notebook 06) indagherà se le caratteristiche di progettazione del corso (densità delle valutazioni, numero di risorse, durata) spiegano queste differenze. Per ora, notiamo che il problema è sostanziale e dipendente dal modulo." - ] - }, - { - "cell_type": "markdown", - "id": "8", - "metadata": {}, - "source": [ - "## 3. Curve cumulative di dropout\n", - "\n", - "Questa è la **visualizzazione centrale di BQ1** — un'analisi survival-style che mostra come il dropout si accumula nel tempo.\n", - "\n", - "La query `q_bq1_dropout_curves.sql` calcola conteggi e percentuali cumulative di dropout per corso-presentazione utilizzando window function (`SUM OVER ... ROWS BETWEEN UNBOUNDED PRECEDING`). Ogni punto sulla curva rappresenta la percentuale della coorte originale che si è ritirata entro quel giorno.\n", - "\n", - "**Perché grafici a gradini?** Il dropout è un evento discreto — gli studenti si ritirano in giorni specifici, non in modo continuo. Un grafico a gradini (anziché una linea smussata) preserva questa realtà e rende visibili gli eventi cliff (picchi improvvisi)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Execute BQ1 query ---\n", - "# The SQL file was loaded in the setup cell. Each row represents a day\n", - "# when at least one dropout occurred, with running totals and percentages.\n", - "df_curves = execute_query(BQ1_SQL)\n", - "\n", - "print(f'BQ1 result: {len(df_curves):,} rows')\n", - "print(f'Courses: {df_curves[\"code_module\"].nunique()} modules, '\n", - " f'{df_curves[[\"code_module\", \"code_presentation\"]].drop_duplicates().shape[0]} presentations')\n", - "\n", - "# --- Overlaid cumulative dropout curves ---\n", - "# One line per course-presentation, colored by module. This shows the\n", - "# full landscape: how different cohorts decay over time.\n", - "fig, ax = plt.subplots(figsize=(12, 7))\n", - "\n", - "for (module, pres), group in df_curves.groupby(['code_module', 'code_presentation']):\n", - " ax.step(\n", - " group['dropout_day'], group['cumulative_dropout_rate_pct'],\n", - " where='post', color=PALETTE_COURSE[module], alpha=0.7,\n", - " linewidth=1.5, label=None,\n", - " )\n", - "\n", - "# Add one legend entry per module (not per presentation)\n", - "for module in _MODULE_ORDER:\n", - " if module in df_curves['code_module'].values:\n", - " ax.plot([], [], color=PALETTE_COURSE[module], linewidth=2, label=module)\n", - "\n", - "ax.set_xlabel(LABEL_DROPOUT_DAY)\n", - "ax.set_ylabel(LABEL_CUMULATIVE_DROPOUT)\n", - "ax.set_title('Cumulative Dropout Curves — All Course-Presentations')\n", - "ax.legend(title='Module', bbox_to_anchor=(1.02, 1), loc='upper left')\n", - "ax.set_ylim(0, None)\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '03_dropout_curves_overlaid')\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "10", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Faceted small multiples: one panel per module ---\n", - "# Separating modules into individual panels avoids the visual clutter of\n", - "# the overlaid view and lets the reader focus on each course's trajectory.\n", - "modules_present = sorted(df_curves['code_module'].unique())\n", - "n_modules = len(modules_present)\n", - "n_cols = 4\n", - "n_rows = (n_modules + n_cols - 1) // n_cols\n", - "\n", - "fig, axes = plt.subplots(n_rows, n_cols, figsize=(16, 4 * n_rows), sharey=True)\n", - "axes_flat = axes.flatten()\n", - "\n", - "for i, module in enumerate(modules_present):\n", - " ax = axes_flat[i]\n", - " module_data = df_curves[df_curves['code_module'] == module]\n", - "\n", - " for pres, group in module_data.groupby('code_presentation'):\n", - " ax.step(\n", - " group['dropout_day'], group['cumulative_dropout_rate_pct'],\n", - " where='post', color=PALETTE_COURSE[module], alpha=0.7,\n", - " linewidth=1.5, label=pres,\n", - " )\n", - "\n", - " ax.set_title(module, fontsize=12, fontweight='bold')\n", - " ax.set_xlabel(LABEL_DROPOUT_DAY)\n", - " if i % n_cols == 0:\n", - " ax.set_ylabel(LABEL_CUMULATIVE_DROPOUT)\n", - " ax.legend(fontsize=7, loc='lower right')\n", - " ax.set_ylim(0, None)\n", - " sns.despine(ax=ax)\n", - "\n", - "# Hide unused subplots\n", - "for j in range(i + 1, len(axes_flat)):\n", - " axes_flat[j].set_visible(False)\n", - "\n", - "fig.suptitle('Cumulative Dropout Curves by Module', fontsize=14, y=1.02)\n", - "fig.tight_layout()\n", - "save_fig(fig, '03_dropout_curves_faceted')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "11", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - Le curve di dropout rivelano **profili temporali diversi** tra i moduli. Alcuni corsi subiscono un'erosione costante e graduale; altri mostrano cali bruschi in punti specifici.\n", - "> - All'interno dello stesso modulo, presentazioni (coorti) diverse seguono traiettorie ampiamente simili, suggerendo che il pattern di dropout sia più una funzione della progettazione del corso che della composizione della coorte.\n", - "> - La maggior parte delle curve di dropout mostra un **segmento iniziale ripido** (abbandoni precoci) seguito da un declino più lento e graduale. Questo pattern è caratteristico delle curve di sopravvivenza in molti domini.\n", - ">\n", - "> **Parallelo SaaS:** Queste curve sono direttamente analoghe alle *curve di retention per coorte* nei business ad abbonamento. La forma della curva (convessa vs. concava, ripida vs. graduale) rivela se il churn è concentrato nell'onboarding (problema di attivazione) o distribuito nel tempo (problema di valore erogato)." - ] - }, - { - "cell_type": "markdown", - "id": "12", - "metadata": {}, - "source": [ - "## 4. Identificazione dei cliff di dropout\n", - "\n", - "Non tutti i giorni sono uguali. Alcuni giorni registrano **numeri sproporzionatamente elevati di ritiri** — \"dropout cliff\" che possono corrispondere a eventi specifici del corso (scadenze delle valutazioni, pubblicazione dei voti, date limite per l'iscrizione).\n", - "\n", - "Identifichiamo i cliff analizzando il conteggio giornaliero di dropout dai risultati della query BQ1 e segnalando i giorni in cui il conteggio supera il 95° percentile per quel corso. Sono i giorni in cui qualcosa ha innescato un numero insolito di abbandoni." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "13", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Cliff detection: days with unusually high dropout counts ---\n", - "# For each course-presentation, flag days where daily dropout count\n", - "# exceeds the 95th percentile. These outlier days represent sudden\n", - "# mass departures — likely tied to course milestones.\n", - "df_daily_dropouts = df_curves[[\n", - " 'code_module', 'code_presentation', 'dropout_day', 'n_dropouts'\n", - "]].copy()\n", - "\n", - "# 95th percentile threshold per course-presentation\n", - "thresholds = (\n", - " df_daily_dropouts\n", - " .groupby(['code_module', 'code_presentation'])['n_dropouts']\n", - " .quantile(0.95)\n", - " .reset_index()\n", - " .rename(columns={'n_dropouts': 'p95_threshold'})\n", - ")\n", - "\n", - "df_cliffs = df_daily_dropouts.merge(thresholds, on=['code_module', 'code_presentation'])\n", - "df_cliffs = df_cliffs[df_cliffs['n_dropouts'] >= df_cliffs['p95_threshold']]\n", - "\n", - "# Aggregate across presentations: which module-day combos are cliffs most often?\n", - "df_cliff_summary = (\n", - " df_cliffs\n", - " .groupby(['code_module', 'dropout_day'])\n", - " .agg(\n", - " total_dropouts=('n_dropouts', 'sum'),\n", - " n_presentations=('code_presentation', 'nunique'),\n", - " )\n", - " .reset_index()\n", - " .sort_values('total_dropouts', ascending=False)\n", - ")\n", - "\n", - "print('=== Top Cliff Events (days with >= p95 dropouts) ===\\n')\n", - "print(df_cliff_summary.head(15).to_string(index=False))\n", - "\n", - "# --- Visualization: top cliff events across all modules ---\n", - "df_cliff_top = df_cliff_summary.head(20)\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "colors = [PALETTE_COURSE.get(m, '#999999') for m in df_cliff_top['code_module']]\n", - "ax.barh(\n", - " range(len(df_cliff_top)),\n", - " df_cliff_top['total_dropouts'],\n", - " color=colors, edgecolor='white',\n", - ")\n", - "\n", - "# Label each bar with module + day for identification\n", - "labels = [\n", - " f\"{row['code_module']} — day {int(row['dropout_day'])}\"\n", - " for _, row in df_cliff_top.iterrows()\n", - "]\n", - "ax.set_yticks(range(len(df_cliff_top)))\n", - "ax.set_yticklabels(labels)\n", - "ax.set_xlabel('Number of dropouts')\n", - "ax.set_title('Top Dropout Cliff Events (days exceeding 95th percentile)')\n", - "ax.invert_yaxis()\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '03_dropout_cliffs')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "14", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - Gli eventi cliff sono concentrati in **giorni specifici** che probabilmente corrispondono a milestone del corso: scadenze delle valutazioni, pubblicazione dei voti o date limite per l'iscrizione.\n", - "> - Alcuni moduli mostrano cliff più precoci nella timeline (suggerendo erosione nella fase di onboarding), mentre altri mostrano cliff a metà corso (suggerendo erosione guidata dalle valutazioni).\n", - "> - La coerenza dei tempi dei cliff tra presentazioni dello stesso modulo rafforza l'ipotesi che questi eventi siano legati alla **progettazione del corso** piuttosto che a fattori specifici dello studente.\n", - ">\n", - "> **Insight azionabile:** Se i giorni cliff coincidono con eventi noti del corso, l'operatore della piattaforma può implementare interventi mirati *prima* di quelle date — ad es. email di promemoria, risorse di supporto, o percorsi semplificati di re-iscrizione per gli studenti che hanno mancato le scadenze delle valutazioni." - ] - }, - { - "cell_type": "markdown", - "id": "15", - "metadata": {}, - "source": [ - "## 5. Timeline normalizzata — Percentuale di avanzamento nel corso\n", - "\n", - "I corsi OULAD hanno durate diverse (da ~240 a ~270 giorni). Confrontare i giorni di dropout in termini assoluti è come confrontare mele e arance. Per consentire il confronto tra corsi, `v_dropout_timing` include un campo `dropout_pct`: la percentuale di durata del corso trascorsa quando lo studente si è ritirato.\n", - "\n", - "- 0% = ritirato all'inizio del corso (giorno 0)\n", - "- 50% = ritirato a metà corso\n", - "- 100% = ritirato alla fine programmata\n", - "\n", - "I valori negativi rappresentano **ritiri pre-corso** (studenti che si sono disiscritti prima dell'inizio ufficiale del corso). Valori superiori al 100% sono possibili se uno studente si è ritirato dopo la data di fine programmata." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "16", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Normalized dropout timing distribution ---\n", - "# Using v_dropout_timing.dropout_pct to compare across courses.\n", - "# This DataFrame is reused in Section 6 (pre-course withdrawals).\n", - "df_dropout_timing = execute_query('SELECT * FROM v_dropout_timing')\n", - "\n", - "# Separate in-course and pre-course withdrawals\n", - "df_in_course = df_dropout_timing[\n", - " (df_dropout_timing['dropout_pct'] >= 0) & (df_dropout_timing['dropout_pct'] <= 100)\n", - "]\n", - "df_pre_course = df_dropout_timing[df_dropout_timing['dropout_pct'] < 0]\n", - "\n", - "n_post = len(df_dropout_timing[df_dropout_timing['dropout_pct'] > 100])\n", - "print(f'Total withdrawals: {len(df_dropout_timing):,}')\n", - "print(f'In-course (0-100%): {len(df_in_course):,}')\n", - "print(f'Pre-course (< 0%): {len(df_pre_course):,}')\n", - "print(f'Post-course (> 100%): {n_post:,}')\n", - "\n", - "# --- Histogram + KDE of normalized dropout timing ---\n", - "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=FIG_SIZE_WIDE)\n", - "\n", - "# Left: overall histogram for in-course withdrawals\n", - "ax1.hist(\n", - " df_in_course['dropout_pct'], bins=50, color='#8172B3',\n", - " edgecolor='white', alpha=0.8,\n", - ")\n", - "ax1.set_xlabel('Percentage through course at withdrawal')\n", - "ax1.set_ylabel('Number of withdrawals')\n", - "ax1.set_title('Dropout Timing Distribution (normalized)')\n", - "ax1.axvline(x=50, color='gray', linestyle='--', linewidth=1, label='Midpoint')\n", - "ax1.legend()\n", - "sns.despine(ax=ax1)\n", - "\n", - "# Right: KDE by module for cross-course comparison\n", - "for module in sorted(df_in_course['code_module'].unique()):\n", - " subset = df_in_course[df_in_course['code_module'] == module]\n", - " # KDE requires enough data points to be meaningful\n", - " if len(subset) > 10:\n", - " sns.kdeplot(\n", - " subset['dropout_pct'], ax=ax2,\n", - " color=PALETTE_COURSE[module], label=module, linewidth=1.5,\n", - " )\n", - "ax2.set_xlabel('Percentage through course at withdrawal')\n", - "ax2.set_ylabel('Density')\n", - "ax2.set_title('Dropout Timing by Module (KDE, normalized)')\n", - "ax2.legend(title='Module')\n", - "sns.despine(ax=ax2)\n", - "\n", - "fig.suptitle('When Do Students Drop Out? (normalized by course length)',\n", - " fontsize=14, y=1.02)\n", - "fig.tight_layout()\n", - "save_fig(fig, '03_dropout_normalized')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "17", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - L'istogramma normalizzato rivela le **fasi** del dropout: erosione precoce (0–20%), abbandono a metà corso (intorno al 30–50%) e ritiro in fase avanzata (oltre il 60%).\n", - "> - L'overlay KDE mostra che moduli diversi hanno **profili temporali di dropout diversi**. Alcuni corsi registrano la maggior parte del dropout nel primo quarto, mentre altri hanno un pattern più graduale o concentrato a metà corso.\n", - "> - Il fatto che una quota non trascurabile di ritiri avvenga oltre il 50% è notevole: questi studenti hanno investito tempo significativo prima di andarsene. Il dropout in fase avanzata potrebbe essere guidato dal fallimento nelle valutazioni o da difficoltà accademiche, piuttosto che dal disimpegno iniziale.\n", - ">\n", - "> **Parallelo SaaS:** La timeline normalizzata equivale all'*analisi per fase del ciclo di vita* nei business ad abbonamento. Il churn precoce (fallimento dell'attivazione) richiede interventi diversi rispetto al churn a metà ciclo (gap di valore) o al churn tardivo (spostamento competitivo o cambiamento di vita)." - ] - }, - { - "cell_type": "markdown", - "id": "18", - "metadata": {}, - "source": [ - "## 6. Ritiri pre-corso\n", - "\n", - "Un sottoinsieme sorprendente di studenti si ritira **prima ancora che il corso inizi** (`dropout_day < 0`). Si tratta di iscrizioni in cui lo studente si è registrato e poi disiscritto prima del giorno 0.\n", - "\n", - "I ritiri pre-corso rappresentano un fenomeno distinto: lo studente non ha mai sperimentato alcun contenuto del corso. Si tratta di puro **churn di registrazione** — analogo agli utenti SaaS che si iscrivono durante un trial e cancellano prima ancora di utilizzare il prodotto." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "19", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Pre-course withdrawal analysis ---\n", - "# Students who withdrew before day 0 (negative dropout_day).\n", - "df_precourse = execute_query('''\n", - " SELECT\n", - " code_module,\n", - " COUNT(*) AS n_precourse,\n", - " ROUND(\n", - " 100.0 * COUNT(*)\n", - " / SUM(COUNT(*)) OVER (),\n", - " 1\n", - " ) AS pct_of_precourse\n", - " FROM v_dropout_timing\n", - " WHERE dropout_day < 0\n", - " GROUP BY code_module\n", - " ORDER BY n_precourse DESC\n", - "''')\n", - "\n", - "# What share of all withdrawals are pre-course?\n", - "total_withdrew_all = len(df_dropout_timing)\n", - "total_precourse = df_precourse['n_precourse'].sum()\n", - "precourse_pct = 100.0 * total_precourse / total_withdrew_all\n", - "\n", - "print('=== Pre-Course Withdrawals ===\\n')\n", - "print(f' Total withdrawals: {total_withdrew_all:>8,}')\n", - "print(f' Pre-course (day < 0): {total_precourse:>8,} ({precourse_pct:.1f}% of all withdrawals)')\n", - "print('\\n=== Pre-Course by Module ===\\n')\n", - "print(df_precourse.to_string(index=False))\n", - "\n", - "# --- Bar chart: pre-course withdrawals by module ---\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "colors = [PALETTE_COURSE.get(m, '#999999') for m in df_precourse['code_module']]\n", - "bars = ax.barh(\n", - " df_precourse['code_module'], df_precourse['n_precourse'],\n", - " color=colors, edgecolor='white',\n", - ")\n", - "for bar, (_, row) in zip(bars, df_precourse.iterrows()):\n", - " ax.text(\n", - " bar.get_width() + ax.get_xlim()[1] * 0.01,\n", - " bar.get_y() + bar.get_height() / 2,\n", - " f\"{int(row['n_precourse']):,} ({row['pct_of_precourse']:.1f}%)\",\n", - " va='center', fontsize=9, color='#333333',\n", - " )\n", - "ax.set_xlabel('Number of pre-course withdrawals')\n", - "ax.set_title('Pre-Course Withdrawals by Module (withdrew before day 0)')\n", - "ax.invert_yaxis()\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '03_precourse_withdrawals')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "20", - "metadata": {}, - "source": [ - "> **Risultato chiave:** Una quota misurabile di tutti i ritiri avviene prima ancora che il corso inizi. Questi studenti si sono registrati ma non hanno mai interagito con alcun contenuto.\n", - ">\n", - "> - Il ritiro pre-corso è un segnale di **qualità della registrazione**: il funnel di iscrizione converte utenti che non sono ancora impegnati.\n", - "> - La distribuzione tra i moduli suggerisce che alcuni corsi potrebbero avere una conversione dalla registrazione all'avvio migliore di altri.\n", - ">\n", - "> **Opportunità di intervento:** I ritiri pre-corso potrebbero essere ridotti con una migliore impostazione delle aspettative al momento della registrazione, email di benvenuto con \"primi passi\" concreti, o un percorso semplificato di iscrizione tardiva. In termini SaaS, questo è il problema della \"email di attivazione\" — l'utente si è iscritto ma ha bisogno di una spinta per iniziare davvero." - ] - }, - { - "cell_type": "markdown", - "id": "21", - "metadata": {}, - "source": [ - "## 7. Tempi di dropout per dati demografici\n", - "\n", - "Il *momento* in cui gli studenti abbandonano varia in base al gruppo demografico? `v_dropout_timing` include colonne demografiche da `v_student_enriched`, permettendoci di segmentare i tempi di dropout per livello di istruzione, fascia d'età e indice di deprivazione.\n", - "\n", - "**Distinzione importante:** Questa sezione è **solo descrittiva** — osserviamo differenze nel giorno mediano di dropout ma non eseguiamo test statistici. I test formali di ipotesi sulle associazioni demografiche appartengono al Notebook 05 (BQ3: dati demografici vs. comportamento)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "22", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Dropout timing by demographics ---\n", - "# Compute median dropout day for each demographic group.\n", - "# Using PERCENTILE_CONT for ANSI compliance.\n", - "# Only in-course withdrawals (dropout_day >= 0) to focus on the course experience.\n", - "df_demo_dropout = execute_query('''\n", - " SELECT\n", - " highest_education,\n", - " ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY dropout_day), 0)\n", - " AS median_dropout_day,\n", - " COUNT(*) AS n\n", - " FROM v_dropout_timing\n", - " WHERE dropout_day >= 0\n", - " GROUP BY highest_education\n", - " HAVING COUNT(*) >= 30\n", - " ORDER BY median_dropout_day\n", - "''')\n", - "\n", - "df_age_dropout = execute_query('''\n", - " SELECT\n", - " age_band,\n", - " ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY dropout_day), 0)\n", - " AS median_dropout_day,\n", - " COUNT(*) AS n\n", - " FROM v_dropout_timing\n", - " WHERE dropout_day >= 0\n", - " GROUP BY age_band\n", - " HAVING COUNT(*) >= 30\n", - " ORDER BY median_dropout_day\n", - "''')\n", - "\n", - "df_imd_dropout = execute_query('''\n", - " SELECT\n", - " COALESCE(imd_band, 'Unknown') AS imd_band,\n", - " ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY dropout_day), 0)\n", - " AS median_dropout_day,\n", - " COUNT(*) AS n\n", - " FROM v_dropout_timing\n", - " WHERE dropout_day >= 0\n", - " GROUP BY 1\n", - " HAVING COUNT(*) >= 30\n", - " ORDER BY median_dropout_day\n", - "''')\n", - "\n", - "# --- 1x3 bar chart panel ---\n", - "fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(18, 6))\n", - "\n", - "# Left: by education level\n", - "ax1.barh(df_demo_dropout['highest_education'], df_demo_dropout['median_dropout_day'],\n", - " color='#4C72B0', edgecolor='white')\n", - "for i, (_, row) in enumerate(df_demo_dropout.iterrows()):\n", - " ax1.text(\n", - " row['median_dropout_day'] + 1, i,\n", - " f\"day {int(row['median_dropout_day'])} (n={int(row['n']):,})\",\n", - " va='center', fontsize=8, color='#333333',\n", - " )\n", - "ax1.set_xlabel(LABEL_MEDIAN_DROPOUT_DAY)\n", - "ax1.set_title('By Education Level')\n", - "ax1.invert_yaxis()\n", - "sns.despine(ax=ax1)\n", - "\n", - "# Center: by age band\n", - "ax2.barh(df_age_dropout['age_band'], df_age_dropout['median_dropout_day'],\n", - " color='#55A868', edgecolor='white')\n", - "for i, (_, row) in enumerate(df_age_dropout.iterrows()):\n", - " ax2.text(\n", - " row['median_dropout_day'] + 1, i,\n", - " f\"day {int(row['median_dropout_day'])} (n={int(row['n']):,})\",\n", - " va='center', fontsize=8, color='#333333',\n", - " )\n", - "ax2.set_xlabel(LABEL_MEDIAN_DROPOUT_DAY)\n", - "ax2.set_title('By Age Band')\n", - "ax2.invert_yaxis()\n", - "sns.despine(ax=ax2)\n", - "\n", - "# Right: by IMD band\n", - "ax3.barh(df_imd_dropout['imd_band'], df_imd_dropout['median_dropout_day'],\n", - " color='#C44E52', edgecolor='white')\n", - "for i, (_, row) in enumerate(df_imd_dropout.iterrows()):\n", - " ax3.text(\n", - " row['median_dropout_day'] + 1, i,\n", - " f\"day {int(row['median_dropout_day'])} (n={int(row['n']):,})\",\n", - " va='center', fontsize=8, color='#333333',\n", - " )\n", - "ax3.set_xlabel(LABEL_MEDIAN_DROPOUT_DAY)\n", - "ax3.set_title('By IMD Band')\n", - "ax3.invert_yaxis()\n", - "sns.despine(ax=ax3)\n", - "\n", - "fig.suptitle('Median Dropout Day by Demographic Group (in-course withdrawals only)',\n", - " fontsize=14, y=1.02)\n", - "fig.tight_layout()\n", - "save_fig(fig, '03_dropout_by_demographics')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "23", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - Il giorno mediano di dropout varia tra i gruppi demografici, ma le differenze sono moderate rispetto alla variazione complessiva nei tempi di dropout.\n", - "> - Il **livello di istruzione** mostra una certa differenziazione: studenti con background educativi diversi potrebbero abbandonare in fasi diverse del ciclo di vita del corso.\n", - "> - La **fascia d'età** e la **fascia IMD** (indice di deprivazione socioeconomica) mostrano pattern che suggeriscono come il contesto demografico influenzi non solo *se* gli studenti abbandonano, ma *quando*.\n", - ">\n", - "> **Avvertenza:** Queste sono osservazioni descrittive — nessuna affermazione causale. Le differenze potrebbero essere confuse dalla scelta del corso, dall'esperienza pregressa o da altri fattori. BQ3 (Notebook 05) eseguirà test statistici formali confrontando predittori demografici e comportamentali." - ] - }, - { - "cell_type": "markdown", - "id": "24", - "metadata": {}, - "source": [ - "## 8. Progettazione del corso e pattern di dropout\n", - "\n", - "Le caratteristiche del corso spiegano la variazione nei tassi di dropout tra i moduli? `v_course_profile` fornisce metriche di progettazione per ogni corso-presentazione: durata, numero di valutazioni, densità delle valutazioni (valutazioni per 30 giorni), numero di risorse VLE e diversità dei tipi di attività.\n", - "\n", - "Questa sezione cerca **correlazioni visive** tra le caratteristiche di progettazione del corso e il comportamento di dropout. Un confronto completo tra corsi è l'oggetto di BQ4 (Notebook 06); qui stabiliamo i pattern." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "25", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Course profile data ---\n", - "df_course = execute_query('SELECT * FROM v_course_profile')\n", - "\n", - "# --- Median dropout day per course-presentation (in-course only) ---\n", - "df_course_dropout = execute_query('''\n", - " SELECT\n", - " code_module,\n", - " code_presentation,\n", - " ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY dropout_day), 0)\n", - " AS median_dropout_day\n", - " FROM v_dropout_timing\n", - " WHERE dropout_day >= 0\n", - " GROUP BY code_module, code_presentation\n", - "''')\n", - "\n", - "# Merge course profile with median dropout day\n", - "df_merged = df_course_dropout.merge(\n", - " df_course[['code_module', 'code_presentation',\n", - " 'course_length_days', 'assessments_per_30_days',\n", - " 'withdrawal_rate_pct']],\n", - " on=['code_module', 'code_presentation'],\n", - ")\n", - "\n", - "# --- 1x2 scatter panel: course design vs dropout ---\n", - "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=FIG_SIZE_WIDE)\n", - "\n", - "# Left: assessment density vs withdrawal rate\n", - "for _, row in df_merged.iterrows():\n", - " module = row['code_module']\n", - " ax1.scatter(\n", - " row['assessments_per_30_days'], row['withdrawal_rate_pct'],\n", - " color=PALETTE_COURSE.get(module, '#999999'), s=80,\n", - " edgecolor='white', zorder=3,\n", - " )\n", - " ax1.annotate(\n", - " module,\n", - " (row['assessments_per_30_days'], row['withdrawal_rate_pct']),\n", - " fontsize=7, ha='center', va='bottom',\n", - " xytext=(0, 5), textcoords='offset points',\n", - " )\n", - "ax1.set_xlabel('Assessments per 30 days')\n", - "ax1.set_ylabel('Withdrawal rate (%)')\n", - "ax1.set_title('Assessment Density vs. Withdrawal Rate')\n", - "sns.despine(ax=ax1)\n", - "\n", - "# Right: course length vs median dropout day\n", - "for _, row in df_merged.iterrows():\n", - " module = row['code_module']\n", - " ax2.scatter(\n", - " row['course_length_days'], row['median_dropout_day'],\n", - " color=PALETTE_COURSE.get(module, '#999999'), s=80,\n", - " edgecolor='white', zorder=3,\n", - " )\n", - " ax2.annotate(\n", - " module,\n", - " (row['course_length_days'], row['median_dropout_day']),\n", - " fontsize=7, ha='center', va='bottom',\n", - " xytext=(0, 5), textcoords='offset points',\n", - " )\n", - "# Reference line: dropout at course end (y = x diagonal)\n", - "max_len = df_merged['course_length_days'].max()\n", - "ax2.plot([0, max_len], [0, max_len], color='gray', linestyle='--',\n", - " linewidth=1, alpha=0.5, label='Dropout at course end')\n", - "ax2.set_xlabel('Course length (days)')\n", - "ax2.set_ylabel(LABEL_MEDIAN_DROPOUT_DAY)\n", - "ax2.set_title('Course Length vs. Median Dropout Day')\n", - "ax2.legend(fontsize=8)\n", - "sns.despine(ax=ax2)\n", - "\n", - "fig.suptitle('Course Design Characteristics and Dropout Patterns',\n", - " fontsize=14, y=1.02)\n", - "fig.tight_layout()\n", - "save_fig(fig, '03_course_design_vs_dropout')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "26", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - **Densità delle valutazioni** e **tasso di ritiro** potrebbero mostrare una relazione: corsi con valutazioni più frequenti potrebbero avere profili di retention diversi. La direzione di questa relazione (se esiste) è una questione empirica che BQ4 esplorerà formalmente.\n", - "> - **Durata del corso** e **giorno mediano di dropout** mostrano che i corsi più lunghi tendono ad avere giorni mediani di dropout più tardivi in termini assoluti, il che è prevedibile — ma l'analisi normalizzata nella Sezione 5 ha già mostrato che il *timing relativo* (percentuale di avanzamento nel corso) potrebbe essere più coerente tra i moduli.\n", - "> - I pattern degli scatter plot suggeriscono che le caratteristiche di progettazione del corso contribuiscono — ma non spiegano completamente — la variazione nel comportamento di dropout. Anche i fattori a livello di studente (dati demografici, engagement) giocano un ruolo, come esploreranno BQ2 e BQ3.\n", - ">\n", - "> **Anteprima di BQ4:** Il Notebook 06 formalizzerà questa analisi con metriche di retention per corso, analisi dell'interazione con le valutazioni ed effetti della diversità delle risorse." - ] - }, - { - "cell_type": "markdown", - "id": "27", - "metadata": {}, - "source": [ - "## 9. Conclusioni chiave e prossimi passi\n", - "\n", - "### Cosa abbiamo imparato\n", - "\n", - "1. **Il dropout è sostanziale e universale.** Circa un terzo di tutte le iscrizioni si conclude con un ritiro esplicito. Nessun modulo ne è immune, sebbene i tassi varino significativamente tra i corsi.\n", - "\n", - "2. **Le curve cumulative di dropout rivelano profili temporali distinti.** Alcuni corsi subiscono un'erosione precoce ripida (fallimento dell'onboarding), mentre altri mostrano un declino più graduale a metà corso. La visualizzazione survival-style rende questi pattern immediatamente leggibili.\n", - "\n", - "3. **Esistono eventi cliff.** Giorni specifici registrano numeri sproporzionatamente elevati di ritiri, probabilmente legati a milestone del corso (scadenze delle valutazioni, pubblicazione dei voti). Sono azionabili: gli interventi possono essere temporizzati prima delle date cliff note.\n", - "\n", - "4. **Il timing normalizzato consente il confronto tra corsi.** Convertire il giorno di dropout in percentuale di avanzamento nel corso rivela che la *fase* del dropout (precoce, intermedia, tardiva) varia per modulo, suggerendo cause sottostanti diverse.\n", - "\n", - "5. **I ritiri pre-corso sono un fenomeno distinto.** Una quota misurabile di studenti si ritira prima del giorno 0 — puro churn di registrazione. Questi studenti hanno bisogno di attivazione, non di supporto accademico.\n", - "\n", - "6. **La segmentazione demografica mostra una variazione moderata.** I tempi di dropout differiscono leggermente per livello di istruzione, età e indice socioeconomico, ma le differenze sono modeste rispetto alla variazione complessiva. I test formali in BQ3 determineranno la significatività statistica.\n", - "\n", - "7. **Le caratteristiche di progettazione del corso correlano con i pattern di dropout.** La densità delle valutazioni e la durata del corso mostrano associazioni visive con i tassi e i tempi di dropout, ma l'analisi formale in BQ4 è necessaria per quantificare queste relazioni.\n", - "\n", - "### Cosa viene dopo\n", - "\n", - "| Notebook | Business Question | Focus |\n", - "|----------|------------------|-------|\n", - "| **04** | BQ2 | Quali segnali comportamentali precoci predicono il dropout? |\n", - "| **05** | BQ3 | Dati demografici vs. comportamento — cosa predice meglio l'esito? |\n", - "| **06** | BQ4 | Come influiscono le caratteristiche del corso sulla retention? |\n", - "| **07** | BQ5 | Le 3 principali raccomandazioni operative |\n", - "\n", - "---\n", - "\n", - "**Riproducibilità:** Tutte le figure sono salvate in `reports/figures/`. Per riprodurre questo notebook, eseguire prima `python -m run_pipeline`, poi eseguire tutte le celle in ordine." - ] - }, - { - "cell_type": "markdown", - "id": "28", - "metadata": {}, - "source": [ - "> **Dal timing ai segnali:** Questo notebook ha stabilito *quando* gli studenti se ne vanno — il panorama temporale del dropout. Ma il timing da solo non consente la prevenzione. Il passo successivo è identificare *quali comportamenti precoci predicono l'abbandono*.\n", - ">\n", - "> Proseguire con il **Notebook 04** (`04_bq2_early_signals.ipynb`) per BQ2: quali segnali comportamentali precoci predicono il dropout? Il Notebook 04 introduce i test statistici formali con t-test, effect size e correzione per confronti multipli — il primo utilizzo di `src/stats/tests.py` nell'analisi." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.13.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/it/04_bq2_early_signals.ipynb b/notebooks/it/04_bq2_early_signals.ipynb deleted file mode 100644 index 4736de3..0000000 --- a/notebooks/it/04_bq2_early_signals.ipynb +++ /dev/null @@ -1,945 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "# 04 — BQ2: Quali segnali comportamentali precoci predicono il dropout?\n", - "\n", - "> **Notebook 04 di 7** | Learning Retention Analytics \n", - "> Analisi della seconda business question: identificazione dei segnali precoci di engagement che differenziano chi completa da chi non completa, con test statistici formali." - ] - }, - { - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "## Scopo e ambito\n", - "\n", - "Questo notebook risponde a **BQ2: Quali segnali comportamentali precoci predicono il dropout?** — la seconda delle cinque business question che guidano il progetto.\n", - "\n", - "Il Notebook 02 (EDA) ha mostrato che le metriche di engagement *differiscono* tra chi completa e chi non completa. Il Notebook 03 (BQ1) ha stabilito *quando* gli studenti se ne vanno. Questo notebook passa dall'osservazione al **test statistico formale**: quali segnali precoci (primi 28 giorni) sono predittori statisticamente significativi del dropout finale, e quanto sono forti gli effetti?\n", - "\n", - "**Cosa copre questo notebook:**\n", - "- Caricamento e ispezione del dataset di analisi BQ2 (`q_bq2_early_signals.sql`)\n", - "- Confronto descrittivo di 8 segnali precoci per esito di completamento\n", - "- T-test indipendenti con effect size Cohen's d su ciascun segnale\n", - "- Correzione per confronti multipli (Bonferroni e Benjamini-Hochberg)\n", - "- Forest plot degli effect size classificati — la figura chiave deliverable\n", - "- Approfondimenti sui segnali: dose-response per i predittori più forti\n", - "- Segnali basati sulle valutazioni: primo punteggio e tempistica di consegna\n", - "- Quantificazione del segnale ghost student con intervalli di confidenza bootstrap\n", - "\n", - "**Cosa questo notebook NON fa:**\n", - "- Nessun modello di machine learning. Tutta l'analisi usa statistica descrittiva e inferenziale.\n", - "- Nessuna affermazione causale. Identifichiamo *associazioni*, non cause.\n", - "\n", - "**Cosa viene dopo:**\n", - "- **Notebook 05** (`05_bq3_demographics_vs_behavior.ipynb`): dati demografici vs. comportamento — cosa predice meglio l'esito? (BQ3)\n", - "\n", - "> **Trasferibilità metodologica:** L'approccio di classificazione dei segnali usato qui — t-test sistematici con effect size, correzione per confronti multipli e forest plot — è il toolkit standard per identificare feature predittive nella product analytics. Sostituite \"segnali di engagement\" con \"metriche di utilizzo delle feature\" e \"completamento\" con \"retention\" per applicare questo framework all'analisi del churn SaaS, alla previsione di rinnovo degli abbonamenti o allo scoring dell'engagement nelle app fitness." - ] - }, - { - "cell_type": "markdown", - "id": "2", - "metadata": {}, - "source": [ - "## Indice\n", - "\n", - "1. [Configurazione ambiente](#1.-Configurazione-ambiente)\n", - "2. [Il dataset di analisi](#2.-Il-dataset-di-analisi)\n", - "3. [Confronto descrittivo](#3.-Confronto-descrittivo)\n", - "4. [Test statistici — T-Test](#4.-Test-statistici-—-T-Test)\n", - "5. [Correzione per confronti multipli](#5.-Correzione-per-confronti-multipli)\n", - "6. [Classificazione per effect size — Forest Plot](#6.-Classificazione-per-effect-size-—-Forest-Plot)\n", - "7. [Approfondimenti sui segnali](#7.-Approfondimenti-sui-segnali)\n", - "8. [Segnali basati sulle valutazioni](#8.-Segnali-basati-sulle-valutazioni)\n", - "9. [Segnale Ghost Student](#9.-Segnale-Ghost-Student)\n", - "10. [Conclusioni chiave e prossimi passi](#10.-Conclusioni-chiave-e-prossimi-passi)\n", - "\n", - "---\n", - "\n", - "**Prerequisiti:**\n", - "- La pipeline ETL deve essere stata eseguita: `python -m run_pipeline`\n", - "- Il database DuckDB in `data/db/oulad.duckdb` deve contenere tutte e 5 le viste analitiche\n", - "\n", - "**Dataset:** Open University Learning Analytics Dataset (OULAD) — ~32K studenti, 7 corsi, clickstream comportamentale completo. Licenza: CC-BY 4.0." - ] - }, - { - "cell_type": "markdown", - "id": "3", - "metadata": {}, - "source": [ - "## 1. Configurazione ambiente\n", - "\n", - "Configuriamo gli import, i parametri di visualizzazione e le funzioni helper riutilizzabili.\n", - "\n", - "**Note tecniche per il lettore:**\n", - "- Tutte le query al database passano attraverso `src.db.connection.execute_query()` — il livello di astrazione DB del progetto (ADR-003).\n", - "- La query SQL principale di BQ2 risiede in `sql/queries/q_bq2_early_signals.sql` e viene caricata a runtime dal disco.\n", - "- I test statistici usano `src.stats.tests` — wrapper di progetto attorno a scipy che standardizzano il formato di output (statistica del test, p-value, effect size, intervallo di confidenza).\n", - "- Le figure vengono salvate in `reports/figures/` a 150 DPI." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Path setup ---\n", - "# Notebooks live in notebooks/ but project modules are at the project root.\n", - "# We search upward for pyproject.toml so the notebook works regardless of\n", - "# where the kernel is launched from (JupyterLab, VS Code, Cursor, repo root).\n", - "import sys\n", - "from pathlib import Path\n", - "\n", - "\n", - "def _find_project_root(start: Path) -> Path:\n", - " \"\"\"Walk upward from start until we find pyproject.toml (the repo root marker).\"\"\"\n", - " for candidate in (start, *start.parents):\n", - " if (candidate / 'pyproject.toml').is_file():\n", - " return candidate\n", - " raise RuntimeError(\n", - " f\"Could not locate project root: no pyproject.toml found in '{start}' \"\n", - " \"or any parent directory. Run the notebook from within the repository.\"\n", - " )\n", - "\n", - "\n", - "PROJECT_ROOT = _find_project_root(Path.cwd())\n", - "if str(PROJECT_ROOT) not in sys.path:\n", - " sys.path.insert(0, str(PROJECT_ROOT))\n", - "\n", - "# --- Standard library ---\n", - "import logging\n", - "import warnings\n", - "\n", - "# --- Third-party ---\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import pandas as pd\n", - "import seaborn as sns\n", - "\n", - "# --- Project modules ---\n", - "from src.config import FIGURES_DIR, QUERIES_DIR\n", - "from src.db.connection import execute_query\n", - "from src.stats.tests import (\n", - " apply_multiple_comparison_correction,\n", - " bootstrap_ci,\n", - " independent_t_test,\n", - ")\n", - "\n", - "# --- Configuration ---\n", - "warnings.filterwarnings('ignore', category=FutureWarning)\n", - "logging.basicConfig(level=logging.WARNING)\n", - "\n", - "# --- Visualization defaults ---\n", - "sns.set_theme(style='whitegrid', font_scale=1.1)\n", - "\n", - "PALETTE_OUTCOME = {\n", - " 'Pass': '#4C72B0',\n", - " 'Distinction': '#55A868',\n", - " 'Fail': '#C44E52',\n", - " 'Withdrawn': '#8172B3',\n", - "}\n", - "LABEL_COMPLETED = 'Completed'\n", - "LABEL_NOT_COMPLETED = 'Not completed'\n", - "PALETTE_BINARY = {1: '#55A868', 0: '#C44E52'}\n", - "LABEL_BINARY = {1: LABEL_COMPLETED, 0: LABEL_NOT_COMPLETED}\n", - "PALETTE_BINARY_LABELS = {LABEL_COMPLETED: '#55A868', LABEL_NOT_COMPLETED: '#C44E52'}\n", - "PALETTE_SEQUENTIAL = 'YlOrRd'\n", - "\n", - "# Shared axis labels\n", - "LABEL_COMPLETION_RATE = 'Completion rate (%)'\n", - "LABEL_NUM_ENROLLMENTS = 'Number of enrollments'\n", - "LABEL_EFFECT_SIZE = \"Cohen's d\"\n", - "\n", - "# Significance threshold — used for annotation and interpretation\n", - "ALPHA = 0.05\n", - "\n", - "FIG_DPI = 150\n", - "FIG_SIZE = (10, 6)\n", - "FIG_SIZE_WIDE = (16, 5)\n", - "\n", - "FIGURES_DIR.mkdir(parents=True, exist_ok=True)\n", - "\n", - "\n", - "def save_fig(fig, name: str) -> None:\n", - " \"\"\"Save figure to reports/figures/ with consistent settings.\"\"\"\n", - " path = FIGURES_DIR / f'{name}.png'\n", - " fig.savefig(path, dpi=FIG_DPI, bbox_inches='tight', facecolor='white')\n", - " print(f' Saved: {path}')\n", - "\n", - "\n", - "# --- Load BQ2 query from SQL file ---\n", - "_bq2_sql_path = QUERIES_DIR / 'q_bq2_early_signals.sql'\n", - "BQ2_SQL = _bq2_sql_path.read_text(encoding='utf-8')\n", - "print(f'Loaded BQ2 query from: {_bq2_sql_path.name} ({len(BQ2_SQL):,} chars)')\n", - "\n", - "# --- Prerequisite check ---\n", - "try:\n", - " _check_student = execute_query('SELECT COUNT(*) AS n FROM v_student_enriched')\n", - " _check_early = execute_query('SELECT COUNT(*) AS n FROM v_engagement_early')\n", - " _n_student = _check_student['n'].iloc[0]\n", - " _n_early = _check_early['n'].iloc[0]\n", - " if _n_student == 0 or _n_early == 0:\n", - " raise RuntimeError('One or more views are empty')\n", - " print('Database OK')\n", - " print(f' v_student_enriched: {_n_student:>12,} rows')\n", - " print(f' v_engagement_early: {_n_early:>12,} rows')\n", - "except Exception as exc:\n", - " raise RuntimeError(\n", - " 'Cannot query analytical views. '\n", - " \"Run 'python -m run_pipeline' first to populate the database.\"\n", - " ) from exc" - ] - }, - { - "cell_type": "markdown", - "id": "5", - "metadata": {}, - "source": [ - "## 2. Il dataset di analisi\n", - "\n", - "La query BQ2 (`q_bq2_early_signals.sql`) esegue il join tra `v_student_enriched`, `v_engagement_early` e i dati della prima valutazione per creare un singolo DataFrame pronto per l'analisi. Ogni riga rappresenta un'iscrizione studente con:\n", - "\n", - "| Colonna | Descrizione | Sorgente |\n", - "|---------|-------------|----------|\n", - "| `active_days_first_28` | Giorni distinti con attività VLE (0–28) | `v_engagement_early` |\n", - "| `total_clicks_first_28` | Click totali nei primi 28 giorni | `v_engagement_early` |\n", - "| `avg_clicks_per_active_day` | Intensità di click per giorno attivo | `v_engagement_early` |\n", - "| `last_active_day_in_window` | Ultimo giorno di attività nella finestra | `v_engagement_early` |\n", - "| `engagement_decile_in_course` | Rank di engagement nel corso (1–10) | `v_engagement_early` |\n", - "| `first_score` | Punteggio alla prima valutazione (NULL se nessuna) | `studentAssessment` |\n", - "| `first_submit_day` | Giorno di consegna della prima valutazione (NULL se nessuna) | `studentAssessment` |\n", - "| `date_registration` | Giorno di registrazione (negativo = registrazione anticipata) | `v_student_enriched` |\n", - "| `completed` | Esito binario: 1 = completato, 0 = non completato | `v_student_enriched` |\n", - "\n", - "Gli studenti con zero attività VLE ricevono valori `COALESCE` pari a 0 per le metriche di engagement — sono inclusi come estremo inferiore dello spettro di engagement." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Load BQ2 analysis dataset ---\n", - "df_signals = execute_query(BQ2_SQL)\n", - "\n", - "print(f'BQ2 dataset: {len(df_signals):,} rows x {df_signals.shape[1]} columns')\n", - "print('\\nOutcome distribution:')\n", - "print(df_signals['completed'].value_counts().rename(LABEL_BINARY).to_string())\n", - "\n", - "# --- Signal columns to test ---\n", - "# These are the 8 early behavioral signals we will compare between\n", - "# completed (1) and not-completed (0) groups.\n", - "SIGNAL_COLUMNS = [\n", - " 'active_days_first_28',\n", - " 'total_clicks_first_28',\n", - " 'avg_clicks_per_active_day',\n", - " 'last_active_day_in_window',\n", - " 'engagement_decile_in_course',\n", - " 'first_score',\n", - " 'first_submit_day',\n", - " 'date_registration',\n", - "]\n", - "\n", - "# Readable labels for plots\n", - "SIGNAL_LABELS = {\n", - " 'active_days_first_28': 'Active days (first 28)',\n", - " 'total_clicks_first_28': 'Total clicks (first 28)',\n", - " 'avg_clicks_per_active_day': 'Avg clicks per active day',\n", - " 'last_active_day_in_window': 'Last active day in window',\n", - " 'engagement_decile_in_course': 'Engagement decile',\n", - " 'first_score': 'First assessment score',\n", - " 'first_submit_day': 'First submission day',\n", - " 'date_registration': 'Registration day',\n", - "}\n", - "\n", - "# --- Missingness check ---\n", - "# first_score and first_submit_day are NULL for students who never submitted\n", - "# an assessment in the first 28 days. The t-test wrapper handles NaN-dropping,\n", - "# but we need to report the effective sample size for each signal.\n", - "print('\\n=== Non-null counts per signal ===')\n", - "for col in SIGNAL_COLUMNS:\n", - " n_valid = df_signals[col].notna().sum()\n", - " n_missing = df_signals[col].isna().sum()\n", - " print(f' {SIGNAL_LABELS[col]:35s} {n_valid:>8,} valid {n_missing:>6,} missing')" - ] - }, - { - "cell_type": "markdown", - "id": "7", - "metadata": {}, - "source": [ - "## 3. Confronto descrittivo\n", - "\n", - "Prima dei test statistici, confrontiamo visivamente la distribuzione di ciascun segnale tra i due gruppi di esito. Questo costruisce l'intuizione su *dove* risiedono le differenze e se le distribuzioni si sovrappongono in modo sostanziale.\n", - "\n", - "I violin plot mostrano la forma completa della distribuzione — più informativi dei semplici grafici a barre delle medie, specialmente per rilevare bimodalità o code pesanti tipiche dei dati di clickstream." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Summary statistics by outcome group ---\n", - "df_signals['outcome'] = df_signals['completed'].map(LABEL_BINARY)\n", - "\n", - "# Display mean and median for each signal by group\n", - "summary_rows = []\n", - "for col in SIGNAL_COLUMNS:\n", - " for outcome_val in [1, 0]:\n", - " subset = df_signals[df_signals['completed'] == outcome_val][col].dropna()\n", - " summary_rows.append({\n", - " 'Signal': SIGNAL_LABELS[col],\n", - " 'Outcome': LABEL_BINARY[outcome_val],\n", - " 'N': len(subset),\n", - " 'Mean': round(subset.mean(), 2),\n", - " 'Median': round(subset.median(), 2),\n", - " 'Std': round(subset.std(), 2),\n", - " })\n", - "\n", - "df_summary = pd.DataFrame(summary_rows)\n", - "print('=== Descriptive Statistics by Outcome ===\\n')\n", - "# Pivot for side-by-side comparison\n", - "for col in SIGNAL_COLUMNS:\n", - " label = SIGNAL_LABELS[col]\n", - " row_data = df_summary[df_summary['Signal'] == label]\n", - " print(f'\\n {label}:')\n", - " for _, r in row_data.iterrows():\n", - " print(f\" {r['Outcome']:20s} N={r['N']:>8,} \"\n", - " f\"mean={r['Mean']:>10.2f} median={r['Median']:>10.2f} std={r['Std']:>10.2f}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Violin plot grid: 6 engagement signals split by outcome ---\n", - "# We plot the 6 main engagement signals (excluding first_score and\n", - "# first_submit_day which have substantial missingness and are analyzed\n", - "# separately in Section 8).\n", - "engagement_signals = [\n", - " 'active_days_first_28',\n", - " 'total_clicks_first_28',\n", - " 'avg_clicks_per_active_day',\n", - " 'last_active_day_in_window',\n", - " 'engagement_decile_in_course',\n", - " 'date_registration',\n", - "]\n", - "\n", - "fig, axes = plt.subplots(2, 3, figsize=(16, 10))\n", - "axes_flat = axes.flatten()\n", - "\n", - "for i, col in enumerate(engagement_signals):\n", - " ax = axes_flat[i]\n", - " sns.violinplot(\n", - " data=df_signals, x='outcome', y=col,\n", - " palette=PALETTE_BINARY_LABELS, inner='quartile', ax=ax,\n", - " order=[LABEL_COMPLETED, LABEL_NOT_COMPLETED],\n", - " )\n", - " ax.set_xlabel('')\n", - " ax.set_ylabel(SIGNAL_LABELS[col])\n", - " sns.despine(ax=ax)\n", - "\n", - "fig.suptitle(\n", - " 'Early Signals Distribution by Completion Outcome',\n", - " fontsize=14, y=1.02,\n", - ")\n", - "fig.tight_layout()\n", - "save_fig(fig, '04_signals_violins')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "10", - "metadata": {}, - "source": [ - "> **Impressione visiva:** Tutti e sei i segnali di engagement mostrano una separazione visibile tra chi completa e chi non completa. Le distribuzioni si sovrappongono sostanzialmente — come previsto con dati comportamentali — ma le tendenze centrali (medie, mediane) differiscono in modo coerente.\n", - ">\n", - "> La domanda è: queste differenze sono **statisticamente significative**, e quanto sono **grandi** gli effetti? Le Sezioni 4–6 rispondono formalmente." - ] - }, - { - "cell_type": "markdown", - "id": "11", - "metadata": {}, - "source": [ - "## 4. Test statistici — T-Test\n", - "\n", - "Usiamo il **t-test indipendente di Welch** (varianze disuguali) per confrontare ciascun segnale tra i gruppi completato e non completato. Per ogni test riportiamo:\n", - "\n", - "- **t-statistic**: entità della differenza nelle medie dei gruppi rispetto alla variabilità\n", - "- **p-value**: probabilità di osservare questa differenza (o più estrema) sotto l'ipotesi nulla di nessuna differenza\n", - "- **Cohen's d**: effect size standardizzato (piccolo ≈ 0.2, medio ≈ 0.5, grande ≈ 0.8)\n", - "- **IC 95%**: intervallo di confidenza per la differenza nelle medie\n", - "\n", - "Tutti i test usano il wrapper `independent_t_test()` da `src/stats/tests.py`, che standardizza il formato di output tramite una dataclass `TestResult`.\n", - "\n", - "**Perché t-test e non qualcosa di più sofisticato?** Con ~32K osservazioni, anche differenze banali diventano statisticamente significative. Per questo l'**effect size (Cohen's d)** è il nostro criterio di classificazione primario, non il p-value. Un p-value significativo ci dice che la differenza è reale; il Cohen's d ci dice se è *significativa nella pratica*." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "12", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Systematic t-testing across all 8 signals ---\n", - "# We split the dataset by outcome and run independent_t_test() on each\n", - "# signal column, collecting results for ranking and correction.\n", - "completed = df_signals[df_signals['completed'] == 1]\n", - "not_completed = df_signals[df_signals['completed'] == 0]\n", - "\n", - "results = []\n", - "for col in SIGNAL_COLUMNS:\n", - " result = independent_t_test(\n", - " completed[col].dropna(),\n", - " not_completed[col].dropna(),\n", - " variable_name=col,\n", - " )\n", - " results.append(result)\n", - "\n", - "# --- Build results DataFrame ---\n", - "df_results = pd.DataFrame([{\n", - " 'signal': r.test_name.replace('t-test: ', ''),\n", - " 'signal_label': SIGNAL_LABELS[r.test_name.replace('t-test: ', '')],\n", - " 't_statistic': round(r.statistic, 3),\n", - " 'p_value': r.p_value,\n", - " 'cohens_d': round(r.effect_size, 3),\n", - " 'abs_cohens_d': round(abs(r.effect_size), 3),\n", - " 'ci_lower': round(r.ci_lower, 3),\n", - " 'ci_upper': round(r.ci_upper, 3),\n", - " 'n_completed': r.n_group1,\n", - " 'n_not_completed': r.n_group2,\n", - "} for r in results])\n", - "\n", - "# Sort by absolute effect size (strongest signal first)\n", - "df_results = df_results.sort_values('abs_cohens_d', ascending=False).reset_index(drop=True)\n", - "\n", - "print('=== T-Test Results (sorted by |Cohen\\'s d|) ===\\n')\n", - "print(df_results[[\n", - " 'signal_label', 't_statistic', 'p_value', 'cohens_d',\n", - " 'ci_lower', 'ci_upper', 'n_completed', 'n_not_completed',\n", - "]].to_string(index=False))" - ] - }, - { - "cell_type": "markdown", - "id": "13", - "metadata": {}, - "source": [ - "## 5. Correzione per confronti multipli\n", - "\n", - "Stiamo testando 8 segnali simultaneamente. Senza correzione, la probabilità di almeno un falso positivo è `1 - (1 - 0.05)^8 ≈ 34%`. Per controllare questo rischio, applichiamo due correzioni standard:\n", - "\n", - "- **Bonferroni**: conservativa, controlla il family-wise error rate (FWER). Moltiplica ogni p-value per il numero di test. Può essere eccessivamente conservativa per segnali correlati.\n", - "- **Benjamini-Hochberg (BH)**: meno conservativa, controlla il false discovery rate (FDR). Preferibile quando si testano molte variabili correlate.\n", - "\n", - "Con solo 8 test, Bonferroni è ancora ragionevole. Mostriamo entrambe per illustrare la differenza." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "14", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Apply multiple comparison correction ---\n", - "raw_p = df_results['p_value'].tolist()\n", - "\n", - "df_results['p_bonferroni'] = apply_multiple_comparison_correction(raw_p, 'bonferroni')\n", - "df_results['p_bh'] = apply_multiple_comparison_correction(raw_p, 'benjamini-hochberg')\n", - "df_results['sig_raw'] = df_results['p_value'] < ALPHA\n", - "df_results['sig_bonferroni'] = df_results['p_bonferroni'] < ALPHA\n", - "df_results['sig_bh'] = df_results['p_bh'] < ALPHA\n", - "\n", - "print('=== Significance After Correction ===\\n')\n", - "print(df_results[[\n", - " 'signal_label', 'p_value', 'p_bonferroni', 'p_bh',\n", - " 'sig_raw', 'sig_bonferroni', 'sig_bh',\n", - "]].to_string(index=False))\n", - "\n", - "n_sig_raw = df_results['sig_raw'].sum()\n", - "n_sig_bonf = df_results['sig_bonferroni'].sum()\n", - "n_sig_bh = df_results['sig_bh'].sum()\n", - "print(f'\\nSignificant at alpha={ALPHA}:')\n", - "print(f' Raw: {n_sig_raw}/8')\n", - "print(f' Bonferroni: {n_sig_bonf}/8')\n", - "print(f' BH: {n_sig_bh}/8')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "15", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Visualization: raw vs corrected p-values ---\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "\n", - "y_pos = np.arange(len(df_results))\n", - "bar_height = 0.25\n", - "\n", - "# Use -log10(p) for visual clarity: larger bars = more significant\n", - "ax.barh(y_pos - bar_height, -np.log10(df_results['p_value'].clip(lower=1e-300)),\n", - " bar_height, label='Raw p-value', color='#4C72B0', edgecolor='white')\n", - "ax.barh(y_pos, -np.log10(df_results['p_bonferroni'].clip(lower=1e-300)),\n", - " bar_height, label='Bonferroni', color='#C44E52', edgecolor='white')\n", - "ax.barh(y_pos + bar_height, -np.log10(df_results['p_bh'].clip(lower=1e-300)),\n", - " bar_height, label='Benjamini-Hochberg', color='#55A868', edgecolor='white')\n", - "\n", - "# Significance threshold line: -log10(0.05) ≈ 1.3\n", - "ax.axvline(x=-np.log10(ALPHA), color='gray', linestyle='--', linewidth=1,\n", - " label=f'alpha = {ALPHA}')\n", - "\n", - "ax.set_yticks(y_pos)\n", - "ax.set_yticklabels(df_results['signal_label'])\n", - "ax.set_xlabel('-log10(p-value) [larger = more significant]')\n", - "ax.set_title('P-Value Comparison: Raw vs. Corrected')\n", - "ax.legend(loc='lower right')\n", - "ax.invert_yaxis()\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '04_correction_comparison')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "16", - "metadata": {}, - "source": [ - "> **Interpretazione:** Con un dataset ampio (~32K iscrizioni), la maggior parte dei segnali rimane significativa anche dopo correzione Bonferroni. Questo conferma che le differenze sono reali — ma la significatività statistica da sola non ci dice quali segnali sono *praticamente significativi*. È ciò che affronta la classificazione per effect size (sezione successiva)." - ] - }, - { - "cell_type": "markdown", - "id": "17", - "metadata": {}, - "source": [ - "## 6. Classificazione per effect size — Forest Plot\n", - "\n", - "Questa è la **figura chiave deliverable** per BQ2. Il forest plot classifica tutti i segnali per Cohen's d (effect size assoluto), mostrando la differenza standardizzata delle medie per ciascun segnale.\n", - "\n", - "**Come leggere il grafico:**\n", - "- Ogni riga è un segnale\n", - "- Il punto mostra il valore di Cohen's d (differenza standardizzata delle medie)\n", - "- Le linee di riferimento verticali a d = 0.2, 0.5, 0.8 indicano le soglie convenzionali di Cohen per effetti piccoli, medi e grandi\n", - "- I segnali sono ordinati dall'effetto più grande al più piccolo\n", - "\n", - "**Cohen's d positivo** significa che il gruppo che ha completato ha una media *più alta*. **Cohen's d negativo** significa che il gruppo che ha completato ha una media *più bassa* (es. giorno di registrazione anticipato = `date_registration` più negativo)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "18", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Forest plot: ranked Cohen's d with CI whiskers ---\n", - "# Sort by absolute effect size for consistent visual ranking.\n", - "df_forest = df_results.sort_values('abs_cohens_d', ascending=True).reset_index(drop=True)\n", - "\n", - "fig, ax = plt.subplots(figsize=(10, 7))\n", - "\n", - "y_pos = np.arange(len(df_forest))\n", - "\n", - "# Color based on significance after BH correction\n", - "colors = ['#55A868' if sig else '#999999' for sig in df_forest['sig_bh']]\n", - "\n", - "# Plot effect sizes as points only.\n", - "# Do not draw CI whiskers here: the available test CI is for the mean\n", - "# difference, not for Cohen's d, and arbitrary whiskers would be misleading.\n", - "ax.scatter(df_forest['cohens_d'], y_pos, color=colors, s=80, zorder=3, edgecolor='white')\n", - "\n", - "# Reference lines for effect size interpretation\n", - "for d_ref, label in [(0.2, 'Small'), (0.5, 'Medium'), (0.8, 'Large')]:\n", - " ax.axvline(x=d_ref, color='gray', linestyle=':', linewidth=0.8, alpha=0.6)\n", - " ax.axvline(x=-d_ref, color='gray', linestyle=':', linewidth=0.8, alpha=0.6)\n", - " ax.text(d_ref, len(df_forest) - 0.3, label, ha='center', fontsize=8, color='gray')\n", - "\n", - "# Zero line: no effect\n", - "ax.axvline(x=0, color='black', linewidth=0.8)\n", - "\n", - "# Annotate each point with the exact d value\n", - "for i, (_, row) in enumerate(df_forest.iterrows()):\n", - " ax.text(\n", - " row['cohens_d'] + 0.03 if row['cohens_d'] >= 0 else row['cohens_d'] - 0.03,\n", - " i + 0.15,\n", - " f\"d = {row['cohens_d']:.3f}\",\n", - " ha='left' if row['cohens_d'] >= 0 else 'right',\n", - " fontsize=8, color='#333333',\n", - " )\n", - "\n", - "ax.set_yticks(y_pos)\n", - "ax.set_yticklabels(df_forest['signal_label'])\n", - "ax.set_xlabel(LABEL_EFFECT_SIZE)\n", - "ax.set_title('BQ2: Early Signal Ranking by Effect Size\\n'\n", - " '(green = significant after BH correction, gray = not significant)')\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '04_forest_plot_effect_sizes')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "19", - "metadata": {}, - "source": [ - "> **Risultato chiave:** Il forest plot rivela una classificazione chiara dei segnali comportamentali precoci. I predittori più forti del completamento sono probabilmente le metriche di volume di engagement (giorni attivi, click totali), mentre i segnali basati sulle valutazioni e il timing di registrazione forniscono informazioni complementari.\n", - ">\n", - "> **Implicazione pratica:** Un sistema di early warning dovrebbe dare priorità ai segnali meglio classificati. Queste sono le metriche più utili da monitorare nei primi 28 giorni per identificare gli studenti a rischio." - ] - }, - { - "cell_type": "markdown", - "id": "20", - "metadata": {}, - "source": [ - "## 7. Approfondimenti sui segnali\n", - "\n", - "Per i segnali principali, andiamo oltre il confronto delle medie per esaminare la relazione **dose-response**: più engagement migliora monotonicamente gli esiti, o ci sono soglie e rendimenti decrescenti?\n", - "\n", - "Suddividiamo ogni segnale in quartili e calcoliamo il tasso di completamento per ciascun bin." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "21", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Dose-response for top 3 signals ---\n", - "# Select the 3 signals with the largest absolute Cohen's d.\n", - "top_signals = df_results.head(3)['signal'].tolist()\n", - "\n", - "fig, axes = plt.subplots(1, 3, figsize=(18, 5))\n", - "\n", - "for i, col in enumerate(top_signals):\n", - " ax = axes[i]\n", - " label = SIGNAL_LABELS[col]\n", - "\n", - " # Create quartile bins for this signal\n", - " # Use pd.qcut; for signals with many identical values (like decile),\n", - " # duplicates='drop' prevents errors.\n", - " valid = df_signals[[col, 'completed']].dropna().copy()\n", - " try:\n", - " valid['bin'] = pd.qcut(valid[col], q=4, duplicates='drop')\n", - " except ValueError:\n", - " # If qcut fails (too few unique values), use raw values\n", - " valid['bin'] = valid[col]\n", - "\n", - " dose_response = (\n", - " valid.groupby('bin', observed=True)\n", - " .agg(n=('completed', 'count'), rate=('completed', 'mean'))\n", - " .reset_index()\n", - " )\n", - " dose_response['rate_pct'] = (dose_response['rate'] * 100).round(1)\n", - "\n", - " # Bar chart with completion rate per quartile\n", - " x_labels = [str(b) for b in dose_response['bin']]\n", - " x_pos = range(len(dose_response))\n", - " # Gradient from red (low completion) to green (high completion)\n", - " n_bins = len(dose_response)\n", - " gradient = [plt.cm.RdYlGn(j / max(n_bins - 1, 1)) for j in range(n_bins)]\n", - "\n", - " bars = ax.bar(x_pos, dose_response['rate_pct'], color=gradient, edgecolor='white')\n", - " for bar, (_, row) in zip(bars, dose_response.iterrows()):\n", - " ax.text(\n", - " bar.get_x() + bar.get_width() / 2, bar.get_height() + 1,\n", - " f\"{row['rate_pct']:.1f}%\\n(n={int(row['n']):,})\",\n", - " ha='center', fontsize=8, color='#333333',\n", - " )\n", - "\n", - " ax.set_xticks(x_pos)\n", - " ax.set_xticklabels(x_labels, rotation=30, ha='right', fontsize=8)\n", - " ax.set_xlabel(label)\n", - " ax.set_ylabel(LABEL_COMPLETION_RATE)\n", - " ax.set_title(f'Dose-Response: {label}')\n", - " ax.set_ylim(0, 100)\n", - " sns.despine(ax=ax)\n", - "\n", - "fig.suptitle('Dose-Response: Completion Rate by Signal Quartile (top 3 signals)',\n", - " fontsize=14, y=1.02)\n", - "fig.tight_layout()\n", - "save_fig(fig, '04_top_signal_dose_response')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "22", - "metadata": {}, - "source": [ - "> **Interpretazione:** I grafici dose-response confermano che la relazione tra i segnali principali e il completamento è **monotona** (o quasi-monotona): più engagement predice coerentemente tassi di completamento più alti. Questo è importante — significa che il segnale è utile su tutto il suo range, non solo agli estremi.\n", - ">\n", - "> Il gradiente ripido tra il quartile più basso e il più alto quantifica l'impatto pratico: gli studenti nel quartile inferiore dei segnali chiave di engagement hanno tassi di completamento sostanzialmente più bassi di quelli nel quartile superiore." - ] - }, - { - "cell_type": "markdown", - "id": "23", - "metadata": {}, - "source": [ - "## 8. Segnali basati sulle valutazioni\n", - "\n", - "Due dei nostri segnali provengono dal comportamento nelle valutazioni precoci anziché dal clickstream VLE: `first_score` (punteggio alla prima valutazione) e `first_submit_day` (quando è stata consegnata). Questi segnali hanno un livello sostanziale di dati mancanti — gli studenti che non hanno consegnato alcuna valutazione nei primi 28 giorni hanno valori NULL.\n", - "\n", - "I dati mancanti stessi sono informativi: gli studenti che non hanno consegnato alcuna valutazione precoce hanno probabilmente un rischio di dropout più alto di quelli che l'hanno fatto." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "24", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Assessment-based signal analysis ---\n", - "# Compare first_score and first_submit_day between outcome groups,\n", - "# considering that NULLs represent a meaningful segment (non-submitters).\n", - "\n", - "# First: what is the completion rate for submitters vs non-submitters?\n", - "df_signals['submitted_assessment'] = df_signals['first_score'].notna()\n", - "\n", - "assessment_summary = (\n", - " df_signals.groupby('submitted_assessment')\n", - " .agg(n=('completed', 'count'), rate=('completed', 'mean'))\n", - " .reset_index()\n", - ")\n", - "assessment_summary['rate_pct'] = (assessment_summary['rate'] * 100).round(1)\n", - "assessment_summary['segment'] = assessment_summary['submitted_assessment'].map(\n", - " {True: 'Submitted assessment', False: 'No assessment submitted'}\n", - ")\n", - "\n", - "print('=== Completion Rate: Assessment Submission ===\\n')\n", - "print(assessment_summary[['segment', 'n', 'rate_pct']].to_string(index=False))\n", - "\n", - "# --- Violin plots for assessment signals (submitters only) ---\n", - "df_submitters = df_signals[df_signals['submitted_assessment']].copy()\n", - "\n", - "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=FIG_SIZE_WIDE)\n", - "\n", - "sns.violinplot(\n", - " data=df_submitters, x='outcome', y='first_score',\n", - " palette=PALETTE_BINARY_LABELS, inner='quartile', ax=ax1,\n", - " order=[LABEL_COMPLETED, LABEL_NOT_COMPLETED],\n", - ")\n", - "ax1.set_xlabel('')\n", - "ax1.set_ylabel('First assessment score')\n", - "ax1.set_title('First Assessment Score by Outcome (submitters only)')\n", - "sns.despine(ax=ax1)\n", - "\n", - "sns.violinplot(\n", - " data=df_submitters, x='outcome', y='first_submit_day',\n", - " palette=PALETTE_BINARY_LABELS, inner='quartile', ax=ax2,\n", - " order=[LABEL_COMPLETED, LABEL_NOT_COMPLETED],\n", - ")\n", - "ax2.set_xlabel('')\n", - "ax2.set_ylabel('First submission day (relative to course start)')\n", - "ax2.set_title('First Submission Timing by Outcome (submitters only)')\n", - "sns.despine(ax=ax2)\n", - "\n", - "fig.suptitle('Assessment-Based Early Signals', fontsize=14, y=1.02)\n", - "fig.tight_layout()\n", - "save_fig(fig, '04_assessment_signal')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "25", - "metadata": {}, - "source": [ - "> **Interpretazione:**\n", - "> - **Consegna vs non-consegna** è di per sé un segnale potente: gli studenti che hanno consegnato almeno una valutazione nei primi 28 giorni hanno un tasso di completamento sostanzialmente più alto.\n", - "> - Tra chi ha consegnato, il **punteggio alla prima valutazione** mostra separazione — chi completa tende ad avere punteggi più alti alla prima valutazione.\n", - "> - Il **timing di consegna** fornisce un segnale aggiuntivo: una consegna anticipata può indicare una migliore gestione del tempo e un engagement maggiore.\n", - ">\n", - "> **Avvertenza:** I segnali delle valutazioni hanno alta percentuale di dati mancanti. I risultati del t-test per `first_score` e `first_submit_day` riflettono solo la sottopopolazione di chi ha consegnato. Il segnale di *non-consegna* è catturato dalle metriche di engagement (ghost student/studenti a bassa attività)." - ] - }, - { - "cell_type": "markdown", - "id": "26", - "metadata": {}, - "source": [ - "## 9. Segnale Ghost Student\n", - "\n", - "Il Notebook 02 ha identificato i \"ghost student\" — iscrizioni con zero attività VLE nei primi 28 giorni. Qui quantifichiamo questo segnale formalmente usando **intervalli di confidenza bootstrap** per la differenza nel tasso di completamento tra studenti ghost e attivi.\n", - "\n", - "L'approccio bootstrap è usato perché la suddivisione ghost/attivi produce gruppi di dimensioni molto diverse, e il tasso di completamento per i ghost student è vicino allo zero — le assunzioni parametriche per l'IC potrebbero non reggere bene a questi estremi." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "27", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Ghost vs active student completion rate with bootstrap CI ---\n", - "# Ghost students = zero VLE activity in first 28 days.\n", - "# In the BQ2 dataset, they have active_days_first_28 = 0 (COALESCE'd).\n", - "df_signals['is_ghost'] = df_signals['active_days_first_28'] == 0\n", - "\n", - "ghost_completed = df_signals[df_signals['is_ghost']]['completed']\n", - "active_completed = df_signals[~df_signals['is_ghost']]['completed']\n", - "\n", - "ghost_rate = ghost_completed.mean() * 100\n", - "active_rate = active_completed.mean() * 100\n", - "\n", - "# Bootstrap CI for each group's completion rate\n", - "ghost_ci = bootstrap_ci(ghost_completed, statistic_fn=np.mean, n_bootstrap=2000)\n", - "active_ci = bootstrap_ci(active_completed, statistic_fn=np.mean, n_bootstrap=2000)\n", - "\n", - "print('=== Ghost vs Active Student Completion Rate ===\\n')\n", - "print(f' Ghost students (n={len(ghost_completed):,}): '\n", - " f'{ghost_rate:.1f}% 95% CI: [{ghost_ci[0]*100:.1f}%, {ghost_ci[1]*100:.1f}%]')\n", - "print(f' Active students (n={len(active_completed):,}): '\n", - " f'{active_rate:.1f}% 95% CI: [{active_ci[0]*100:.1f}%, {active_ci[1]*100:.1f}%]')\n", - "print(f'\\n Gap: {active_rate - ghost_rate:.1f} percentage points')\n", - "\n", - "# --- Bar chart with bootstrap CI whiskers ---\n", - "fig, ax = plt.subplots(figsize=(8, 5))\n", - "\n", - "segments = ['Ghost\\n(zero activity)', 'Active\\n(>= 1 active day)']\n", - "rates = [ghost_rate, active_rate]\n", - "ci_lower = [ghost_ci[0] * 100, active_ci[0] * 100]\n", - "ci_upper = [ghost_ci[1] * 100, active_ci[1] * 100]\n", - "colors_bar = ['#C44E52', '#55A868']\n", - "\n", - "bars = ax.bar(segments, rates, color=colors_bar, edgecolor='white', width=0.5)\n", - "\n", - "# Add CI whiskers\n", - "for i, bar in enumerate(bars):\n", - " ax.plot(\n", - " [bar.get_x() + bar.get_width() / 2] * 2,\n", - " [ci_lower[i], ci_upper[i]],\n", - " color='black', linewidth=2,\n", - " )\n", - " # Caps\n", - " cap_width = 0.08\n", - " for y_cap in [ci_lower[i], ci_upper[i]]:\n", - " ax.plot(\n", - " [bar.get_x() + bar.get_width() / 2 - cap_width,\n", - " bar.get_x() + bar.get_width() / 2 + cap_width],\n", - " [y_cap, y_cap],\n", - " color='black', linewidth=2,\n", - " )\n", - "\n", - "# Annotate with rate + CI\n", - "for i, bar in enumerate(bars):\n", - " ax.text(\n", - " bar.get_x() + bar.get_width() / 2,\n", - " ci_upper[i] + 2,\n", - " f'{rates[i]:.1f}%\\n[{ci_lower[i]:.1f}–{ci_upper[i]:.1f}%]',\n", - " ha='center', fontsize=10, color='#333333',\n", - " )\n", - "\n", - "ax.set_ylabel(LABEL_COMPLETION_RATE)\n", - "ax.set_title('Completion Rate: Ghost vs. Active Students\\n(with 95% Bootstrap CI)')\n", - "ax.set_ylim(0, max(ci_upper) + 15)\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '04_ghost_vs_active_completion')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "28", - "metadata": {}, - "source": [ - "> **Risultato chiave:** Il gap tra ghost student e studenti attivi è enorme e gli intervalli di confidenza bootstrap al 95% non si sovrappongono. I ghost student — quelli con zero attività VLE nei primi 28 giorni — hanno un tasso di completamento prossimo allo zero.\n", - ">\n", - "> Questo è il **segnale singolo più forte** nel dataset: se uno studente non ha cliccato su nessuna risorsa VLE entro le prime 4 settimane, la sua probabilità di completare il corso è trascurabile.\n", - ">\n", - "> **Priorità di intervento:** I ghost student sono il frutto più facile da raccogliere. Non hanno bisogno di contenuti migliori — hanno bisogno di essere *attivati*. In termini SaaS, questo è il gap dell'onboarding: utenti che si sono iscritti ma non hanno mai sperimentato il valore del prodotto." - ] - }, - { - "cell_type": "markdown", - "id": "29", - "metadata": {}, - "source": [ - "## 10. Conclusioni chiave e prossimi passi\n", - "\n", - "### Cosa abbiamo imparato\n", - "\n", - "1. **Tutti e 8 i segnali precoci mostrano differenze statisticamente significative** tra chi completa e chi non completa. Con ~32K iscrizioni, anche differenze modeste raggiungono la significatività — motivo per cui l'effect size (Cohen's d) è il criterio di classificazione primario.\n", - "\n", - "2. **I predittori comportamentali più forti** sono le metriche di volume di engagement: giorni attivi, click totali e decile di engagement. Questi segnali catturano sia la frequenza che l'intensità dell'interazione con la piattaforma nei primi 28 giorni.\n", - "\n", - "3. **La correzione per confronti multipli conferma la robustezza.** La maggior parte dei segnali rimane significativa dopo correzione sia Bonferroni che Benjamini-Hochberg, indicando che le associazioni sono reali, non artefatti dei test multipli.\n", - "\n", - "4. **Le relazioni dose-response sono monotone.** Più engagement predice coerentemente tassi di completamento più alti in tutti i quartili dei segnali. Non ci sono soglie evidenti o rendimenti decrescenti — la relazione è graduale.\n", - "\n", - "5. **La consegna della valutazione è un predittore binario.** Il fatto che uno studente abbia o meno consegnato una valutazione nei primi 28 giorni è di per sé un segnale potente, indipendente dal punteggio ottenuto.\n", - "\n", - "6. **I ghost student sono il caso estremo.** Zero attività VLE nei primi 28 giorni predice con quasi certezza il non completamento. Questo è il segmento azionabile più chiaro per l'intervento.\n", - "\n", - "7. **Gli effect size sono piccoli-medi** secondo le convenzioni di Cohen. Questo è tipico per dati comportamentali: i singoli segnali spiegano una quota modesta della varianza dell'esito. Il valore pratico viene dalla combinazione di più segnali in un punteggio di engagement (BQ5).\n", - "\n", - "8. **Nessuna affermazione causale.** Tutti i risultati sono associazioni. Gli studenti motivati possono sia impegnarsi di più sia completare di più — l'engagement potrebbe essere un proxy della motivazione, non una causa del successo.\n", - "\n", - "### Cosa viene dopo\n", - "\n", - "| Notebook | Business Question | Focus |\n", - "|----------|------------------|-------|\n", - "| **05** | BQ3 | Dati demografici vs. comportamento — cosa predice meglio l'esito? |\n", - "| **06** | BQ4 | Come influiscono le caratteristiche del corso sulla retention? |\n", - "| **07** | BQ5 | Le 3 principali raccomandazioni operative |\n", - "\n", - "---\n", - "\n", - "**Riproducibilità:** Tutte le figure sono salvate in `reports/figures/`. Per riprodurre questo notebook, eseguire prima `python -m run_pipeline`, poi eseguire tutte le celle in ordine." - ] - }, - { - "cell_type": "markdown", - "id": "30", - "metadata": {}, - "source": [ - "> **Dai segnali al confronto:** Questo notebook ha identificato *quali* comportamenti precoci predicono il dropout e li ha classificati per effect size. La prossima domanda è: questi segnali comportamentali superano i fattori demografici? O il background di uno studente conta più di ciò che effettivamente fa sulla piattaforma?\n", - ">\n", - "> Proseguire con il **Notebook 05** (`05_bq3_demographics_vs_behavior.ipynb`) per BQ3: dati demografici vs. comportamento — cosa predice meglio l'esito?" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.13.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/it/05_bq3_demographics_vs_behavior.ipynb b/notebooks/it/05_bq3_demographics_vs_behavior.ipynb deleted file mode 100644 index cbdd3d0..0000000 --- a/notebooks/it/05_bq3_demographics_vs_behavior.ipynb +++ /dev/null @@ -1,1014 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "# 05 — BQ3: Dati demografici vs comportamento — Cosa predice meglio l'esito?\n", - "\n", - "> **Notebook 05 di 7** | Learning Retention Analytics \n", - "> Analisi della terza business question: confronto della forza predittiva delle caratteristiche demografiche rispetto ai segnali comportamentali precoci." - ] - }, - { - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "## Scopo e ambito\n", - "\n", - "Questo notebook risponde a **BQ3: I dati demografici o il comportamento predicono l'esito in modo più forte?** — la terza delle cinque business question che guidano il progetto.\n", - "\n", - "Il Notebook 04 (BQ2) ha classificato i segnali comportamentali precoci per associazione con il completamento. Questo notebook aggiunge la **dimensione demografica**: se conosciamo l'età, il livello di istruzione e il background socioeconomico di uno studente, questo predice il suo esito meglio o peggio rispetto a sapere come ha interagito con la piattaforma nei primi 28 giorni?\n", - "\n", - "**Cosa copre questo notebook:**\n", - "- Caricamento e ispezione del dataset di analisi BQ3 (`q_bq3_demographics_vs_behavior.sql`)\n", - "- Test chi-quadrato con V di Cramér per le caratteristiche demografiche categoriali\n", - "- T-test indipendenti con Cohen's d per le caratteristiche continue e numeriche\n", - "- Confronto diretto tra effect size demografici e comportamentali\n", - "- Approfondimento: interazione livello di istruzione × engagement\n", - "- Inquadramento etico: perché la risposta conta per la progettazione della piattaforma\n", - "\n", - "**Cosa questo notebook NON fa:**\n", - "- Nessun modello di machine learning. Tutta l'analisi usa statistica inferenziale.\n", - "- Nessuna affermazione causale. Misuriamo *associazione*, non causalità.\n", - "- Nessuna previsione a livello individuale. Questa è analisi di pattern a livello di popolazione.\n", - "\n", - "**Cosa viene dopo:**\n", - "- **Notebook 06** (`06_bq4_course_comparison.ipynb`): come influiscono le caratteristiche del corso sulla retention? (BQ4)\n", - "\n", - "> **Trasferibilità metodologica:** Il confronto dati demografici vs. comportamento è una domanda centrale nella product analytics. In SaaS: i dati demografici degli utenti (dimensione azienda, settore, ruolo) predicono il churn meglio dei pattern di utilizzo del prodotto? In health tech: i dati demografici dei pazienti predicono l'aderenza meglio dell'engagement con l'app? Il framework usato qui — test paralleli di effect size e confronto — si trasferisce direttamente." - ] - }, - { - "cell_type": "markdown", - "id": "2", - "metadata": {}, - "source": [ - "## Indice\n", - "\n", - "1. [Configurazione ambiente](#1.-Configurazione-ambiente)\n", - "2. [Il dataset di analisi](#2.-Il-dataset-di-analisi)\n", - "3. [Parte A — Associazioni demografiche](#3.-Parte-A-—-Associazioni-demografiche)\n", - "4. [Parte B — Associazioni comportamentali](#4.-Parte-B-—-Associazioni-comportamentali)\n", - "5. [Parte C — Il verdetto: dati demografici vs comportamento](#5.-Parte-C-—-Il-verdetto:-dati-demografici-vs-comportamento)\n", - "6. [Approfondimento — Livello di istruzione × Engagement](#6.-Approfondimento-—-Livello-di-istruzione-×-Engagement)\n", - "7. [Inquadramento etico](#7.-Inquadramento-etico)\n", - "8. [Conclusioni chiave e prossimi passi](#8.-Conclusioni-chiave-e-prossimi-passi)\n", - "\n", - "---\n", - "\n", - "**Prerequisiti:**\n", - "- La pipeline ETL deve essere stata eseguita: `python -m run_pipeline`\n", - "- Il database DuckDB in `data/db/oulad.duckdb` deve contenere tutte e 5 le viste analitiche\n", - "\n", - "**Dataset:** Open University Learning Analytics Dataset (OULAD) — ~32K studenti, 7 corsi, clickstream comportamentale completo. Licenza: CC-BY 4.0." - ] - }, - { - "cell_type": "markdown", - "id": "3", - "metadata": {}, - "source": [ - "## 1. Configurazione ambiente\n", - "\n", - "Configuriamo gli import, i parametri di visualizzazione e le funzioni helper riutilizzabili.\n", - "\n", - "**Note tecniche per il lettore:**\n", - "- Tutte le query al database passano attraverso `src.db.connection.execute_query()` — il livello di astrazione DB del progetto (ADR-003).\n", - "- La query SQL principale di BQ3 risiede in `sql/queries/q_bq3_demographics_vs_behavior.sql` e viene caricata a runtime dal disco.\n", - "- I test statistici usano `src.stats.tests` — wrapper di progetto attorno a scipy. Questo notebook introduce `chi_square_test()` (V di Cramér) accanto a `independent_t_test()` (Cohen's d) usato in NB04.\n", - "- Le figure vengono salvate in `reports/figures/` a 150 DPI." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Path setup ---\n", - "# Notebooks live in notebooks/ but project modules are at the project root.\n", - "# We search upward for pyproject.toml so the notebook works regardless of\n", - "# where the kernel is launched from (JupyterLab, VS Code, Cursor, repo root).\n", - "import sys\n", - "from pathlib import Path\n", - "\n", - "\n", - "def _find_project_root(start: Path) -> Path:\n", - " \"\"\"Walk upward from start until we find pyproject.toml (the repo root marker).\"\"\"\n", - " for candidate in (start, *start.parents):\n", - " if (candidate / 'pyproject.toml').is_file():\n", - " return candidate\n", - " raise RuntimeError(\n", - " f\"Could not locate project root: no pyproject.toml found in '{start}' \"\n", - " \"or any parent directory. Run the notebook from within the repository.\"\n", - " )\n", - "\n", - "\n", - "PROJECT_ROOT = _find_project_root(Path.cwd())\n", - "if str(PROJECT_ROOT) not in sys.path:\n", - " sys.path.insert(0, str(PROJECT_ROOT))\n", - "\n", - "# --- Standard library ---\n", - "import logging\n", - "import warnings\n", - "\n", - "# --- Third-party ---\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import pandas as pd\n", - "import seaborn as sns\n", - "\n", - "# --- Project modules ---\n", - "from src.config import FIGURES_DIR, QUERIES_DIR\n", - "from src.db.connection import execute_query\n", - "from src.stats.tests import (\n", - " apply_multiple_comparison_correction,\n", - " chi_square_test,\n", - " independent_t_test,\n", - ")\n", - "\n", - "# --- Configuration ---\n", - "warnings.filterwarnings('ignore', category=FutureWarning)\n", - "logging.basicConfig(level=logging.WARNING)\n", - "\n", - "# --- Visualization defaults ---\n", - "sns.set_theme(style='whitegrid', font_scale=1.1)\n", - "\n", - "PALETTE_OUTCOME = {\n", - " 'Pass': '#4C72B0',\n", - " 'Distinction': '#55A868',\n", - " 'Fail': '#C44E52',\n", - " 'Withdrawn': '#8172B3',\n", - "}\n", - "LABEL_COMPLETED = 'Completed'\n", - "LABEL_NOT_COMPLETED = 'Not completed'\n", - "PALETTE_BINARY = {1: '#55A868', 0: '#C44E52'}\n", - "LABEL_BINARY = {1: LABEL_COMPLETED, 0: LABEL_NOT_COMPLETED}\n", - "PALETTE_BINARY_LABELS = {LABEL_COMPLETED: '#55A868', LABEL_NOT_COMPLETED: '#C44E52'}\n", - "PALETTE_SEQUENTIAL = 'YlOrRd'\n", - "\n", - "# Shared axis labels — defined as constants to avoid\n", - "# duplicated string literals flagged by static analysis\n", - "LABEL_COMPLETION_RATE = 'Completion rate (%)'\n", - "LABEL_NUM_ENROLLMENTS = 'Number of enrollments'\n", - "LABEL_EFFECT_SIZE = \"Cohen's d\"\n", - "LABEL_CRAMERS_V = \"Cramér's V\"\n", - "\n", - "# Significance threshold — used for annotation and interpretation\n", - "ALPHA = 0.05\n", - "\n", - "FIG_DPI = 150\n", - "FIG_SIZE = (10, 6)\n", - "FIG_SIZE_WIDE = (16, 5)\n", - "\n", - "FIGURES_DIR.mkdir(parents=True, exist_ok=True)\n", - "\n", - "\n", - "def save_fig(fig, name: str) -> None:\n", - " \"\"\"Save figure to reports/figures/ with consistent settings.\"\"\"\n", - " path = FIGURES_DIR / f'{name}.png'\n", - " fig.savefig(path, dpi=FIG_DPI, bbox_inches='tight', facecolor='white')\n", - " print(f' Saved: {path}')\n", - "\n", - "\n", - "# --- Load BQ3 query from SQL file ---\n", - "_bq3_sql_path = QUERIES_DIR / 'q_bq3_demographics_vs_behavior.sql'\n", - "BQ3_SQL = _bq3_sql_path.read_text(encoding='utf-8')\n", - "print(f'Loaded BQ3 query from: {_bq3_sql_path.name} ({len(BQ3_SQL):,} chars)')\n", - "\n", - "# --- Prerequisite check ---\n", - "try:\n", - " _check_student = execute_query('SELECT COUNT(*) AS n FROM v_student_enriched')\n", - " _check_early = execute_query('SELECT COUNT(*) AS n FROM v_engagement_early')\n", - " _n_student = _check_student['n'].iloc[0]\n", - " _n_early = _check_early['n'].iloc[0]\n", - " if _n_student == 0 or _n_early == 0:\n", - " raise RuntimeError('One or more views are empty')\n", - " print('Database OK')\n", - " print(f' v_student_enriched: {_n_student:>12,} rows')\n", - " print(f' v_engagement_early: {_n_early:>12,} rows')\n", - "except Exception as exc:\n", - " raise RuntimeError(\n", - " 'Cannot query analytical views. '\n", - " \"Run 'python -m run_pipeline' first to populate the database.\"\n", - " ) from exc" - ] - }, - { - "cell_type": "markdown", - "id": "5", - "metadata": {}, - "source": [ - "## 2. Il dataset di analisi\n", - "\n", - "La query BQ3 (`q_bq3_demographics_vs_behavior.sql`) esegue il join tra `v_student_enriched`, `v_engagement_early` e i dati della prima valutazione. Ogni riga rappresenta un'iscrizione studente con caratteristiche sia demografiche che comportamentali:\n", - "\n", - "| Tipo di feature | Colonne | Test |\n", - "|-----------------|---------|------|\n", - "| **Demografica (categoriale)** | gender, age_band, highest_education, imd_band, disability, region | Chi-quadrato + V di Cramér |\n", - "| **Demografica (numerica)** | num_of_prev_attempts, studied_credits | T-test + Cohen's d |\n", - "| **Comportamentale (continua)** | active_days_first_28, total_clicks_first_28, avg_clicks_per_active_day, engagement_decile_in_course, first_score | T-test + Cohen's d |\n", - "| **Comportamentale (binaria)** | submitted_first_assessment | T-test + Cohen's d |\n", - "| **Esito** | completed (0/1) | — |\n", - "\n", - "L'aspetto chiave del design: le feature demografiche sono note *prima* dell'inizio del corso. Le feature comportamentali emergono durante i primi 28 giorni. Se il comportamento è un segnale più forte, la piattaforma ha una finestra per intervenire.\n", - "\n", - "**Avvertenze sui dati (feature condizionali):**\n", - "- `engagement_decile_in_course` è NULL per gli studenti senza attività VLE (non hanno una posizione nel ranking di engagement). Gli effect size per questa feature sono condizionali all'avere almeno un minimo di attività sulla piattaforma. Il segnale `active_days_first_28 = 0` cattura già la distinzione \"nessuna attività\".\n", - "- `first_score` è NULL per gli studenti che non hanno consegnato alcuna valutazione precoce. Gli effect size per questa feature sono condizionali alla consegna (vedi avvertenze Parte B)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Load BQ3 analysis dataset ---\n", - "df_bq3 = execute_query(BQ3_SQL)\n", - "\n", - "print(f'BQ3 dataset: {len(df_bq3):,} rows x {df_bq3.shape[1]} columns')\n", - "print('\\nOutcome distribution:')\n", - "print(df_bq3['completed'].value_counts().rename(LABEL_BINARY).to_string())\n", - "\n", - "# --- Feature columns ---\n", - "# Categorical demographics: tested with chi-square (Cramér's V)\n", - "DEMO_CATEGORICAL = [\n", - " 'gender', 'age_band', 'highest_education',\n", - " 'imd_band', 'disability', 'region',\n", - "]\n", - "DEMO_CAT_LABELS = {\n", - " 'gender': 'Gender',\n", - " 'age_band': 'Age band',\n", - " 'highest_education': 'Highest education',\n", - " 'imd_band': 'IMD band',\n", - " 'disability': 'Disability',\n", - " 'region': 'Region',\n", - "}\n", - "\n", - "# Numeric demographics: tested with t-test (Cohen's d)\n", - "DEMO_NUMERIC = ['num_of_prev_attempts', 'studied_credits']\n", - "DEMO_NUM_LABELS = {\n", - " 'num_of_prev_attempts': 'Previous attempts',\n", - " 'studied_credits': 'Studied credits',\n", - "}\n", - "\n", - "# Behavioral features: tested with t-test (Cohen's d)\n", - "BEHAV_COLUMNS = [\n", - " 'active_days_first_28', 'total_clicks_first_28',\n", - " 'avg_clicks_per_active_day', 'engagement_decile_in_course',\n", - " 'submitted_first_assessment', 'first_score',\n", - "]\n", - "BEHAV_LABELS = {\n", - " 'active_days_first_28': 'Active days (first 28)',\n", - " 'total_clicks_first_28': 'Total clicks (first 28)',\n", - " 'avg_clicks_per_active_day': 'Avg clicks per active day',\n", - " # NULL for students with no VLE activity — they have no position\n", - " # in the engagement ranking. Effect size is conditional on having\n", - " # at least some platform activity (dropna excludes inactive students).\n", - " 'engagement_decile_in_course': 'Engagement decile (active only)',\n", - " 'submitted_first_assessment': 'Submitted first assessment',\n", - " # NULL for non-submitters — effect size is conditional on having\n", - " # submitted at least one early assessment.\n", - " 'first_score': 'First score (submitters only)',\n", - "}\n", - "\n", - "# Features measured on the full population (all enrollments).\n", - "# engagement_decile and first_score are conditional (different population)\n", - "# and excluded from the head-to-head comparison in Part C.\n", - "CONDITIONAL_FEATURES = {'engagement_decile_in_course', 'first_score'}\n", - "\n", - "# --- Missingness check ---\n", - "# imd_band may have NULLs from the source data;\n", - "# engagement_decile_in_course is NULL for students with no VLE activity;\n", - "# first_score has NULLs for students who did not submit any early assessment.\n", - "ALL_LABELS = {**DEMO_CAT_LABELS, **DEMO_NUM_LABELS, **BEHAV_LABELS}\n", - "print('\\n=== Missingness Check ===')\n", - "for col in DEMO_CATEGORICAL + DEMO_NUMERIC + BEHAV_COLUMNS:\n", - " n_valid = df_bq3[col].notna().sum()\n", - " n_missing = df_bq3[col].isna().sum()\n", - " pct_missing = 100 * n_missing / len(df_bq3)\n", - " print(f' {ALL_LABELS[col]:35s} {n_valid:>8,} valid {n_missing:>6,} missing ({pct_missing:.1f}%)')" - ] - }, - { - "cell_type": "markdown", - "id": "7", - "metadata": {}, - "source": [ - "## 3. Parte A — Associazioni demografiche\n", - "\n", - "Testiamo ciascuna feature demografica per l'associazione con il completamento usando il test statistico appropriato:\n", - "\n", - "- **Feature categoriali** (gender, age_band, highest_education, imd_band, disability, region): **test chi-quadrato di indipendenza** con **V di Cramér** come effect size. V varia da 0 (nessuna associazione) a 1 (associazione perfetta). Soglie convenzionali: piccolo ≈ 0.1, medio ≈ 0.3, grande ≈ 0.5.\n", - "\n", - "- **Feature numeriche** (num_of_prev_attempts, studied_credits): **t-test di Welch** con **Cohen's d** come effect size. Soglie convenzionali: piccolo ≈ 0.2, medio ≈ 0.5, grande ≈ 0.8.\n", - "\n", - "Tutti i p-value sono corretti per confronti multipli usando la procedura Benjamini-Hochberg (BH) su tutti gli 8 test demografici simultaneamente." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Chi-square tests on categorical demographics ---\n", - "# For each categorical demographic, we build a contingency table\n", - "# (categories × completed) and run a chi-square test of independence.\n", - "# Cramér's V quantifies the strength of association.\n", - "demo_chi_results = []\n", - "for col in DEMO_CATEGORICAL:\n", - " # Drop NULLs for this specific variable (e.g. imd_band has missing values)\n", - " valid = df_bq3[[col, 'completed']].dropna()\n", - " contingency = pd.crosstab(valid[col], valid['completed'])\n", - " result = chi_square_test(contingency, variable_name=col)\n", - " demo_chi_results.append({\n", - " 'feature': col,\n", - " 'label': DEMO_CAT_LABELS[col],\n", - " 'test': 'chi-square',\n", - " 'statistic': round(result.statistic, 2),\n", - " 'p_value': result.p_value,\n", - " 'effect_size': round(result.effect_size, 4),\n", - " 'effect_metric': LABEL_CRAMERS_V,\n", - " 'n': result.n_group1,\n", - " 'n_categories': contingency.shape[0],\n", - " })\n", - "\n", - "df_demo_chi = pd.DataFrame(demo_chi_results)\n", - "\n", - "# --- T-tests on numeric demographics ---\n", - "# num_of_prev_attempts and studied_credits are numeric,\n", - "# so we use t-test + Cohen's d for these two.\n", - "completed = df_bq3[df_bq3['completed'] == 1]\n", - "not_completed = df_bq3[df_bq3['completed'] == 0]\n", - "\n", - "demo_ttest_results = []\n", - "for col in DEMO_NUMERIC:\n", - " result = independent_t_test(\n", - " completed[col].dropna(),\n", - " not_completed[col].dropna(),\n", - " variable_name=col,\n", - " )\n", - " demo_ttest_results.append({\n", - " 'feature': col,\n", - " 'label': DEMO_NUM_LABELS[col],\n", - " 'test': 't-test',\n", - " 'statistic': round(result.statistic, 3),\n", - " 'p_value': result.p_value,\n", - " 'effect_size': round(abs(result.effect_size), 4),\n", - " 'cohens_d': round(result.effect_size, 4),\n", - " 'effect_metric': LABEL_EFFECT_SIZE,\n", - " 'n': result.n_group1 + result.n_group2,\n", - " })\n", - "\n", - "df_demo_ttest = pd.DataFrame(demo_ttest_results)\n", - "\n", - "# --- Combined multiple comparison correction ---\n", - "# 8 demographic tests total: correct all p-values together with BH\n", - "all_demo_p = df_demo_chi['p_value'].tolist() + df_demo_ttest['p_value'].tolist()\n", - "all_demo_p_bh = apply_multiple_comparison_correction(all_demo_p, 'benjamini-hochberg')\n", - "\n", - "df_demo_chi['p_bh'] = all_demo_p_bh[:len(DEMO_CATEGORICAL)]\n", - "df_demo_chi['significant'] = df_demo_chi['p_bh'] < ALPHA\n", - "df_demo_ttest['p_bh'] = all_demo_p_bh[len(DEMO_CATEGORICAL):]\n", - "df_demo_ttest['significant'] = df_demo_ttest['p_bh'] < ALPHA\n", - "\n", - "# Sort by effect size (strongest association first)\n", - "df_demo_chi = df_demo_chi.sort_values('effect_size', ascending=False).reset_index(drop=True)\n", - "df_demo_ttest = df_demo_ttest.sort_values('effect_size', ascending=False).reset_index(drop=True)\n", - "\n", - "print('=== Chi-Square Results: Categorical Demographics ===\\n')\n", - "print(df_demo_chi[['label', 'n_categories', 'statistic', 'p_value',\n", - " 'effect_size', 'p_bh', 'significant']].to_string(index=False))\n", - "print(f'\\nSignificant (BH): {df_demo_chi[\"significant\"].sum()}/{len(DEMO_CATEGORICAL)}')\n", - "\n", - "print('\\n=== T-Test Results: Numeric Demographics ===\\n')\n", - "print(df_demo_ttest[['label', 'statistic', 'p_value', 'cohens_d',\n", - " 'effect_size', 'p_bh', 'significant']].to_string(index=False))" - ] - }, - { - "cell_type": "markdown", - "id": "9", - "metadata": {}, - "source": [ - "> **Interpretazione:** La maggior parte delle feature demografiche mostra associazioni statisticamente significative con il completamento — ma la significatività da sola non è il punto. Con ~32K iscrizioni, anche differenze banali raggiungono la significatività. La domanda critica è l'**effect size**: queste associazioni sono *significative nella pratica*?\n", - ">\n", - "> Osserviamo i valori della V di Cramér. Secondo le convenzioni di Cohen, valori sotto 0.1 rappresentano associazioni trascurabili. Il fatto che le feature demografiche abbiano valori V piccoli suggerisce che sono predittori deboli del completamento — ci dicono *qualcosa*, ma non molto." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "10", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Completion rate by category for each demographic variable ---\n", - "# Faceted bar chart (2x3 grid): one subplot per categorical demographic.\n", - "# Bars are sorted by completion rate to highlight which categories do best/worst.\n", - "fig, axes = plt.subplots(2, 3, figsize=(18, 10))\n", - "axes_flat = axes.flatten()\n", - "\n", - "for i, col in enumerate(DEMO_CATEGORICAL):\n", - " ax = axes_flat[i]\n", - " label = DEMO_CAT_LABELS[col]\n", - "\n", - " # Compute completion rate per category, dropping NULLs\n", - " valid = df_bq3[[col, 'completed']].dropna()\n", - " rates = (\n", - " valid.groupby(col)\n", - " .agg(n=('completed', 'count'), rate=('completed', 'mean'))\n", - " .reset_index()\n", - " .sort_values('rate', ascending=False)\n", - " )\n", - " rates['rate_pct'] = (rates['rate'] * 100).round(1)\n", - "\n", - " # Color gradient: green (high completion) → red (low completion).\n", - " # Bars are sorted descending, so j=0 is the highest rate.\n", - " # RdYlGn_r maps 0→green and 1→red, matching the sort order.\n", - " n_cats = len(rates)\n", - " gradient = [plt.cm.RdYlGn_r(j / max(n_cats - 1, 1)) for j in range(n_cats)]\n", - "\n", - " bars = ax.bar(range(n_cats), rates['rate_pct'], color=gradient, edgecolor='white')\n", - " ax.set_xticks(range(n_cats))\n", - " # Shorten long labels for readability\n", - " x_labels = [str(v)[:18] for v in rates[col]]\n", - " ax.set_xticklabels(x_labels, rotation=45, ha='right', fontsize=7)\n", - "\n", - " # Annotate each bar with the completion rate\n", - " for bar, (_, row) in zip(bars, rates.iterrows()):\n", - " ax.text(\n", - " bar.get_x() + bar.get_width() / 2,\n", - " bar.get_height() + 1,\n", - " f'{row[\"rate_pct\"]:.0f}%',\n", - " ha='center', fontsize=7, color='#333333',\n", - " )\n", - "\n", - " # Show Cramér's V in the subtitle for immediate context\n", - " v_val = df_demo_chi[df_demo_chi['feature'] == col]['effect_size'].values[0]\n", - " ax.set_ylabel(LABEL_COMPLETION_RATE)\n", - " ax.set_title(f'{label} (V = {v_val:.3f})')\n", - " ax.set_ylim(0, 100)\n", - " sns.despine(ax=ax)\n", - "\n", - "fig.suptitle(\n", - " 'Completion Rate by Demographic Category\\n'\n", - " '(sorted by rate within each variable)',\n", - " fontsize=14, y=1.02,\n", - ")\n", - "fig.tight_layout()\n", - "save_fig(fig, '05_demographic_completion_rates')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "11", - "metadata": {}, - "source": [ - "> **Impressione visiva:** Sebbene i tassi di completamento varino tra le categorie demografiche — specialmente per livello di istruzione e fascia IMD — le differenze sono modeste. Nessun singolo gruppo demografico ha un tasso di completamento vicino allo zero o al 100%. I dati demografici spostano leggermente la probabilità, ma non determinano l'esito.\n", - ">\n", - "> Questo contrasta nettamente con i segnali comportamentali del NB04, dove i ghost student avevano tassi di completamento prossimi allo zero — una separazione molto più netta." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "12", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Demographic effect sizes bar chart ---\n", - "# Combine Cramér's V (categorical) and |Cohen's d| (numeric) in one plot.\n", - "# Both measure association strength, though on different scales — the\n", - "# metric type is noted in each bar's annotation for transparency.\n", - "df_demo_effects = pd.concat([\n", - " df_demo_chi[['label', 'effect_size', 'effect_metric', 'significant']],\n", - " df_demo_ttest[['label', 'effect_size', 'effect_metric', 'significant']],\n", - "]).sort_values('effect_size', ascending=True).reset_index(drop=True)\n", - "\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "\n", - "y_pos = np.arange(len(df_demo_effects))\n", - "colors = ['#4C72B0' if sig else '#CCCCCC' for sig in df_demo_effects['significant']]\n", - "\n", - "ax.barh(y_pos, df_demo_effects['effect_size'], color=colors, edgecolor='white')\n", - "\n", - "# Annotate each bar with value and metric type\n", - "for i, (_, row) in enumerate(df_demo_effects.iterrows()):\n", - " ax.text(\n", - " row['effect_size'] + 0.003, i,\n", - " f\"{row['effect_size']:.4f} ({row['effect_metric']})\",\n", - " va='center', fontsize=9, color='#333333',\n", - " )\n", - "\n", - "# Reference lines for both metrics:\n", - "# - Cramér's V small threshold = 0.1\n", - "# - Cohen's d small threshold = 0.2\n", - "ax.axvline(x=0.1, color='gray', linestyle=':', linewidth=0.8, alpha=0.6)\n", - "ax.text(0.1, len(df_demo_effects) - 0.3, 'Small (V)',\n", - " ha='center', fontsize=8, color='gray')\n", - "ax.axvline(x=0.2, color='gray', linestyle='--', linewidth=0.8, alpha=0.6)\n", - "ax.text(0.2, len(df_demo_effects) - 0.3, 'Small (d)',\n", - " ha='center', fontsize=8, color='gray')\n", - "\n", - "ax.set_yticks(y_pos)\n", - "ax.set_yticklabels(df_demo_effects['label'])\n", - "ax.set_xlabel('Effect Size')\n", - "ax.set_title('Demographic Features: Association with Completion\\n'\n", - " '(blue = significant after BH correction)')\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '05_demographic_effect_sizes')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "13", - "metadata": {}, - "source": [ - "> **Riepilogo Parte A:** Tutte e 8 le feature demografiche mostrano associazioni statisticamente significative con il completamento dopo correzione per confronti multipli. Tuttavia, gli effect size sono uniformemente **piccoli**: i valori della V di Cramér sono sotto 0.15 e i valori |Cohen's d| per le feature demografiche numeriche sono sotto 0.2. I dati demografici ci dicono *chi ha una probabilità leggermente maggiore* di completare, ma mancano del potere discriminante per identificare con fiducia gli studenti a rischio." - ] - }, - { - "cell_type": "markdown", - "id": "14", - "metadata": {}, - "source": [ - "## 4. Parte B — Associazioni comportamentali\n", - "\n", - "Testiamo ora le 6 feature comportamentali (metriche di engagement precoce) per l'associazione con il completamento usando il **t-test di Welch** con **Cohen's d** come effect size. Sono le stesse metriche classificate nel NB04, ma qui le inquadriamo come controparte dei dati demografici per un confronto diretto.\n", - "\n", - "Nota: `submitted_first_assessment` è binaria (0/1). Manteniamo lo stesso framework del t-test qui per riportare il Cohen's d in modo coerente tra le feature comportamentali, non per affermare equivalenza con il p-value di un test chi-quadrato 2×2.\n", - "\n", - "Tutti i p-value sono corretti usando la procedura Benjamini-Hochberg su tutti i 6 test comportamentali." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "15", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Part B: T-tests on behavioral features ---\n", - "# All 6 behavioral features are tested with Welch's t-test.\n", - "# submitted_first_assessment is binary (0/1) but t-test is valid\n", - "# and produces Cohen's d for direct comparison with other features.\n", - "#\n", - "# Two features have conditional populations (dropna excludes NULLs):\n", - "# - engagement_decile_in_course: NULL for students with no VLE activity\n", - "# (effect size measures \"among active students, does rank matter?\")\n", - "# - first_score: NULL for non-submitters\n", - "# (effect size measures \"among submitters, does score matter?\")\n", - "# Both are tested and reported, but excluded from the head-to-head\n", - "# comparison in Part C (different population base).\n", - "behav_results = []\n", - "for col in BEHAV_COLUMNS:\n", - " result = independent_t_test(\n", - " completed[col].dropna(),\n", - " not_completed[col].dropna(),\n", - " variable_name=col,\n", - " )\n", - " behav_results.append({\n", - " 'feature': col,\n", - " 'label': BEHAV_LABELS[col],\n", - " 'test': 't-test',\n", - " 'statistic': round(result.statistic, 3),\n", - " 'p_value': result.p_value,\n", - " 'cohens_d': round(result.effect_size, 4),\n", - " 'abs_cohens_d': round(abs(result.effect_size), 4),\n", - " 'ci_lower': round(result.ci_lower, 3),\n", - " 'ci_upper': round(result.ci_upper, 3),\n", - " 'n_completed': result.n_group1,\n", - " 'n_not_completed': result.n_group2,\n", - " })\n", - "\n", - "df_behav = pd.DataFrame(behav_results)\n", - "\n", - "# --- Multiple comparison correction (6 behavioral tests) ---\n", - "raw_p_behav = df_behav['p_value'].tolist()\n", - "df_behav['p_bh'] = apply_multiple_comparison_correction(raw_p_behav, 'benjamini-hochberg')\n", - "df_behav['sig_bh'] = df_behav['p_bh'] < ALPHA\n", - "\n", - "# Sort by absolute effect size (strongest first)\n", - "df_behav = df_behav.sort_values('abs_cohens_d', ascending=False).reset_index(drop=True)\n", - "\n", - "print('=== T-Test Results: Behavioral Features ===\\n')\n", - "print(df_behav[['label', 'statistic', 'p_value', 'cohens_d', 'abs_cohens_d',\n", - " 'p_bh', 'sig_bh', 'n_completed', 'n_not_completed']].to_string(index=False))\n", - "print(f'\\nSignificant (BH): {df_behav[\"sig_bh\"].sum()}/{len(BEHAV_COLUMNS)}')" - ] - }, - { - "cell_type": "markdown", - "id": "16", - "metadata": {}, - "source": [ - "> **Interpretazione:** Tutte e 6 le feature comportamentali mostrano associazioni statisticamente significative con il completamento. Più importante, gli **effect size sono sostanzialmente più grandi** di quelli demografici. Diverse feature comportamentali raggiungono effect size medi (|d| > 0.4), rispetto agli effetti demografici piccoli (|d| < 0.2, V < 0.15).\n", - ">\n", - "> I segnali comportamentali più forti — volume di engagement, frequenza di attività e consegna della prima valutazione — forniscono informazioni molto più discriminanti sul completamento finale rispetto a qualsiasi variabile demografica.\n", - ">\n", - "> **Avvertenze sulle feature condizionali:** Due feature sono testate su popolazioni ridotte (vedi dimensioni del campione nella tabella):\n", - "> - `engagement_decile` — esclude gli studenti senza attività VLE (NULL nei dati). L'effect size misura l'engagement relativo *tra gli studenti attivi*.\n", - "> - `first_score` — esclude chi non ha consegnato. L'effect size misura le differenze di punteggio *tra chi ha consegnato*.\n", - ">\n", - "> Entrambe sono escluse dal confronto diretto nella Parte C per garantire un confronto equo sulla stessa popolazione." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "17", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Effect sizes: behavioral features (Cohen's d) ---\n", - "df_behav_plot = df_behav.sort_values('abs_cohens_d', ascending=True).reset_index(drop=True)\n", - "\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "\n", - "y_pos = np.arange(len(df_behav_plot))\n", - "colors = ['#55A868' if sig else '#CCCCCC' for sig in df_behav_plot['sig_bh']]\n", - "\n", - "ax.barh(y_pos, df_behav_plot['abs_cohens_d'], color=colors, edgecolor='white')\n", - "\n", - "# Annotate each bar with effect size value\n", - "for i, (_, row) in enumerate(df_behav_plot.iterrows()):\n", - " ax.text(\n", - " row['abs_cohens_d'] + 0.01, i,\n", - " f\"|d| = {row['abs_cohens_d']:.3f}\",\n", - " va='center', fontsize=9, color='#333333',\n", - " )\n", - "\n", - "# Reference lines for Cohen's d thresholds\n", - "for d_ref, ref_label in [(0.2, 'Small'), (0.5, 'Medium'), (0.8, 'Large')]:\n", - " ax.axvline(x=d_ref, color='gray', linestyle=':', linewidth=0.8, alpha=0.6)\n", - " ax.text(d_ref, len(df_behav_plot) - 0.3, ref_label,\n", - " ha='center', fontsize=8, color='gray')\n", - "\n", - "ax.set_yticks(y_pos)\n", - "ax.set_yticklabels(df_behav_plot['label'])\n", - "ax.set_xlabel(f'|{LABEL_EFFECT_SIZE}|')\n", - "ax.set_title('Behavioral Features: Effect Size on Completion\\n'\n", - " '(green = significant after BH correction)')\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '05_behavior_effect_sizes')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "18", - "metadata": {}, - "source": [ - "> **Riepilogo Parte B:** Le feature comportamentali mostrano costantemente effect size medi (|d| ≈ 0.3–0.6), con i segnali più forti provenienti dalle metriche di volume di engagement e dalla consegna della prima valutazione. Questi effetti sono 2–5× più grandi degli effetti demografici nella Parte A." - ] - }, - { - "cell_type": "markdown", - "id": "19", - "metadata": {}, - "source": [ - "## 5. Parte C — Il verdetto: dati demografici vs comportamento\n", - "\n", - "Questa è l'analisi centrale di BQ3: un confronto diretto tra effect size.\n", - "\n", - "**Nota metodologica:** La V di Cramér e il Cohen's d sono misurati su scale diverse, quindi il confronto numerico diretto richiede cautela. Tuttavia, entrambi hanno soglie consolidate per effetti \"piccoli\", \"medi\" e \"grandi\", e l'**ordinamento relativo all'interno e tra i gruppi** racconta una storia chiara. Inoltre, i dati demografici numerici (tentativi precedenti, crediti studiati) usano il Cohen's d — la stessa metrica delle feature comportamentali — consentendo un confronto diretto.\n", - "\n", - "La figura sottostante mostra le feature sull'intera popolazione in una vista unificata:\n", - "- **Pannello sinistro**: |Cohen's d| per tutte le feature continue sull'intera popolazione (2 demografiche + 4 comportamentali), colorate per tipo. Le feature condizionali (decile di engagement, primo punteggio) sono escluse perché misurate su popolazioni diverse — i loro valori sono riportati separatamente nel riepilogo quantitativo.\n", - "- **Pannello destro**: V di Cramér per i dati demografici categoriali, per contesto" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "20", - "metadata": {}, - "outputs": [], - "source": [ - "# --- BQ3 Key Figure: Demographics vs Behavior effect size comparison ---\n", - "# Left panel: |Cohen's d| for all continuous features (demographic + behavioral)\n", - "# Right panel: Cramér's V for categorical demographics\n", - "#\n", - "# Conditional features (engagement_decile, first_score) are excluded from the\n", - "# left panel because their effect sizes are measured on different populations.\n", - "# They are reported separately in the quantitative summary below.\n", - "fig, (ax1, ax2) = plt.subplots(\n", - " 1, 2, figsize=(16, 7),\n", - " gridspec_kw={'width_ratios': [3, 2]},\n", - ")\n", - "\n", - "# --- Left panel: Cohen's d for continuous features ---\n", - "# Combine numeric demographics and behavioral features on the same scale.\n", - "# Color by feature type only to avoid mixing significance flags derived from\n", - "# different multiple-testing correction families in one shared visual encoding.\n", - "# Exclude conditional features (different population base).\n", - "df_behav_comparable = df_behav[~df_behav['feature'].isin(CONDITIONAL_FEATURES)]\n", - "\n", - "df_d_compare = pd.concat([\n", - " df_demo_ttest[['label', 'effect_size']].assign(\n", - " feature_type='Demographic'\n", - " ),\n", - " df_behav_comparable[['label', 'abs_cohens_d']].rename(\n", - " columns={'abs_cohens_d': 'effect_size'}\n", - " ).assign(feature_type='Behavioral'),\n", - "]).sort_values('effect_size', ascending=True).reset_index(drop=True)\n", - "\n", - "y_pos_left = np.arange(len(df_d_compare))\n", - "palette_type = {'Demographic': '#4C72B0', 'Behavioral': '#55A868'}\n", - "colors_left = [palette_type[t] for t in df_d_compare['feature_type']]\n", - "\n", - "ax1.barh(y_pos_left, df_d_compare['effect_size'], color=colors_left, edgecolor='white')\n", - "\n", - "for i, (_, row) in enumerate(df_d_compare.iterrows()):\n", - " ax1.text(\n", - " row['effect_size'] + 0.01, i,\n", - " f\"|d| = {row['effect_size']:.3f}\",\n", - " va='center', fontsize=9, color='#333333',\n", - " )\n", - "\n", - "# Reference lines for Cohen's d\n", - "for d_ref, ref_label in [(0.2, 'Small'), (0.5, 'Medium')]:\n", - " ax1.axvline(x=d_ref, color='gray', linestyle=':', linewidth=0.8, alpha=0.6)\n", - " ax1.text(d_ref, len(df_d_compare) - 0.3, ref_label,\n", - " ha='center', fontsize=8, color='gray')\n", - "\n", - "ax1.set_yticks(y_pos_left)\n", - "ax1.set_yticklabels(df_d_compare['label'])\n", - "ax1.set_xlabel(f'|{LABEL_EFFECT_SIZE}|')\n", - "ax1.set_title(f'Continuous Features: |{LABEL_EFFECT_SIZE}|\\n'\n", - " '(blue = demographic, green = behavioral)')\n", - "sns.despine(ax=ax1)\n", - "\n", - "# --- Right panel: Cramér's V for categorical demographics ---\n", - "df_chi_plot = df_demo_chi.sort_values('effect_size', ascending=True).reset_index(drop=True)\n", - "y_pos_right = np.arange(len(df_chi_plot))\n", - "colors_right = ['#4C72B0' if sig else '#CCCCCC' for sig in df_chi_plot['significant']]\n", - "\n", - "ax2.barh(y_pos_right, df_chi_plot['effect_size'], color=colors_right, edgecolor='white')\n", - "\n", - "for i, (_, row) in enumerate(df_chi_plot.iterrows()):\n", - " ax2.text(\n", - " row['effect_size'] + 0.002, i,\n", - " f\"V = {row['effect_size']:.4f}\",\n", - " va='center', fontsize=9, color='#333333',\n", - " )\n", - "\n", - "ax2.axvline(x=0.1, color='gray', linestyle=':', linewidth=0.8, alpha=0.6)\n", - "ax2.text(0.1, len(df_chi_plot) - 0.3, 'Small',\n", - " ha='center', fontsize=8, color='gray')\n", - "\n", - "ax2.set_yticks(y_pos_right)\n", - "ax2.set_yticklabels(df_chi_plot['label'])\n", - "ax2.set_xlabel(LABEL_CRAMERS_V)\n", - "ax2.set_title(f'Categorical Demographics: {LABEL_CRAMERS_V}\\n(blue = significant)')\n", - "sns.despine(ax=ax2)\n", - "\n", - "fig.suptitle(\n", - " 'BQ3: Demographics vs Behavior — Effect Size Comparison\\n'\n", - " '(all-enrollment features only; conditional features reported below)',\n", - " fontsize=14, y=1.02,\n", - ")\n", - "fig.tight_layout()\n", - "save_fig(fig, '05_demographics_vs_behavior_comparison')\n", - "plt.show()\n", - "\n", - "# --- Quantitative summary ---\n", - "# Compare d-vs-d only on comparable samples (all-enrollment metrics).\n", - "# Conditional features (engagement_decile, first_score) are excluded from\n", - "# the main summary and reported separately to avoid skewing the mean/ratio.\n", - "# Cramér's V reported separately — different scale.\n", - "avg_demo_d = df_demo_ttest['effect_size'].mean()\n", - "avg_behav_d = df_behav_comparable['abs_cohens_d'].mean()\n", - "max_demo_d = df_demo_ttest['effect_size'].max()\n", - "min_behav_d = df_behav_comparable['abs_cohens_d'].min()\n", - "\n", - "avg_demo_v = df_demo_chi['effect_size'].mean()\n", - "max_demo_v = df_demo_chi['effect_size'].max()\n", - "\n", - "df_conditional = df_behav[df_behav['feature'].isin(CONDITIONAL_FEATURES)]\n", - "\n", - "print('\\n=== Quantitative Comparison (Cohen\\'s d, all-enrollment metrics) ===')\n", - "print(f' Demographic |d| (mean): {avg_demo_d:.4f}')\n", - "print(f' Behavioral |d| (mean): {avg_behav_d:.4f}')\n", - "if avg_demo_d > 0:\n", - " print(f' Ratio (behavioral / demographic): {avg_behav_d / avg_demo_d:.1f}x')\n", - "print(f'\\n Largest demographic |d|: {max_demo_d:.4f}')\n", - "print(f' Smallest behavioral |d|: {min_behav_d:.4f}')\n", - "print('\\n=== Conditional Features (different population base) ===')\n", - "for _, row in df_conditional.iterrows():\n", - " print(f' {row[\"label\"]:35s} |d| = {row[\"abs_cohens_d\"]:.4f}')\n", - "print('\\n=== Cramér\\'s V (categorical demographics) ===')\n", - "print(f' Mean V: {avg_demo_v:.4f}')\n", - "print(f' Max V: {max_demo_v:.4f}')" - ] - }, - { - "cell_type": "markdown", - "id": "21", - "metadata": {}, - "source": [ - "> **Il verdetto: il comportamento vince, in modo decisivo.**\n", - ">\n", - "> Il confronto rivela un pattern chiaro:\n", - "> - Le **feature comportamentali** (verdi) hanno effect size 2–5× più grandi delle **feature demografiche** (blu)\n", - "> - Anche il segnale comportamentale *più debole* è paragonabile o più forte del segnale demografico *più forte*\n", - "> - I dati demografici categoriali (V di Cramér) mostrano associazioni uniformemente piccole (V < 0.15)\n", - ">\n", - "> **Cosa significa per un operatore di piattaforma:** Il profiling demografico ha un valore predittivo limitato. Non si possono identificare in modo significativo gli studenti a rischio basandosi solo sulla loro età, genere o livello di istruzione. Ma monitorare il loro comportamento nei primi 28 giorni fornisce segnali di early warning azionabili.\n", - ">\n", - "> Questa è una buona notizia: *i dati demografici non possono essere cambiati, ma il comportamento può essere influenzato attraverso il design e gli interventi.*" - ] - }, - { - "cell_type": "markdown", - "id": "22", - "metadata": {}, - "source": [ - "## 6. Approfondimento — Livello di istruzione × Engagement\n", - "\n", - "Sebbene i dati demografici siano predittori deboli complessivamente, il livello di istruzione (`highest_education`) è tipicamente il segnale demografico più forte. La domanda critica è: il livello di istruzione *conta ancora* una volta che teniamo conto dell'engagement?\n", - "\n", - "Creiamo un grafico di interazione: per ogni livello di istruzione, confrontiamo i tassi di completamento tra studenti ad alto engagement e basso engagement (suddivisi alla mediana di `active_days_first_28`). Se l'engagement domina, il gap *all'interno* di ogni livello di istruzione dovrebbe essere maggiore del gap *tra* livelli di istruzione allo stesso livello di engagement." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "23", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Deep dive: education level × engagement interaction ---\n", - "# Does engagement matter WITHIN each education level?\n", - "# If yes, this confirms that behavior (actionable) > demographics (fixed).\n", - "median_active = df_bq3['active_days_first_28'].median()\n", - "LABEL_HIGH_ENG = 'High engagement'\n", - "LABEL_LOW_ENG = 'Low engagement'\n", - "\n", - "df_deep = df_bq3[['highest_education', 'active_days_first_28', 'completed']].dropna().copy()\n", - "df_deep['engagement'] = df_deep['active_days_first_28'].apply(\n", - " lambda x: LABEL_HIGH_ENG if x >= median_active else LABEL_LOW_ENG\n", - ")\n", - "\n", - "interaction = (\n", - " df_deep.groupby(['highest_education', 'engagement'])\n", - " .agg(n=('completed', 'count'), rate=('completed', 'mean'))\n", - " .reset_index()\n", - ")\n", - "interaction['rate_pct'] = (interaction['rate'] * 100).round(1)\n", - "\n", - "# Sort education levels by overall completion rate for readability\n", - "edu_order = (\n", - " interaction.groupby('highest_education')['rate_pct']\n", - " .mean()\n", - " .sort_values(ascending=False)\n", - " .index.tolist()\n", - ")\n", - "\n", - "fig, ax = plt.subplots(figsize=(12, 6))\n", - "sns.barplot(\n", - " data=interaction, x='highest_education', y='rate_pct',\n", - " hue='engagement',\n", - " palette={LABEL_HIGH_ENG: '#55A868', LABEL_LOW_ENG: '#C44E52'},\n", - " order=edu_order, ax=ax, edgecolor='white',\n", - ")\n", - "\n", - "# Annotate bars with completion rate\n", - "for container in ax.containers:\n", - " for bar in container:\n", - " height = bar.get_height()\n", - " if height > 0:\n", - " ax.text(\n", - " bar.get_x() + bar.get_width() / 2, height + 1,\n", - " f'{height:.0f}%', ha='center', fontsize=8, color='#333333',\n", - " )\n", - "\n", - "ax.set_xlabel('Highest Education Level')\n", - "ax.set_ylabel(LABEL_COMPLETION_RATE)\n", - "ax.set_title(\n", - " 'Completion Rate: Education Level × Engagement\\n'\n", - " f'(split at median = {median_active:.0f} active days in first 28 days)'\n", - ")\n", - "ax.set_ylim(0, 100)\n", - "ax.legend(title='Engagement')\n", - "plt.xticks(rotation=30, ha='right')\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '05_education_engagement_interaction')\n", - "plt.show()\n", - "\n", - "# --- Quantify the within-education engagement gaps ---\n", - "print('\\n=== Engagement Gap Within Each Education Level ===\\n')\n", - "for edu in edu_order:\n", - " high = interaction[(interaction['highest_education'] == edu) &\n", - " (interaction['engagement'] == LABEL_HIGH_ENG)]\n", - " low = interaction[(interaction['highest_education'] == edu) &\n", - " (interaction['engagement'] == LABEL_LOW_ENG)]\n", - " if len(high) > 0 and len(low) > 0:\n", - " gap = high['rate_pct'].values[0] - low['rate_pct'].values[0]\n", - " print(f' {edu:30s} high={high[\"rate_pct\"].values[0]:5.1f}% '\n", - " f'low={low[\"rate_pct\"].values[0]:5.1f}% gap={gap:+.1f}pp')" - ] - }, - { - "cell_type": "markdown", - "id": "24", - "metadata": {}, - "source": [ - "> **Risultato chiave:** All'interno di ogni livello di istruzione, gli studenti ad alto engagement superano drasticamente quelli a basso engagement. Il gap all'interno del livello di istruzione (effetto engagement) è costantemente **maggiore** del gap tra livelli (effetto istruzione allo stesso livello di engagement).\n", - ">\n", - "> Questo significa che uno studente con istruzione formale inferiore ma alto engagement ha una probabilità *migliore* di completare rispetto a uno studente altamente istruito che non interagisce con la piattaforma. L'engagement è il fattore dominante — il livello di istruzione sposta solo la baseline." - ] - }, - { - "cell_type": "markdown", - "id": "25", - "metadata": {}, - "source": [ - "## 7. Inquadramento etico\n", - "\n", - "Il risultato di BQ3 — che **il comportamento predice l'esito in modo più forte dei dati demografici** — ha implicazioni importanti per come una piattaforma di apprendimento dovrebbe essere progettata e gestita:\n", - "\n", - "**1. Gli interventi dovrebbero mirare al comportamento, non ai dati demografici.**\n", - "Inviare messaggi di supporto aggiuntivi solo agli studenti con background educativo inferiore sarebbe sia meno efficace che potenzialmente discriminatorio. Invece, monitorare le metriche di engagement e intervenire quando l'attività cala offre un approccio più equo e più efficace.\n", - "\n", - "**2. I dati demografici non sono destino.**\n", - "L'analisi dell'interazione (Sezione 6) mostra che un alto engagement può compensare lo svantaggio demografico. Questo valida un approccio di growth mindset alla retention: il compito della piattaforma è attivare e mantenere l'engagement, non prevedere il fallimento basandosi su chi sono gli studenti.\n", - "\n", - "**3. Evitare il profiling demografico per lo scoring del rischio.**\n", - "Anche se i dati demografici mostrano associazioni statisticamente significative, i loro effect size deboli li rendono predittori scadenti a livello individuale. Usare feature demografiche per la segnalazione automatica del rischio produrrebbe molti falsi positivi e falsi negativi, sollevando al contempo questioni di equità.\n", - "\n", - "**Il punto centrale:** investire nell'infrastruttura di engagement (nudge di attività, promemoria per le valutazioni precoci, dashboard di progresso), non nel targeting demografico." - ] - }, - { - "cell_type": "markdown", - "id": "26", - "metadata": {}, - "source": [ - "## 8. Conclusioni chiave e prossimi passi\n", - "\n", - "### Cosa abbiamo imparato\n", - "\n", - "1. **Tutte le feature demografiche mostrano associazioni statisticamente significative ma deboli** con il completamento. I valori più alti della V di Cramér sono sotto 0.15, e i valori Cohen's d per i dati demografici numerici sono sotto 0.2. Con ~32K iscrizioni, la significatività è facile da raggiungere — l'effect size è ciò che conta.\n", - "\n", - "2. **Le feature comportamentali hanno effect size 2–5× più grandi** delle feature demografiche. Volume di engagement, frequenza di attività e consegna della prima valutazione sono molto più informative sul completamento finale.\n", - "\n", - "3. **All'interno di ogni livello di istruzione, l'engagement è il fattore decisivo.** Gli studenti ad alto engagement superano quelli a basso engagement indipendentemente dal loro background educativo. Il gap comportamentale all'interno del gruppo supera il gap demografico tra i gruppi.\n", - "\n", - "4. **I dati demografici non possono essere cambiati; il comportamento può essere influenzato.** Questo rende i segnali comportamentali non solo predittori *più forti* ma anche *azionabili* — la piattaforma può influenzare l'engagement attraverso il design e gli interventi.\n", - "\n", - "5. **Allineamento etico:** Usare segnali comportamentali per l'identificazione del rischio evita le preoccupazioni di equità insite nel profiling demografico, fornendo al contempo una migliore accuratezza predittiva.\n", - "\n", - "6. **I risultati del chi-quadrato aggiungono sfumatura:** Il livello di istruzione e la fascia IMD (status socioeconomico) mostrano le associazioni demografiche più forti, suggerendo che le strutture di supporto esterne alla piattaforma (finanziarie, istituzionali) possono giocare un ruolo di sfondo.\n", - "\n", - "### Cosa viene dopo\n", - "\n", - "| Notebook | Business Question | Focus |\n", - "|----------|------------------|-------|\n", - "| **06** | BQ4 | Come influiscono le caratteristiche del corso sulla retention? |\n", - "| **07** | BQ5 | Le 3 principali raccomandazioni operative |\n", - "\n", - "---\n", - "\n", - "**Riproducibilità:** Tutte le figure sono salvate in `reports/figures/`. Per riprodurre questo notebook, eseguire prima `python -m run_pipeline`, poi eseguire tutte le celle in ordine." - ] - }, - { - "cell_type": "markdown", - "id": "27", - "metadata": {}, - "source": [ - "> **Dai predittori al design:** Questo notebook ha stabilito che *cosa fanno gli studenti* conta più di *chi sono*. La prossima domanda sposta il focus dalle feature a livello di studente al **design a livello di corso**: alcuni corsi trattengono gli studenti meglio di altri, e quali caratteristiche di design correlano con una retention più alta?\n", - ">\n", - "> Proseguire con il **Notebook 06** (`06_bq4_course_comparison.ipynb`) per BQ4: come influiscono le caratteristiche del corso sulla retention?" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.13.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/it/06_bq4_course_comparison.ipynb b/notebooks/it/06_bq4_course_comparison.ipynb deleted file mode 100644 index c17315a..0000000 --- a/notebooks/it/06_bq4_course_comparison.ipynb +++ /dev/null @@ -1,784 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "# 06 — BQ4: Come influiscono le caratteristiche del corso sulla retention?\n", - "\n", - "> **Notebook 06 di 7** | Learning Retention Analytics \n", - "> Analisi della quarta business question: confronto delle caratteristiche di progettazione dei corsi e relazione con la retention degli studenti." - ] - }, - { - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "## Scopo e ambito\n", - "\n", - "Questo notebook risponde a **BQ4: Come influiscono le caratteristiche del corso sulla retention?** — la quarta delle cinque business question che guidano il progetto.\n", - "\n", - "I notebook precedenti si sono concentrati sull'analisi a livello di studente: quando gli studenti abbandonano (BQ1), quali comportamenti precoci predicono il dropout (BQ2) e se i dati demografici o il comportamento predicono l'esito in modo più forte (BQ3). Questo notebook **sposta l'unità di analisi dagli studenti ai corsi**.\n", - "\n", - "**Cosa copre questo notebook:**\n", - "- Classificazione dei 7 moduli OULAD per tasso di completamento\n", - "- Esplorazione delle associazioni tra caratteristiche di progettazione del corso (durata, densità delle valutazioni, diversità delle risorse) e retention\n", - "- Confronto dell'intensità di engagement tra i corsi\n", - "- Identificazione di pattern a livello di corso attraverso una heatmap di design standardizzata\n", - "- Formulazione di ipotesi su quali caratteristiche del corso possano supportare la retention\n", - "\n", - "**Cosa questo notebook NON fa:**\n", - "- Nessuna inferenza confermativa. Con solo 7 moduli, qualsiasi sintesi di correlazione o soglia è euristica descrittiva, non test di significatività formale. Tutta l'analisi è esplorativa.\n", - "- Nessuna affermazione causale. Le differenze nella retention potrebbero essere guidate dalla selezione degli studenti, dalla difficoltà della materia o da fattori non misurati — non solo dalla progettazione del corso.\n", - "\n", - "**Cosa viene dopo:**\n", - "- **Notebook 07** (`07_bq5_recommendations_synthesis.ipynb`): sintesi dei risultati BQ1–BQ4 nelle 3 principali raccomandazioni operative (BQ5).\n", - "\n", - "> **Trasferibilità metodologica:** Il confronto delle metriche di retention a livello di corso è analogo all'analisi del churn a livello di prodotto o piano in SaaS: quale tier di prodotto trattiene meglio? Quali caratteristiche del piano (durata del trial, set di funzionalità, flusso di onboarding) correlano con un churn più basso? L'approccio — classificazione descrittiva, profilazione delle feature, confronto tramite heatmap — si trasferisce direttamente." - ] - }, - { - "cell_type": "markdown", - "id": "2", - "metadata": {}, - "source": [ - "## Indice\n", - "\n", - "1. [Configurazione ambiente](#1.-Configurazione-ambiente)\n", - "2. [Il dataset di analisi](#2.-Il-dataset-di-analisi)\n", - "3. [Classificazione dei corsi per completamento](#3.-Classificazione-dei-corsi-per-completamento)\n", - "4. [Caratteristiche di progettazione del corso vs completamento](#4.-Caratteristiche-di-progettazione-del-corso-vs-completamento)\n", - "5. [Intensità di engagement per corso](#5.-Intensità-di-engagement-per-corso)\n", - "6. [Heatmap di progettazione del corso](#6.-Heatmap-di-progettazione-del-corso)\n", - "7. [Ipotesi e limitazioni](#7.-Ipotesi-e-limitazioni)\n", - "8. [Conclusioni chiave e prossimi passi](#8.-Conclusioni-chiave-e-prossimi-passi)\n", - "\n", - "---\n", - "\n", - "**Prerequisiti:**\n", - "- La pipeline ETL deve essere stata eseguita: `python -m run_pipeline`\n", - "- Il database DuckDB in `data/db/oulad.duckdb` deve contenere tutte e 5 le viste analitiche\n", - "\n", - "**Dataset:** Open University Learning Analytics Dataset (OULAD) — ~32K studenti, 7 corsi, clickstream comportamentale completo. Licenza: CC-BY 4.0." - ] - }, - { - "cell_type": "markdown", - "id": "3", - "metadata": {}, - "source": [ - "## 1. Configurazione ambiente\n", - "\n", - "Configuriamo gli import, i parametri di visualizzazione e le funzioni helper riutilizzabili.\n", - "\n", - "**Note tecniche per il lettore:**\n", - "- Tutte le query al database passano attraverso `src.db.connection.execute_query()` — il livello di astrazione DB del progetto (ADR-003).\n", - "- La query SQL principale di BQ4 risiede in `sql/queries/q_bq4_course_comparison.sql` e viene caricata a runtime dal disco. La query aggrega metriche a livello di corso da `v_course_profile`, `v_engagement_daily` e `v_engagement_early`.\n", - "- Nessuna funzione di test statistico è importata — con solo 7 data point, tutta l'analisi è descrittiva.\n", - "- Le figure vengono salvate in `reports/figures/` a 150 DPI." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Path setup ---\n", - "# Notebooks live in notebooks/ but project modules are at the project root.\n", - "# We search upward for pyproject.toml so the notebook works regardless of\n", - "# where the kernel is launched from (JupyterLab, VS Code, Cursor, repo root).\n", - "import sys\n", - "from pathlib import Path\n", - "\n", - "\n", - "def _find_project_root(start: Path) -> Path:\n", - " \"\"\"Walk upward from start until we find pyproject.toml (the repo root marker).\"\"\"\n", - " for candidate in (start, *start.parents):\n", - " if (candidate / 'pyproject.toml').is_file():\n", - " return candidate\n", - " raise RuntimeError(\n", - " f\"Could not locate project root: no pyproject.toml found in '{start}' \"\n", - " \"or any parent directory. Run the notebook from within the repository.\"\n", - " )\n", - "\n", - "\n", - "PROJECT_ROOT = _find_project_root(Path.cwd())\n", - "if str(PROJECT_ROOT) not in sys.path:\n", - " sys.path.insert(0, str(PROJECT_ROOT))\n", - "\n", - "# --- Standard library ---\n", - "import logging\n", - "import warnings\n", - "\n", - "# --- Third-party ---\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import seaborn as sns\n", - "\n", - "# --- Project modules ---\n", - "from src.config import FIGURES_DIR, QUERIES_DIR\n", - "from src.db.connection import execute_query\n", - "\n", - "# --- Configuration ---\n", - "warnings.filterwarnings('ignore', category=FutureWarning)\n", - "logging.basicConfig(level=logging.WARNING)\n", - "\n", - "# --- Visualization defaults ---\n", - "sns.set_theme(style='whitegrid', font_scale=1.1)\n", - "\n", - "# Course-level palette: one color per module for consistent identification.\n", - "# 7 OULAD modules use tab10 for maximum visual distinction.\n", - "_MODULE_ORDER = ['AAA', 'BBB', 'CCC', 'DDD', 'EEE', 'FFF', 'GGG']\n", - "_TAB10 = plt.cm.tab10.colors\n", - "PALETTE_COURSE = {m: _TAB10[i] for i, m in enumerate(_MODULE_ORDER)}\n", - "\n", - "# Shared axis labels — defined as constants to avoid\n", - "# duplicated string literals flagged by static analysis\n", - "LABEL_COMPLETION_RATE = 'Completion rate (%)'\n", - "LABEL_WITHDRAWAL_RATE = 'Withdrawal rate (%)'\n", - "LABEL_NUM_ENROLLMENTS = 'Number of enrollments'\n", - "\n", - "FIG_DPI = 150\n", - "FIG_SIZE = (10, 6)\n", - "FIG_SIZE_WIDE = (16, 5)\n", - "\n", - "FIGURES_DIR.mkdir(parents=True, exist_ok=True)\n", - "\n", - "\n", - "def save_fig(fig, name: str) -> None:\n", - " \"\"\"Save figure to reports/figures/ with consistent settings.\"\"\"\n", - " path = FIGURES_DIR / f'{name}.png'\n", - " fig.savefig(path, dpi=FIG_DPI, bbox_inches='tight', facecolor='white')\n", - " print(f' Saved: {path}')\n", - "\n", - "\n", - "# --- Load BQ4 query from SQL file ---\n", - "_bq4_sql_path = QUERIES_DIR / 'q_bq4_course_comparison.sql'\n", - "BQ4_SQL = _bq4_sql_path.read_text(encoding='utf-8')\n", - "print(f'Loaded BQ4 query from: {_bq4_sql_path.name} ({len(BQ4_SQL):,} chars)')\n", - "\n", - "# --- Prerequisite check ---\n", - "# BQ4 query uses three views: v_course_profile (FROM), v_engagement_daily\n", - "# and v_engagement_early (scalar subqueries for engagement intensity).\n", - "try:\n", - " _check_cp = execute_query('SELECT COUNT(*) AS n FROM v_course_profile')\n", - " _check_daily = execute_query('SELECT COUNT(*) AS n FROM v_engagement_daily')\n", - " _check_early = execute_query('SELECT COUNT(*) AS n FROM v_engagement_early')\n", - " _n_cp = _check_cp['n'].iloc[0]\n", - " _n_daily = _check_daily['n'].iloc[0]\n", - " _n_early = _check_early['n'].iloc[0]\n", - " if _n_cp == 0 or _n_daily == 0 or _n_early == 0:\n", - " raise RuntimeError('One or more views are empty')\n", - " print('Database OK')\n", - " print(f' v_course_profile: {_n_cp:>12,} rows')\n", - " print(f' v_engagement_daily: {_n_daily:>12,} rows')\n", - " print(f' v_engagement_early: {_n_early:>12,} rows')\n", - "except Exception as exc:\n", - " raise RuntimeError(\n", - " 'Cannot query analytical views. '\n", - " \"Run 'python -m run_pipeline' first to populate the database.\"\n", - " ) from exc" - ] - }, - { - "cell_type": "markdown", - "id": "5", - "metadata": {}, - "source": [ - "## 2. Il dataset di analisi\n", - "\n", - "La query BQ4 (`q_bq4_course_comparison.sql`) aggrega metriche a livello di corso da `v_course_profile`, arricchite con l'intensità di engagement da `v_engagement_daily` e `v_engagement_early`. Ogni riga rappresenta **un modulo** (aggregato su tutte le sue presentazioni):\n", - "\n", - "| Categoria | Colonne | Descrizione |\n", - "|-----------|---------|-------------|\n", - "| **Scala** | n_presentations, total_enrolled, total_completed | Quante coorti, studenti e completamenti |\n", - "| **Esiti** | avg_completion_rate_pct, avg_withdrawal_rate_pct | Performance di retention |\n", - "| **Progettazione del corso** | avg_course_length_days, avg_n_assessments, avg_assessment_density, avg_n_vle_resources, avg_n_activity_types | Caratteristiche controllate dal progettista del corso |\n", - "| **Engagement** | avg_clicks_per_student_day, median_early_clicks *(solo studenti attivi)* | Quanto intensamente gli studenti interagiscono con il corso |\n", - "\n", - "**Avvertenza sulla metrica:** `median_early_clicks` proviene da `v_engagement_early` e riflette la mediana tra gli studenti con attività VLE registrata nei giorni 0–28. Gli studenti iscritti con zero click precoci sono esclusi, quindi questo valore può essere superiore alla mediana calcolata su tutti gli studenti iscritti.\n", - "\n", - "**Decisione chiave di design:** Aggregare per `code_module` (non `code_module + code_presentation`) fornisce una vista stabile per corso. Le singole presentazioni possono variare, ma il design del modulo (valutazioni, risorse, struttura) è la variabile controllabile.\n", - "\n", - "**Avvertenza critica:** Con solo **7 data point** (uno per modulo), questa analisi è interamente descrittiva. Nessun test statistico può rilevare effetti in modo affidabile con questa dimensione campionaria. I pattern sono ipotesi, non conclusioni." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Load BQ4 analysis dataset ---\n", - "df_bq4 = execute_query(BQ4_SQL)\n", - "\n", - "modules_str = ', '.join(df_bq4['code_module'].tolist())\n", - "total_enrolled = df_bq4['total_enrolled'].sum()\n", - "total_presentations = df_bq4['n_presentations'].sum()\n", - "\n", - "print(f'BQ4 dataset: {len(df_bq4)} rows (one per module) x {df_bq4.shape[1]} columns')\n", - "print(f'Modules: {modules_str}')\n", - "print(f'Total enrollments across all modules: {total_enrolled:,}')\n", - "print(f'Total presentations: {total_presentations}')\n", - "\n", - "# --- Feature column constants ---\n", - "DESIGN_FEATURES = [\n", - " 'avg_course_length_days', 'avg_n_assessments',\n", - " 'avg_assessment_density', 'avg_n_vle_resources', 'avg_n_activity_types',\n", - "]\n", - "DESIGN_LABELS = {\n", - " 'avg_course_length_days': 'Course length (days)',\n", - " 'avg_n_assessments': 'Number of assessments',\n", - " 'avg_assessment_density': 'Assessment density (per 30d)',\n", - " 'avg_n_vle_resources': 'VLE resources',\n", - " 'avg_n_activity_types': 'Activity types',\n", - "}\n", - "\n", - "ENGAGEMENT_FEATURES = ['avg_clicks_per_student_day', 'median_early_clicks']\n", - "ENGAGEMENT_LABELS = {\n", - " 'avg_clicks_per_student_day': 'Avg clicks per student-day',\n", - " 'median_early_clicks': 'Median early clicks (active students, first 28d)',\n", - "}\n", - "\n", - "# --- Missingness check ---\n", - "n_nulls = df_bq4.isnull().sum()\n", - "if n_nulls.any():\n", - " print()\n", - " print('=== Null Values Detected ===')\n", - " print(n_nulls[n_nulls > 0].to_string())\n", - "else:\n", - " print()\n", - " print('No null values — all 7 modules have complete data.')\n", - "\n", - "# --- Full dataset overview ---\n", - "print()\n", - "print('=== Course Comparison Dataset (sorted by completion rate) ===')\n", - "print()\n", - "print(df_bq4.to_string(index=False))" - ] - }, - { - "cell_type": "markdown", - "id": "7", - "metadata": {}, - "source": [ - "> **Primo sguardo:** I 7 moduli OULAD coprono un'ampia gamma di tassi di completamento e design dei corsi. Alcuni moduli sono più brevi con meno valutazioni; altri sono più lunghi con più risorse e maggiore densità di valutazioni. La domanda è se queste differenze di design si relazionano con la performance di retention.\n", - ">\n", - "> Il dataset è volutamente piccolo — una riga per modulo — il che significa che ogni osservazione conta e nessun outlier può essere ignorato. Tutti i pattern che identifichiamo sono ipotesi che necessiterebbero di validazione su scala più ampia." - ] - }, - { - "cell_type": "markdown", - "id": "8", - "metadata": {}, - "source": [ - "## 3. Classificazione dei corsi per completamento\n", - "\n", - "Prima di esplorare le feature di design, stabiliamo la baseline: quali corsi trattengono meglio gli studenti?\n", - "\n", - "Il tasso di completamento è mediato su tutte le presentazioni di ciascun modulo. Questo attenua la variazione specifica della coorte e si concentra sulle caratteristiche di retention intrinseche del modulo." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Course completion ranking ---\n", - "# Horizontal bar chart sorted by avg_completion_rate_pct.\n", - "# Module colors from PALETTE_COURSE provide visual consistency across figures.\n", - "df_ranked = df_bq4.sort_values(\n", - " 'avg_completion_rate_pct', ascending=True\n", - ").reset_index(drop=True)\n", - "\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "\n", - "y_pos = np.arange(len(df_ranked))\n", - "colors = [PALETTE_COURSE[m] for m in df_ranked['code_module']]\n", - "\n", - "ax.barh(y_pos, df_ranked['avg_completion_rate_pct'], color=colors, edgecolor='white')\n", - "\n", - "# Annotate each bar with completion rate and enrollment volume\n", - "for i, (_, row) in enumerate(df_ranked.iterrows()):\n", - " rate = row['avg_completion_rate_pct']\n", - " enrolled = int(row['total_enrolled'])\n", - " ax.text(\n", - " rate + 0.8, i,\n", - " f'{rate:.1f}% (n={enrolled:,})',\n", - " va='center', fontsize=9, color='#333333',\n", - " )\n", - "\n", - "# True overall completion rate from raw counts across all presentations\n", - "# (total_completed / total_enrolled), not a simple/unweighted average of module-level averages\n", - "overall_rate = (\n", - " df_bq4['total_completed'].sum() / df_bq4['total_enrolled'].sum() * 100\n", - ")\n", - "ax.axvline(\n", - " x=overall_rate, color='gray', linestyle='--', linewidth=1,\n", - " label=f'Overall rate: {overall_rate:.1f}%',\n", - ")\n", - "\n", - "ax.set_yticks(y_pos)\n", - "ax.set_yticklabels(df_ranked['code_module'])\n", - "ax.set_xlabel(LABEL_COMPLETION_RATE)\n", - "ax.set_title('Course Completion Rate Ranking\\n(averaged across presentations)')\n", - "ax.legend(loc='lower right')\n", - "ax.set_xlim(0, 100)\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '06_course_completion_ranking')\n", - "plt.show()\n", - "\n", - "# --- Quantify the spread ---\n", - "best = df_ranked.iloc[-1]\n", - "worst = df_ranked.iloc[0]\n", - "gap = best['avg_completion_rate_pct'] - worst['avg_completion_rate_pct']\n", - "best_rate = best['avg_completion_rate_pct']\n", - "worst_rate = worst['avg_completion_rate_pct']\n", - "best_module = best['code_module']\n", - "worst_module = worst['code_module']\n", - "print()\n", - "print(f'Completion rate range: {worst_rate:.1f}% ({worst_module}) '\n", - " f'to {best_rate:.1f}% ({best_module}) — gap of {gap:.1f} pp')" - ] - }, - { - "cell_type": "markdown", - "id": "10", - "metadata": {}, - "source": [ - "> **Risultato chiave:** Il gap nel tasso di completamento tra i moduli con le migliori e le peggiori performance è sostanziale. Questa variazione non è casuale — persiste attraverso più presentazioni dello stesso modulo, suggerendo che qualcosa del corso stesso (design, difficoltà della materia, selezione degli studenti) guida la differenza.\n", - ">\n", - "> **Nota sul volume di iscrizioni:** L'annotazione mostra il totale delle iscrizioni per ogni modulo. Un numero maggiore di iscrizioni non correla necessariamente con un completamento più alto o più basso — il pattern è più sfumato, e lo esploreremo attraverso le feature di design nella prossima sezione." - ] - }, - { - "cell_type": "markdown", - "id": "11", - "metadata": {}, - "source": [ - "## 4. Caratteristiche di progettazione del corso vs completamento\n", - "\n", - "Le caratteristiche di progettazione del corso correlano con la retention? Esaminiamo due dimensioni chiave del design:\n", - "\n", - "- **Durata del corso** (giorni): La durata influisce sul fatto che gli studenti restino iscritti?\n", - "- **Densità delle valutazioni** (valutazioni per 30 giorni): Valutazioni più frequenti aiutano o ostacolano la retention?\n", - "\n", - "Ogni scatter plot mostra i 7 moduli posizionati per la loro feature di design (asse x) e tasso di completamento (asse y). La dimensione del punto è proporzionale al totale delle iscrizioni per contesto aggiuntivo.\n", - "\n", - "**Promemoria:** Con n=7, cerchiamo *pattern visivi*, non relazioni statistiche. Qualsiasi tendenza apparente è un'ipotesi da testare con più dati." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "12", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Course design features vs completion rate ---\n", - "# 1x2 scatter panel: course length and assessment density vs completion.\n", - "# Bubble size proportional to enrollment volume for context.\n", - "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=FIG_SIZE_WIDE)\n", - "\n", - "# Scale enrollment to bubble sizes (100–600 pixel range)\n", - "enrolled = df_bq4['total_enrolled'].values\n", - "size_scale = 100 + 500 * (enrolled - enrolled.min()) / max(enrolled.max() - enrolled.min(), 1)\n", - "\n", - "# --- Left: course length vs completion ---\n", - "for i, (_, row) in enumerate(df_bq4.iterrows()):\n", - " ax1.scatter(\n", - " row['avg_course_length_days'], row['avg_completion_rate_pct'],\n", - " color=PALETTE_COURSE[row['code_module']],\n", - " s=size_scale[i], edgecolor='white', linewidth=1.5, zorder=3,\n", - " )\n", - " ax1.annotate(\n", - " row['code_module'],\n", - " (row['avg_course_length_days'], row['avg_completion_rate_pct']),\n", - " fontsize=9, fontweight='bold', ha='center', va='bottom',\n", - " xytext=(0, 8), textcoords='offset points',\n", - " )\n", - "ax1.set_xlabel(DESIGN_LABELS['avg_course_length_days'])\n", - "ax1.set_ylabel(LABEL_COMPLETION_RATE)\n", - "ax1.set_title('Course Length vs Completion Rate')\n", - "sns.despine(ax=ax1)\n", - "\n", - "# --- Right: assessment density vs completion ---\n", - "for i, (_, row) in enumerate(df_bq4.iterrows()):\n", - " ax2.scatter(\n", - " row['avg_assessment_density'], row['avg_completion_rate_pct'],\n", - " color=PALETTE_COURSE[row['code_module']],\n", - " s=size_scale[i], edgecolor='white', linewidth=1.5, zorder=3,\n", - " )\n", - " ax2.annotate(\n", - " row['code_module'],\n", - " (row['avg_assessment_density'], row['avg_completion_rate_pct']),\n", - " fontsize=9, fontweight='bold', ha='center', va='bottom',\n", - " xytext=(0, 8), textcoords='offset points',\n", - " )\n", - "ax2.set_xlabel(DESIGN_LABELS['avg_assessment_density'])\n", - "ax2.set_ylabel(LABEL_COMPLETION_RATE)\n", - "ax2.set_title('Assessment Density vs Completion Rate')\n", - "sns.despine(ax=ax2)\n", - "\n", - "fig.suptitle(\n", - " 'Course Design Features vs Completion Rate\\n'\n", - " '(bubble size = total enrollment)',\n", - " fontsize=14, y=1.02,\n", - ")\n", - "fig.tight_layout()\n", - "save_fig(fig, '06_course_design_vs_completion')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "13", - "metadata": {}, - "source": [ - "> **Pattern visivi:**\n", - "> - **Durata del corso:** Lo scatter potrebbe suggerire una relazione tra durata e retention — corsi più brevi o più lunghi potrebbero avere performance diverse. Tuttavia, con 7 punti, qualsiasi tendenza apparente potrebbe essere guidata da uno o due outlier.\n", - "> - **Densità delle valutazioni:** Corsi con pacing diverso delle valutazioni potrebbero mostrare profili di retention diversi. Se valutazioni più frequenti aiutano (attraverso checkpoint regolari di engagement) o ostacolano (attraverso affaticamento da valutazione) è una domanda aperta.\n", - ">\n", - "> **Fattori confondenti:** Queste associazioni sono confuse dalla difficoltà della materia, dall'autoselezione degli studenti e dalla qualità dei contenuti del modulo. Un corso con alta densità di valutazioni che capita anche di trattare una materia popolare e ben insegnata tratterrà gli studenti indipendentemente dalla frequenza delle valutazioni." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "14", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Exploratory rank correlations ---\n", - "# With only 7 data points, formal significance testing is meaningless\n", - "# (critical Spearman ρ for n=7 at α=0.05 two-tailed ≈ 0.79).\n", - "# These correlations serve as exploratory pattern summaries, not evidence.\n", - "all_features = DESIGN_FEATURES + ENGAGEMENT_FEATURES\n", - "all_labels = {**DESIGN_LABELS, **ENGAGEMENT_LABELS}\n", - "\n", - "rho_series = (\n", - " df_bq4[all_features + ['avg_completion_rate_pct']]\n", - " .corr(method='spearman')['avg_completion_rate_pct']\n", - " .drop('avg_completion_rate_pct')\n", - ")\n", - "\n", - "print('=== Exploratory Spearman ρ vs Completion Rate (n=7) ===')\n", - "print(' CAUTION: 7 data points → descriptive patterns only, not statistical evidence.')\n", - "print(' |ρ| > 0.79 needed for significance at α=0.05 (two-tailed).')\n", - "print()\n", - "\n", - "for feat in all_features:\n", - " rho = rho_series[feat]\n", - " label = all_labels[feat]\n", - " if abs(rho) >= 0.79:\n", - " direction = '★ notable'\n", - " elif abs(rho) >= 0.4:\n", - " direction = '~ moderate'\n", - " else:\n", - " direction = '— weak'\n", - " print(f' {label:35s} ρ = {rho:+.3f} ({direction})')" - ] - }, - { - "cell_type": "markdown", - "id": "15", - "metadata": {}, - "source": [ - "> **Riepilogo delle correlazioni:** Le correlazioni di rango di Spearman forniscono un riepilogo compatto di quali feature *si muovono nella stessa direzione* del tasso di completamento. Le feature contrassegnate come notevoli (|ρ| ≥ 0.79) supererebbero la soglia di significatività per n=7, ma anche queste dovrebbero essere trattate come ipotesi.\n", - ">\n", - "> **Cosa le correlazioni non possono dirci:** Le correlazioni di rango misurano associazione monotona, non causalità. Un forte ρ positivo tra densità delle valutazioni e tasso di completamento potrebbe significare che le valutazioni aiutano la retention — o che i corsi popolari e ben progettati capita che abbiano più valutazioni. I dati non possono distinguere queste spiegazioni con 7 osservazioni." - ] - }, - { - "cell_type": "markdown", - "id": "16", - "metadata": {}, - "source": [ - "## 5. Intensità di engagement per corso\n", - "\n", - "Gli studenti interagiscono in modo diverso con corsi diversi? Due metriche complementari catturano l'intensità di engagement:\n", - "\n", - "- **Click medi per studente-giorno** (da `v_engagement_daily`): l'intensità tipica di interazione giornaliera con il VLE. Valori più alti significano che gli studenti che accedono tendono a cliccare di più.\n", - "- **Click precoci mediani** (da `v_engagement_early`, solo studenti attivi): i click totali mediani nei primi 28 giorni tra gli studenti che hanno avuto almeno un'interazione VLE. Questo cattura il burst iniziale di engagement ed è meno sensibile agli outlier rispetto alla media. Gli studenti con zero click precoci sono esclusi (vedi avvertenza sulla metrica nella Sezione 2).\n", - "\n", - "I moduli sono ordinati per tasso di completamento (stesso ordine del grafico di classificazione) per facilitare il confronto visivo." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "17", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Engagement intensity by course ---\n", - "# 1x2 panel: daily engagement intensity + early engagement volume.\n", - "# Modules sorted by completion rate for consistent reading.\n", - "df_eng = df_bq4.sort_values(\n", - " 'avg_completion_rate_pct', ascending=True\n", - ").reset_index(drop=True)\n", - "\n", - "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=FIG_SIZE_WIDE)\n", - "\n", - "y_pos = np.arange(len(df_eng))\n", - "colors = [PALETTE_COURSE[m] for m in df_eng['code_module']]\n", - "\n", - "# --- Left: avg clicks per student-day ---\n", - "ax1.barh(y_pos, df_eng['avg_clicks_per_student_day'], color=colors, edgecolor='white')\n", - "for i, (_, row) in enumerate(df_eng.iterrows()):\n", - " val = row['avg_clicks_per_student_day']\n", - " ax1.text(\n", - " val + 0.5, i,\n", - " f'{val:.1f}',\n", - " va='center', fontsize=9, color='#333333',\n", - " )\n", - "ax1.set_yticks(y_pos)\n", - "ax1.set_yticklabels(df_eng['code_module'])\n", - "ax1.set_xlabel(ENGAGEMENT_LABELS['avg_clicks_per_student_day'])\n", - "ax1.set_title('Daily Engagement Intensity')\n", - "sns.despine(ax=ax1)\n", - "\n", - "# --- Right: median early clicks (first 28 days) ---\n", - "ax2.barh(y_pos, df_eng['median_early_clicks'], color=colors, edgecolor='white')\n", - "for i, (_, row) in enumerate(df_eng.iterrows()):\n", - " val = row['median_early_clicks']\n", - " ax2.text(\n", - " val + 10, i,\n", - " f'{val:.0f}',\n", - " va='center', fontsize=9, color='#333333',\n", - " )\n", - "ax2.set_yticks(y_pos)\n", - "ax2.set_yticklabels(df_eng['code_module'])\n", - "ax2.set_xlabel(ENGAGEMENT_LABELS['median_early_clicks'])\n", - "ax2.set_title('Early Engagement Volume (first 28 days)')\n", - "sns.despine(ax=ax2)\n", - "\n", - "fig.suptitle(\n", - " 'Engagement Intensity by Course\\n'\n", - " '(modules sorted by completion rate, bottom = lowest)',\n", - " fontsize=14, y=1.02,\n", - ")\n", - "fig.tight_layout()\n", - "save_fig(fig, '06_engagement_by_course')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "18", - "metadata": {}, - "source": [ - "> **Variazione dell'engagement:** L'intensità di engagement varia tra i corsi, ma la relazione con il completamento non è lineare. Un corso potrebbe avere un alto engagement per sessione (molti click per visita) ma basso completamento se gli studenti alla fine si disimpegnano.\n", - ">\n", - "> **Due dimensioni dell'engagement:**\n", - "> - L'**intensità giornaliera** riflette quanto profondamente gli studenti interagiscono *quando accedono*. Questa è influenzata dal design del corso: corsi ricchi di risorse con elementi interattivi generano naturalmente più click.\n", - "> - Il **volume precoce** riflette il momentum iniziale di engagement. BQ2 (NB04) ha mostrato che l'engagement precoce è il predittore più forte del completamento a livello di studente. Qui vediamo se alcuni corsi generano più engagement iniziale di altri.\n", - ">\n", - "> **Cautela nell'interpretazione:** Le metriche di engagement a livello di corso confondono il design del corso con il comportamento degli studenti. Un corso che attrae studenti più motivati mostrerà un engagement più alto indipendentemente dalla qualità del suo design." - ] - }, - { - "cell_type": "markdown", - "id": "19", - "metadata": {}, - "source": [ - "## 6. Heatmap di progettazione del corso\n", - "\n", - "La heatmap standardizza tutte le feature di design e engagement in **z-score** (deviazioni standard dalla media dei 7 moduli) in modo che feature su scale diverse diventino visivamente comparabili.\n", - "\n", - "- Le **celle verdi** indicano valori sopra la media (rispetto agli altri moduli)\n", - "- Le **celle rosse** indicano valori sotto la media\n", - "- Le **annotazioni** mostrano i valori reali (non standardizzati) come riferimento\n", - "\n", - "I moduli sono ordinati dall'alto verso il basso per tasso di completamento, così i pattern visivi tra i corsi ad alta retention (in alto) e bassa retention (in basso) diventano evidenti." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "20", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Course design heatmap ---\n", - "# Z-score standardization puts all features on the same scale for visual\n", - "# comparison: how many standard deviations above or below the module mean.\n", - "all_features = DESIGN_FEATURES + ENGAGEMENT_FEATURES\n", - "all_labels = {**DESIGN_LABELS, **ENGAGEMENT_LABELS}\n", - "\n", - "# Sort by completion rate: highest at top for intuitive reading\n", - "module_order = df_bq4.sort_values(\n", - " 'avg_completion_rate_pct', ascending=False\n", - ")['code_module'].tolist()\n", - "\n", - "df_heat_raw = df_bq4.set_index('code_module').loc[module_order, all_features]\n", - "\n", - "# Z-score: (value - mean) / std across the 7 modules for each feature\n", - "df_z = (df_heat_raw - df_heat_raw.mean()) / df_heat_raw.std()\n", - "\n", - "# Readable column labels\n", - "readable_cols = [all_labels[c] for c in all_features]\n", - "df_z.columns = readable_cols\n", - "\n", - "# Build annotation matrix with actual values (not z-scores)\n", - "# so the reader sees real numbers alongside the color encoding\n", - "annot_values = []\n", - "for _, row in df_heat_raw.iterrows():\n", - " row_annot = []\n", - " for feat in all_features:\n", - " val = row[feat]\n", - " if feat == 'avg_assessment_density':\n", - " row_annot.append(f'{val:.2f}')\n", - " elif feat in ('avg_n_assessments', 'avg_n_activity_types',\n", - " 'avg_clicks_per_student_day'):\n", - " row_annot.append(f'{val:.1f}')\n", - " else:\n", - " row_annot.append(f'{val:.0f}')\n", - " annot_values.append(row_annot)\n", - "\n", - "annot_array = np.array(annot_values)\n", - "\n", - "# --- Heatmap ---\n", - "fig, ax = plt.subplots(figsize=(14, 5))\n", - "sns.heatmap(\n", - " df_z, annot=annot_array, fmt='',\n", - " cmap='RdYlGn', center=0, linewidths=0.5,\n", - " ax=ax, cbar_kws={'label': 'Z-score (standard deviations from mean)'},\n", - ")\n", - "\n", - "# Add completion rate to y-axis labels for immediate context\n", - "rate_labels = []\n", - "for m in module_order:\n", - " mask = df_bq4['code_module'] == m\n", - " rate = df_bq4.loc[mask, 'avg_completion_rate_pct'].values[0]\n", - " rate_labels.append(f'{m} ({rate:.1f}%)')\n", - "ax.set_yticklabels(rate_labels, rotation=0)\n", - "\n", - "ax.set_title(\n", - " 'Course Design Heatmap — All Features Standardized\\n'\n", - " '(z-score coloring, actual values annotated; sorted by completion rate)'\n", - ")\n", - "ax.set_ylabel('')\n", - "fig.tight_layout()\n", - "save_fig(fig, '06_course_design_heatmap')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "21", - "metadata": {}, - "source": [ - "> **Leggere la heatmap:** Cercare pattern di colore verticali (feature che differiscono in modo consistente tra corsi ad alta e bassa retention) e pattern orizzontali (moduli con caratteristiche consistentemente sopra o sotto la media).\n", - ">\n", - "> **Cosa cercare:**\n", - "> - I corsi ad alto completamento (righe in alto) tendono ad essere più verdi su specifiche colonne di feature? Se sì, quelle feature potrebbero contribuire alla retention.\n", - "> - I corsi a basso completamento (righe in basso) condividono celle rosse sulle stesse feature? Questo rafforzerebbe il pattern.\n", - "> - Ci sono moduli che sono outlier — verdi sulla maggior parte delle feature ma bassi sul completamento, o viceversa? Questi rompono il pattern e suggeriscono altri fattori in gioco.\n", - ">\n", - "> **Avvertenza:** La standardizzazione in z-score amplifica il contrasto visivo — una differenza di 0.5 deviazioni standard appare drammatica nella scala cromatica ma potrebbe non essere praticamente significativa con solo 7 osservazioni." - ] - }, - { - "cell_type": "markdown", - "id": "22", - "metadata": {}, - "source": [ - "## 7. Ipotesi e limitazioni\n", - "\n", - "### Ipotesi (non conclusioni)\n", - "\n", - "Sulla base dei pattern descrittivi osservati:\n", - "\n", - "1. **La struttura delle valutazioni potrebbe influenzare la retention.** Corsi con un certo pacing delle valutazioni potrebbero fornire checkpoint regolari di engagement che aiutano gli studenti a restare in carreggiata — oppure potrebbero creare punti di pressione che spingono all'abbandono. La direzione di questo effetto è una domanda empirica.\n", - "\n", - "2. **La diversità delle risorse potrebbe supportare l'engagement.** Corsi che offrono una maggiore varietà di tipologie di attività VLE potrebbero mantenere gli studenti coinvolti attraverso esperienze di apprendimento diversificate. Tuttavia, la diversità delle risorse potrebbe anche correlare con l'investimento istituzionale nel corso, il che rappresenta un fattore confondente.\n", - "\n", - "3. **L'intensità dell'engagement iniziale varia in base al design del corso.** Alcuni corsi generano un engagement precoce più elevato, che NB04 (BQ2) ha identificato come il predittore più forte a livello di studente per il completamento. Se il design del corso può aumentare l'engagement iniziale, questa è una leva operativa per la retention.\n", - "\n", - "### Limitazioni\n", - "\n", - "Questa analisi presenta vincoli fondamentali che devono essere riconosciuti:\n", - "\n", - "- **n=7.** Sette data point non possono supportare la statistica inferenziale. Tutte le correlazioni, i pattern e le ipotesi sono esplorativi. Un ρ di Spearman significativo per n=7 richiede |ρ| > 0.79 — associazioni estremamente forti.\n", - "\n", - "- **Fallacia ecologica.** Le medie a livello di corso possono mascherare la variazione a livello di studente. Un corso con engagement medio alto potrebbe avere una distribuzione bimodale: molti studenti altamente coinvolti e molti che non hanno mai effettuato l'accesso.\n", - "\n", - "- **Confondimento per materia.** I diversi moduli insegnano materie diverse. La difficoltà intrinseca della materia, la rilevanza professionale e la motivazione degli studenti sono variabili non osservate che potrebbero spiegare sia le scelte di design del corso sia gli esiti di retention.\n", - "\n", - "- **Autoselezione degli studenti.** Gli studenti scelgono quali moduli frequentare. Se studenti più capaci o motivati selezionano determinati corsi, quei corsi mostreranno una retention più alta indipendentemente dal design.\n", - "\n", - "- **Confondimento temporale.** Il design del corso potrebbe essere cambiato tra le presentazioni, e le metriche aggregate appianano questi cambiamenti." - ] - }, - { - "cell_type": "markdown", - "id": "23", - "metadata": {}, - "source": [ - "## 8. Conclusioni chiave e prossimi passi\n", - "\n", - "### Cosa abbiamo imparato\n", - "\n", - "1. **I tassi di completamento variano sostanzialmente tra i moduli** — il gap tra i migliori e i peggiori performer è significativo e consistente tra le presentazioni. Questa variazione non è rumore.\n", - "\n", - "2. **Le feature di progettazione del corso mostrano pattern suggestivi** con la retention, ma con sole 7 osservazioni, queste sono ipotesi piuttosto che risultati. La densità delle valutazioni e la diversità delle risorse emergono come candidati per ulteriori indagini.\n", - "\n", - "3. **L'intensità di engagement differisce per corso.** Alcuni moduli generano più interazione VLE di altri. Poiché l'engagement precoce è il predittore più forte del completamento a livello di studente (BQ2), design dei corsi che promuovono un engagement iniziale più elevato potrebbero supportare indirettamente la retention.\n", - "\n", - "4. **La heatmap rivela profili dei corsi** — i moduli differiscono non solo nel tasso di completamento ma nelle loro caratteristiche complessive di design e engagement. I corsi ad alta retention potrebbero condividere certi pattern di progettazione.\n", - "\n", - "5. **Nessuna affermazione causale è possibile** con questa dimensione campionaria. I pattern osservati qui sono input per le raccomandazioni di BQ5, non evidenze autonome.\n", - "\n", - "### Prossimi passi\n", - "\n", - "| Notebook | Business Question | Focus |\n", - "|----------|-------------------|-------------------------------------------------|\n", - "| **07** | BQ5 | Le 3 principali raccomandazioni operative per la retention |\n", - "\n", - "NB07 sintetizza i risultati di tutte e cinque le business question (BQ1–BQ4) in raccomandazioni concrete e prioritizzate per un operatore di piattaforma — l'output analitico finale di questo progetto.\n", - "\n", - "---\n", - "\n", - "**Riproducibilità:** Tutte le figure sono salvate in `reports/figures/`. Per riprodurre questo notebook, eseguire prima `python -m run_pipeline`, poi eseguire tutte le celle in ordine." - ] - }, - { - "cell_type": "markdown", - "id": "24", - "metadata": {}, - "source": [ - "> **Dai profili dei corsi all'azione:** Questo notebook ha caratterizzato *come i corsi differiscono* in design, engagement e retention. Combinato con gli insight a livello di studente da BQ1–BQ3, ora abbiamo il quadro completo necessario per rispondere alla domanda finale: cosa può fare concretamente un operatore di piattaforma?\n", - ">\n", - "> Continua con il **Notebook 07** (`07_bq5_recommendations_synthesis.ipynb`) per BQ5: le 3 principali raccomandazioni operative per migliorare la retention degli studenti." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "version": "3.13.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/it/07_bq5_recommendations_synthesis.ipynb b/notebooks/it/07_bq5_recommendations_synthesis.ipynb deleted file mode 100644 index 0385992..0000000 --- a/notebooks/it/07_bq5_recommendations_synthesis.ipynb +++ /dev/null @@ -1,1165 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "# 07 — BQ5: Le 3 principali raccomandazioni operative per la retention degli studenti\n", - "\n", - "> **Notebook 07 di 7** | Learning Retention Analytics \n", - "> Quinta business question: sintesi dei risultati BQ1–BQ4 in raccomandazioni concrete e prioritizzate per un operatore di piattaforma." - ] - }, - { - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "## Scopo e ambito\n", - "\n", - "Questo notebook risponde a **BQ5: Quali sono le 3 principali raccomandazioni operative per un operatore di piattaforma?** — l'ultima business question e il punto culminante di questo progetto.\n", - "\n", - "I notebook 03–06 hanno stabilito *cosa accade* (BQ1: tempistica del dropout), *cosa lo predice* (BQ2: segnali precoci), *se contano di più i dati demografici o il comportamento* (BQ3: vince il comportamento) e *come differiscono i corsi* (BQ4: design del corso vs retention). Questo notebook **trasforma quei risultati in azione**.\n", - "\n", - "**Cosa copre questo notebook:**\n", - "- Dimensionamento dei tre segmenti target identificati attraverso BQ1–BQ4\n", - "- Quantificazione della sovrapposizione tra segmenti (gli studenti possono appartenere a più segmenti)\n", - "- Costruzione di tre schede di raccomandazione con: segmento, intervento, impatto atteso, costo\n", - "- Classificazione degli interventi in una matrice di priorità (impatto vs costo)\n", - "- Proposta di una roadmap di implementazione per fasi\n", - "\n", - "**Cosa questo notebook NON fa:**\n", - "- Nessun nuovo test statistico — tutte le evidenze provengono da BQ1–BQ4\n", - "- Nessuna affermazione causale — le stime di impatto sono proiezioni, non effetti misurati\n", - "- Nessun disegno di A/B test — questo è il passo successivo naturale dopo il deployment degli interventi\n", - "\n", - "**Cosa è venuto prima:**\n", - "- **NB03** (BQ1): dove e quando gli studenti abbandonano — curve di dropout, rilevamento dei cliff\n", - "- **NB04** (BQ2): segnali comportamentali precoci che predicono il dropout — effect size, dose-response\n", - "- **NB05** (BQ3): dati demografici vs comportamento — il comportamento predice l'esito 2–5× più fortemente\n", - "- **NB06** (BQ4): design del corso vs retention — profili descrittivi dei corsi, correlazioni esplorative\n", - "\n", - "> **Trasferibilità metodologica:** Questo pattern di sintesi — dimensionamento dei segmenti → design dell'intervento → stima dell'impatto → prioritizzazione — è il \"playbook standard per gli interventi sul churn\" nella product analytics SaaS. I tre segmenti (utenti ghost, non-adottanti di funzionalità, disimpegnati precoci) si mappano direttamente su churn da abbonamento, retention nelle app fitness e conversione freemium." - ] - }, - { - "cell_type": "markdown", - "id": "2", - "metadata": {}, - "source": [ - "## Indice\n", - "\n", - "1. [Configurazione ambiente](#1.-Configurazione-ambiente)\n", - "2. [Dimensionamento dei segmenti — Le tre popolazioni target](#2.-Dimensionamento-dei-segmenti-—-Le-tre-popolazioni-target)\n", - "3. [Sovrapposizione dei segmenti](#3.-Sovrapposizione-dei-segmenti)\n", - "4. [Raccomandazione 1 — Attivazione degli studenti ghost](#4.-Raccomandazione-1-—-Attivazione-degli-studenti-ghost)\n", - "5. [Raccomandazione 2 — Checkpoint della prima valutazione](#5.-Raccomandazione-2-—-Checkpoint-della-prima-valutazione)\n", - "6. [Raccomandazione 3 — Campagna di re-engagement alla settimana 3](#6.-Raccomandazione-3-—-Campagna-di-re-engagement-alla-settimana-3)\n", - "7. [Matrice di priorità](#7.-Matrice-di-priorità)\n", - "8. [Roadmap di implementazione](#8.-Roadmap-di-implementazione)\n", - "9. [Limitazioni e avvertenze](#9.-Limitazioni-e-avvertenze)\n", - "10. [Conclusioni chiave](#10.-Conclusioni-chiave)\n", - "\n", - "---\n", - "\n", - "**Prerequisiti:**\n", - "- La pipeline ETL deve essere stata eseguita: `python -m run_pipeline`\n", - "- Il database DuckDB in `data/db/oulad.duckdb` deve contenere tutte e 5 le viste analitiche\n", - "\n", - "**Dataset:** Open University Learning Analytics Dataset (OULAD) — ~32K studenti, 7 corsi, clickstream comportamentale completo. Licenza: CC-BY 4.0." - ] - }, - { - "cell_type": "markdown", - "id": "3", - "metadata": {}, - "source": [ - "## 1. Configurazione ambiente\n", - "\n", - "Configuriamo gli import, i parametri di visualizzazione e le funzioni helper riutilizzabili.\n", - "\n", - "**Note tecniche per il lettore:**\n", - "- Tutte le query al database passano attraverso `src.db.connection.execute_query()` — il livello di astrazione DB del progetto (ADR-003).\n", - "- La query SQL principale di BQ5 risiede in `sql/queries/q_bq5_segment_sizing.sql` e viene caricata a runtime dal disco. Dimensiona tre segmenti di intervento da `v_student_enriched` e `v_engagement_early`.\n", - "- Query SQL inline aggiuntive calcolano le stime di impatto per ciascuna raccomandazione. Sono specifiche per la narrativa di sintesi di questo notebook e non riutilizzabili come query autonome (coerente con il pattern di query inline in NB03).\n", - "- Nessun import di test statistici — questo è un notebook di sintesi, tutte le evidenze provengono da NB03–NB06.\n", - "- Le figure vengono salvate in `reports/figures/` a 150 DPI." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Path setup ---\n", - "# Notebooks live in notebooks/ but project modules are at the project root.\n", - "# We search upward for pyproject.toml so the notebook works regardless of\n", - "# where the kernel is launched from (JupyterLab, VS Code, Cursor, repo root).\n", - "import sys\n", - "from pathlib import Path\n", - "\n", - "\n", - "def _find_project_root(start: Path) -> Path:\n", - " \"\"\"Walk upward from start until we find pyproject.toml (the repo root marker).\"\"\"\n", - " for candidate in (start, *start.parents):\n", - " if (candidate / 'pyproject.toml').is_file():\n", - " return candidate\n", - " raise RuntimeError(\n", - " f\"Could not locate project root: no pyproject.toml found in '{start}' \"\n", - " \"or any parent directory. Run the notebook from within the repository.\"\n", - " )\n", - "\n", - "\n", - "PROJECT_ROOT = _find_project_root(Path.cwd())\n", - "if str(PROJECT_ROOT) not in sys.path:\n", - " sys.path.insert(0, str(PROJECT_ROOT))\n", - "\n", - "# --- Standard library ---\n", - "import logging\n", - "import warnings\n", - "\n", - "# --- Third-party ---\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import pandas as pd\n", - "import seaborn as sns\n", - "\n", - "# --- Project modules ---\n", - "from src.config import FIGURES_DIR, QUERIES_DIR\n", - "from src.db.connection import execute_query\n", - "\n", - "# --- Configuration ---\n", - "warnings.filterwarnings('ignore', category=FutureWarning)\n", - "logging.basicConfig(level=logging.WARNING)\n", - "\n", - "# --- Visualization defaults ---\n", - "sns.set_theme(style='whitegrid', font_scale=1.1)\n", - "\n", - "# Segment palette: one color per intervention segment for consistent\n", - "# identification across all figures. Colors chosen for semantic clarity:\n", - "# red = most critical (ghost), orange = medium (assessment), blue = re-engagement.\n", - "PALETTE_SEGMENT = {\n", - " 'Ghost students': '#C44E52',\n", - " 'Assessment non-submitters': '#DD8452',\n", - " 'Early disengagers': '#4C72B0',\n", - "}\n", - "SEGMENT_ORDER = list(PALETTE_SEGMENT.keys())\n", - "\n", - "# Shared axis labels and section headers — defined as constants to avoid\n", - "# duplicated string literals flagged by static analysis\n", - "LABEL_COMPLETION_RATE = 'Completion rate (%)'\n", - "LABEL_NON_COMPLETION_RATE = 'Non-completion rate (%)'\n", - "LABEL_NUM_STUDENTS = 'Number of students'\n", - "HEADER_SCENARIO = '=== Scenario Analysis ==='\n", - "\n", - "FIG_DPI = 150\n", - "FIG_SIZE = (10, 6)\n", - "FIG_SIZE_WIDE = (16, 5)\n", - "\n", - "FIGURES_DIR.mkdir(parents=True, exist_ok=True)\n", - "\n", - "\n", - "def save_fig(fig, name: str) -> None:\n", - " \"\"\"Save figure to reports/figures/ with consistent settings.\"\"\"\n", - " path = FIGURES_DIR / f'{name}.png'\n", - " fig.savefig(path, dpi=FIG_DPI, bbox_inches='tight', facecolor='white')\n", - " print(f' Saved: {path}')\n", - "\n", - "\n", - "# --- Load BQ5 query from SQL file ---\n", - "_bq5_sql_path = QUERIES_DIR / 'q_bq5_segment_sizing.sql'\n", - "BQ5_SQL = _bq5_sql_path.read_text(encoding='utf-8')\n", - "print(f'Loaded BQ5 query from: {_bq5_sql_path.name} ({len(BQ5_SQL):,} chars)')\n", - "\n", - "# --- Prerequisite check ---\n", - "# BQ5 query depends on v_student_enriched (main student table) and\n", - "# v_engagement_early (early behavioral metrics for segment classification).\n", - "try:\n", - " _check_se = execute_query('SELECT COUNT(*) AS n FROM v_student_enriched')\n", - " _check_ee = execute_query('SELECT COUNT(*) AS n FROM v_engagement_early')\n", - " _n_se = _check_se['n'].iloc[0]\n", - " _n_ee = _check_ee['n'].iloc[0]\n", - " if _n_se == 0 or _n_ee == 0:\n", - " raise RuntimeError('One or more views are empty')\n", - " print('Database OK')\n", - " print(f' v_student_enriched: {_n_se:>12,} rows')\n", - " print(f' v_engagement_early: {_n_ee:>12,} rows')\n", - "except Exception as exc:\n", - " raise RuntimeError(\n", - " 'Cannot query analytical views. '\n", - " \"Run 'python -m run_pipeline' first to populate the database.\"\n", - " ) from exc" - ] - }, - { - "cell_type": "markdown", - "id": "5", - "metadata": {}, - "source": [ - "## 2. Dimensionamento dei segmenti — Le tre popolazioni target\n", - "\n", - "La query BQ5 (`q_bq5_segment_sizing.sql`) dimensiona tre segmenti di studenti definiti da **criteri osservabili e azionabili** — non demografici. Ogni segmento rappresenta un gruppo in cui un intervento mirato potrebbe ridurre il non-completamento:\n", - "\n", - "| Segmento | Definizione | Razionale |\n", - "|----------|------------|-----------|\n", - "| **Studenti ghost** | ≤1 giorno attivo E <10 click nei primi 28 giorni | Iscritti ma mai iniziato in modo significativo. BQ2 (NB04) ha mostrato che zero engagement precoce è il predittore più forte di dropout. |\n", - "| **Non-submitter delle valutazioni** | Nessuna valutazione consegnata nei primi 28 giorni | Mancare la prima scadenza è un potente segnale binario. BQ2 ha identificato `submitted_first_assessment` come predittore chiave. |\n", - "| **Disimpegnati precoci** | Attività VLE nei giorni 0–14 ma zero attività nei giorni 15–28 | Hanno iniziato ma perso il momentum. BQ1 (NB03) ha mostrato cliff di dropout a metà corso, spesso in corrispondenza dei punti di valutazione. |\n", - "\n", - "**Principio di design:** Tutti e tre i segmenti sono definiti dal *comportamento*, non dai dati demografici. Questo è coerente con il risultato di BQ3 (NB05) che i segnali comportamentali predicono l'esito 2–5× più fortemente delle feature demografiche. Gli interventi che mirano al comportamento sono sia più efficaci sia più etici." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Load BQ5 segment sizing data ---\n", - "df_bq5 = execute_query(BQ5_SQL)\n", - "\n", - "total_students = int(df_bq5['total_students'].iloc[0])\n", - "\n", - "# Build a structured DataFrame for the three segments\n", - "# so we can plot and reference them consistently across sections\n", - "segments = pd.DataFrame({\n", - " 'segment': SEGMENT_ORDER,\n", - " 'n': [\n", - " int(df_bq5['n_ghost'].iloc[0]),\n", - " int(df_bq5['n_non_submitter'].iloc[0]),\n", - " int(df_bq5['n_early_disengager'].iloc[0]),\n", - " ],\n", - " 'pct_of_total': [\n", - " float(df_bq5['pct_ghost'].iloc[0]),\n", - " float(df_bq5['pct_non_submitter'].iloc[0]),\n", - " float(df_bq5['pct_early_disengager'].iloc[0]),\n", - " ],\n", - " 'non_completion_rate': [\n", - " float(df_bq5['ghost_non_completion_rate_pct'].iloc[0]),\n", - " float(df_bq5['non_submitter_non_completion_rate_pct'].iloc[0]),\n", - " float(df_bq5['disengager_non_completion_rate_pct'].iloc[0]),\n", - " ],\n", - "})\n", - "\n", - "# Overall non-completion rate for context (baseline)\n", - "overall_non_completion = execute_query('''\n", - " SELECT ROUND(100.0 * SUM(CASE WHEN completed = 0 THEN 1 ELSE 0 END)\n", - " / COUNT(*), 1) AS rate\n", - " FROM v_student_enriched\n", - "''')['rate'].iloc[0]\n", - "\n", - "print(f'BQ5 segment sizing: {total_students:,} total enrollments')\n", - "print(f'Overall non-completion rate: {overall_non_completion}%')\n", - "print()\n", - "print('=== Segment Summary ===')\n", - "print(segments.to_string(index=False))\n", - "print()\n", - "# How much worse each segment is compared to the overall rate\n", - "for _, row in segments.iterrows():\n", - " excess = row['non_completion_rate'] - overall_non_completion\n", - " print(f\" {row['segment']}: {row['non_completion_rate']:.1f}% non-completion \"\n", - " f\"(+{excess:.1f} pp vs overall)\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Figure: Segment sizing overview ---\n", - "# 1x2 panel: segment sizes (left) and non-completion rates (right).\n", - "# Segments ordered by severity (SEGMENT_ORDER) for consistent reading.\n", - "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=FIG_SIZE_WIDE)\n", - "\n", - "y_pos = np.arange(len(segments))\n", - "colors = [PALETTE_SEGMENT[s] for s in segments['segment']]\n", - "\n", - "# --- Left: segment size ---\n", - "ax1.barh(y_pos, segments['n'], color=colors, edgecolor='white')\n", - "for i, (_, row) in enumerate(segments.iterrows()):\n", - " ax1.text(\n", - " row['n'] + total_students * 0.01, i,\n", - " f\"{row['n']:,} ({row['pct_of_total']:.1f}%)\",\n", - " va='center', fontsize=10, color='#333333',\n", - " )\n", - "ax1.set_yticks(y_pos)\n", - "ax1.set_yticklabels(segments['segment'])\n", - "ax1.set_xlabel(LABEL_NUM_STUDENTS)\n", - "ax1.set_title('Segment Size')\n", - "sns.despine(ax=ax1)\n", - "\n", - "# --- Right: non-completion rate ---\n", - "ax2.barh(y_pos, segments['non_completion_rate'], color=colors, edgecolor='white')\n", - "for i, (_, row) in enumerate(segments.iterrows()):\n", - " ax2.text(\n", - " row['non_completion_rate'] + 1, i,\n", - " f\"{row['non_completion_rate']:.1f}%\",\n", - " va='center', fontsize=10, color='#333333',\n", - " )\n", - "# Overall baseline reference line\n", - "ax2.axvline(\n", - " x=overall_non_completion, color='gray', linestyle='--', linewidth=1,\n", - " label=f'Overall: {overall_non_completion:.1f}%',\n", - ")\n", - "ax2.set_yticks(y_pos)\n", - "ax2.set_yticklabels(segments['segment'])\n", - "ax2.set_xlabel(LABEL_NON_COMPLETION_RATE)\n", - "ax2.set_title('Non-completion Rate by Segment')\n", - "ax2.set_xlim(0, 105)\n", - "ax2.legend(loc='lower right')\n", - "sns.despine(ax=ax2)\n", - "\n", - "fig.suptitle(\n", - " 'BQ5 Segment Sizing — Who Should We Target?\\n'\n", - " f'(total enrollments: {total_students:,})',\n", - " fontsize=14, y=1.02,\n", - ")\n", - "fig.tight_layout()\n", - "save_fig(fig, '07_segment_sizing_overview')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "8", - "metadata": {}, - "source": [ - "> **Profilo dei segmenti:** Tutti e tre i segmenti mostrano tassi di non-completamento sostanzialmente superiori alla baseline complessiva. Il gap tra il tasso di ciascun segmento e la media della piattaforma quantifica il \"non-completamento in eccesso\" — la porzione potenzialmente affrontabile attraverso un intervento mirato.\n", - ">\n", - "> **Nota:** Questi segmenti non sono mutuamente esclusivi. Uno studente ghost che non ha mai acceduto al VLE quasi certamente non ha nemmeno consegnato una valutazione. La sezione successiva quantifica questa sovrapposizione per evitare il doppio conteggio nella stima dell'impatto aggregato." - ] - }, - { - "cell_type": "markdown", - "id": "9", - "metadata": {}, - "source": [ - "## 3. Sovrapposizione dei segmenti\n", - "\n", - "Gli studenti possono appartenere a più segmenti contemporaneamente. Comprendere la sovrapposizione è fondamentale per due ragioni:\n", - "1. **Stima dell'impatto:** Se la maggior parte degli studenti ghost sono anche non-submitter, i due interventi mirano in gran parte alle stesse persone — il loro impatto non dovrebbe essere sommato in modo ingenuo.\n", - "2. **Sequenziamento degli interventi:** Uno studente in più segmenti riceverebbe più interventi; dobbiamo stabilire una priorità su quale riceve per primo.\n", - "\n", - "La query seguente riproduce la classificazione dei segmenti BQ5 a livello di studente (invece di aggregare in una singola riga) e conta le intersezioni a coppie e a tre." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "10", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Segment overlap analysis ---\n", - "# Inline query: reproduce the BQ5 segment flags at the student level\n", - "# and count pairwise/three-way intersections.\n", - "# This mirrors the CTE logic in q_bq5_segment_sizing.sql but selects\n", - "# per-student flags instead of aggregating to a single row.\n", - "OVERLAP_SQL = '''\n", - "WITH early_activity AS (\n", - " SELECT id_student, code_module, code_presentation,\n", - " SUM(sum_click) AS total_clicks_0_14\n", - " FROM studentVle\n", - " WHERE date BETWEEN 0 AND 14\n", - " GROUP BY id_student, code_module, code_presentation\n", - "),\n", - "late_activity AS (\n", - " SELECT id_student, code_module, code_presentation,\n", - " SUM(sum_click) AS total_clicks_15_28\n", - " FROM studentVle\n", - " WHERE date BETWEEN 15 AND 28\n", - " GROUP BY id_student, code_module, code_presentation\n", - "),\n", - "early_assessments AS (\n", - " SELECT DISTINCT sa.id_student, a.code_module, a.code_presentation\n", - " FROM studentAssessment sa\n", - " JOIN assessments a ON sa.id_assessment = a.id_assessment\n", - " WHERE a.date <= 28\n", - "),\n", - "student_segments AS (\n", - " SELECT\n", - " se.id_student, se.code_module, se.code_presentation,\n", - " CASE WHEN COALESCE(ee.active_days_first_28, 0) <= 1\n", - " AND COALESCE(ee.total_clicks_first_28, 0) < 10\n", - " THEN 1 ELSE 0 END AS is_ghost,\n", - " CASE WHEN ea.id_student IS NULL\n", - " THEN 1 ELSE 0 END AS is_non_submitter,\n", - " CASE WHEN early_act.total_clicks_0_14 IS NOT NULL\n", - " AND late_act.total_clicks_15_28 IS NULL\n", - " THEN 1 ELSE 0 END AS is_early_disengager\n", - " FROM v_student_enriched se\n", - " LEFT JOIN v_engagement_early ee\n", - " ON se.id_student = ee.id_student\n", - " AND se.code_module = ee.code_module\n", - " AND se.code_presentation = ee.code_presentation\n", - " LEFT JOIN early_assessments ea\n", - " ON se.id_student = ea.id_student\n", - " AND se.code_module = ea.code_module\n", - " AND se.code_presentation = ea.code_presentation\n", - " LEFT JOIN early_activity early_act\n", - " ON se.id_student = early_act.id_student\n", - " AND se.code_module = early_act.code_module\n", - " AND se.code_presentation = early_act.code_presentation\n", - " LEFT JOIN late_activity late_act\n", - " ON se.id_student = late_act.id_student\n", - " AND se.code_module = late_act.code_module\n", - " AND se.code_presentation = late_act.code_presentation\n", - ")\n", - "SELECT\n", - " COUNT(*) AS total,\n", - " SUM(is_ghost) AS n_ghost,\n", - " SUM(is_non_submitter) AS n_non_sub,\n", - " SUM(is_early_disengager) AS n_disengager,\n", - " -- Pairwise overlaps\n", - " SUM(CASE WHEN is_ghost = 1 AND is_non_submitter = 1\n", - " THEN 1 ELSE 0 END) AS ghost_and_nonsub,\n", - " SUM(CASE WHEN is_ghost = 1 AND is_early_disengager = 1\n", - " THEN 1 ELSE 0 END) AS ghost_and_disengager,\n", - " SUM(CASE WHEN is_non_submitter = 1 AND is_early_disengager = 1\n", - " THEN 1 ELSE 0 END) AS nonsub_and_disengager,\n", - " -- Three-way overlap\n", - " SUM(CASE WHEN is_ghost = 1 AND is_non_submitter = 1 AND is_early_disengager = 1\n", - " THEN 1 ELSE 0 END) AS all_three,\n", - " -- Exclusive membership (belongs to exactly this one segment)\n", - " SUM(CASE WHEN is_ghost = 1 AND is_non_submitter = 0 AND is_early_disengager = 0\n", - " THEN 1 ELSE 0 END) AS ghost_only,\n", - " SUM(CASE WHEN is_ghost = 0 AND is_non_submitter = 1 AND is_early_disengager = 0\n", - " THEN 1 ELSE 0 END) AS nonsub_only,\n", - " SUM(CASE WHEN is_ghost = 0 AND is_non_submitter = 0 AND is_early_disengager = 1\n", - " THEN 1 ELSE 0 END) AS disengager_only,\n", - " -- Union: in at least one segment\n", - " SUM(CASE WHEN is_ghost = 1 OR is_non_submitter = 1 OR is_early_disengager = 1\n", - " THEN 1 ELSE 0 END) AS in_any_segment\n", - "FROM student_segments\n", - "'''\n", - "\n", - "df_overlap = execute_query(OVERLAP_SQL)\n", - "row = df_overlap.iloc[0]\n", - "\n", - "# --- Print overlap summary ---\n", - "print('=== Segment Overlap Analysis ===')\n", - "print(f\"Total enrollments: {int(row['total']):>8,}\")\n", - "print(f\"In at least one segment: {int(row['in_any_segment']):>8,} \"\n", - " f\"({100.0 * row['in_any_segment'] / row['total']:.1f}%)\")\n", - "print()\n", - "print('Pairwise overlaps:')\n", - "print(f\" Ghost ∩ Non-submitter: {int(row['ghost_and_nonsub']):>6,}\")\n", - "print(f\" Ghost ∩ Disengager: {int(row['ghost_and_disengager']):>6,}\")\n", - "print(f\" Non-submitter ∩ Disengager:{int(row['nonsub_and_disengager']):>6,}\")\n", - "print(f\" All three: {int(row['all_three']):>6,}\")\n", - "print()\n", - "print('Exclusive membership (this segment only):')\n", - "print(f\" Ghost only: {int(row['ghost_only']):>6,}\")\n", - "print(f\" Non-submitter only: {int(row['nonsub_only']):>6,}\")\n", - "print(f\" Disengager only: {int(row['disengager_only']):>6,}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "11", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Figure: Segment overlap ---\n", - "# Horizontal bar showing exclusive and overlapping membership counts.\n", - "# This helps understand how much the interventions would target the same students.\n", - "overlap_data = pd.DataFrame({\n", - " 'category': [\n", - " 'Ghost only',\n", - " 'Non-submitter only',\n", - " 'Disengager only',\n", - " 'Ghost + Non-submitter',\n", - " 'Non-submitter + Disengager',\n", - " 'Ghost + Disengager',\n", - " 'All three',\n", - " ],\n", - " 'n': [\n", - " int(row['ghost_only']),\n", - " int(row['nonsub_only']),\n", - " int(row['disengager_only']),\n", - " # Pairwise exclusive: subtract three-way overlap to avoid double-counting\n", - " int(row['ghost_and_nonsub']) - int(row['all_three']),\n", - " int(row['nonsub_and_disengager']) - int(row['all_three']),\n", - " int(row['ghost_and_disengager']) - int(row['all_three']),\n", - " int(row['all_three']),\n", - " ],\n", - "})\n", - "\n", - "# Filter out zero-count categories for cleaner visualization\n", - "overlap_data = overlap_data[overlap_data['n'] > 0].sort_values('n', ascending=True)\n", - "\n", - "# Color mapping: exclusive categories get the segment color,\n", - "# overlap categories get a neutral gray\n", - "overlap_colors = []\n", - "for cat in overlap_data['category']:\n", - " if cat == 'Ghost only':\n", - " overlap_colors.append(PALETTE_SEGMENT['Ghost students'])\n", - " elif cat == 'Non-submitter only':\n", - " overlap_colors.append(PALETTE_SEGMENT['Assessment non-submitters'])\n", - " elif cat == 'Disengager only':\n", - " overlap_colors.append(PALETTE_SEGMENT['Early disengagers'])\n", - " else:\n", - " overlap_colors.append('#999999')\n", - "\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "y_pos = np.arange(len(overlap_data))\n", - "\n", - "ax.barh(y_pos, overlap_data['n'].values, color=overlap_colors, edgecolor='white')\n", - "for i, (_, cat_row) in enumerate(overlap_data.iterrows()):\n", - " n_val = cat_row['n']\n", - " pct = 100.0 * n_val / total_students\n", - " ax.text(\n", - " n_val + total_students * 0.005, i,\n", - " f'{n_val:,} ({pct:.1f}%)',\n", - " va='center', fontsize=9, color='#333333',\n", - " )\n", - "\n", - "ax.set_yticks(y_pos)\n", - "ax.set_yticklabels(overlap_data['category'].values)\n", - "ax.set_xlabel(LABEL_NUM_STUDENTS)\n", - "ax.set_title(\n", - " 'Segment Overlap — Exclusive and Shared Membership\\n'\n", - " '(gray bars = students in multiple segments)'\n", - ")\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '07_segment_overlap')\n", - "plt.show()\n", - "\n", - "# --- Key overlap metric for downstream impact estimation ---\n", - "# Percentage of ghost students who are also non-submitters\n", - "ghost_total = int(row['n_ghost'])\n", - "ghost_nonsub_overlap = int(row['ghost_and_nonsub'])\n", - "if ghost_total > 0:\n", - " overlap_pct = 100.0 * ghost_nonsub_overlap / ghost_total\n", - " print(f'\\nGhost ∩ Non-submitter overlap: {ghost_nonsub_overlap:,} of '\n", - " f'{ghost_total:,} ghost students ({overlap_pct:.0f}%)')\n", - " print(' → Ghost activation and assessment reminders would largely target '\n", - " 'the same students.')" - ] - }, - { - "cell_type": "markdown", - "id": "12", - "metadata": {}, - "source": [ - "> **Insight sulla sovrapposizione:** Studenti ghost e non-submitter delle valutazioni si sovrappongono pesantemente — uno studente che non accede mai al VLE non può consegnare una valutazione. Questo significa che le **Raccomandazioni 1 e 2 mirano in gran parte alla stessa popolazione** da angolazioni diverse. I disimpegnati precoci, per definizione, hanno avuto *qualche* attività iniziale, quindi si sovrappongono meno con gli studenti ghost. Questo rende la Raccomandazione 3 un intervento indipendente che mira a una diversa modalità di fallimento.\n", - ">\n", - "> **Implicazione per la stima dell'impatto:** A causa della sovrapposizione ghost–non-submitter, l'impatto combinato di tutti e tre gli interventi è **inferiore alla somma degli impatti individuali**. Ogni sezione di raccomandazione sotto stima l'impatto indipendentemente; la Matrice di Priorità (Sezione 7) presenta quelle stime indipendenti e include la sovrapposizione come considerazione interpretativa nella lettura dei totali combinati." - ] - }, - { - "cell_type": "markdown", - "id": "13", - "metadata": {}, - "source": [ - "## 4. Raccomandazione 1 — Attivazione degli studenti ghost\n", - "\n", - "### Il problema\n", - "\n", - "Gli studenti ghost si iscrivono ma non interagiscono mai in modo significativo con il corso. Rappresentano la forma più grave di non-completamento: non un fallimento nel persistere, ma un fallimento nell'*iniziare*.\n", - "\n", - "### Evidenze da BQ1–BQ4\n", - "\n", - "- **BQ2 (NB04):** L'engagement precoce (giorni attivi, click totali nei primi 28 giorni) ha il più grande effect size tra tutti i predittori comportamentali. Gli studenti con zero attività precoce hanno tassi di completamento prossimi allo zero.\n", - "- **BQ3 (NB05):** Il comportamento predice l'esito 2–5× più fortemente dei dati demografici. Questo significa che dobbiamo mirare a *cosa fanno gli studenti* (o non fanno), non a *chi sono*.\n", - "- **BQ1 (NB03):** Una frazione significativa dei ritiri avviene nelle prime due settimane — prima che la maggior parte degli studenti abbia stabilito una routine di studio.\n", - "\n", - "### Intervento proposto\n", - "\n", - "| Elemento | Dettagli |\n", - "|----------|----------|\n", - "| **Trigger** | Lo studente ha zero attività VLE entro il giorno 3 del corso |\n", - "| **Azione** | Sequenza di attivazione automatizzata: email di benvenuto al giorno 3 con link \"primo passo\" a una singola risorsa facile, follow-up al giorno 7 se ancora inattivo |\n", - "| **Canale** | Email + notifica in-platform |\n", - "| **Costo** | **Basso** — solo automazione email, nessuna modifica alla piattaforma richiesta |\n", - "\n", - "### Stima dell'impatto" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "14", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Impact estimation: Ghost student activation ---\n", - "# Compare completion rates between ghost students and active students\n", - "# to quantify the gap that activation could partially close.\n", - "df_ghost_impact = execute_query('''\n", - " SELECT\n", - " CASE\n", - " WHEN COALESCE(ee.active_days_first_28, 0) <= 1\n", - " AND COALESCE(ee.total_clicks_first_28, 0) < 10\n", - " THEN 'Ghost'\n", - " ELSE 'Active'\n", - " END AS student_type,\n", - " COUNT(*) AS n,\n", - " SUM(se.completed) AS n_completed,\n", - " ROUND(100.0 * SUM(se.completed) / COUNT(*), 1) AS completion_rate_pct\n", - " FROM v_student_enriched se\n", - " LEFT JOIN v_engagement_early ee\n", - " ON se.id_student = ee.id_student\n", - " AND se.code_module = ee.code_module\n", - " AND se.code_presentation = ee.code_presentation\n", - " GROUP BY 1\n", - "''')\n", - "\n", - "ghost_row = df_ghost_impact[df_ghost_impact['student_type'] == 'Ghost'].iloc[0]\n", - "active_row = df_ghost_impact[df_ghost_impact['student_type'] == 'Active'].iloc[0]\n", - "\n", - "ghost_n = int(ghost_row['n'])\n", - "ghost_rate = float(ghost_row['completion_rate_pct'])\n", - "active_rate = float(active_row['completion_rate_pct'])\n", - "\n", - "print('=== Ghost vs Active Completion Rates ===')\n", - "print(df_ghost_impact.to_string(index=False))\n", - "print()\n", - "\n", - "# Scenario analysis: if activation emails convert X% of ghost students\n", - "# to minimal engagement, and those converted students achieve the\n", - "# platform-average completion rate (conservative estimate)\n", - "\n", - "print(HEADER_SCENARIO)\n", - "print(f'Ghost students: {ghost_n:,} with {ghost_rate:.1f}% completion rate')\n", - "print(f'Active students: {active_rate:.1f}% completion rate')\n", - "print(f'Platform average: {100.0 - overall_non_completion:.1f}% completion rate')\n", - "print()\n", - "\n", - "# Store scenario results for the priority matrix\n", - "ghost_scenarios = {}\n", - "for conversion_pct in [10, 20, 30]:\n", - " converted = int(ghost_n * conversion_pct / 100)\n", - " # Conservative: converted students achieve the overall average, not the active rate\n", - " target_rate = (100.0 - overall_non_completion) / 100.0\n", - " current_rate = ghost_rate / 100.0\n", - " additional_completions = int(converted * (target_rate - current_rate))\n", - " ghost_scenarios[conversion_pct] = additional_completions\n", - " print(f' If {conversion_pct}% of ghosts activate → ~{additional_completions:,} '\n", - " f'additional completions ({converted:,} converted)')\n", - "\n", - "# Save the middle scenario for the priority matrix\n", - "rec1_impact = ghost_scenarios[20]" - ] - }, - { - "cell_type": "markdown", - "id": "15", - "metadata": {}, - "source": [ - "> **Interpretazione:** Il gap nel tasso di completamento tra studenti ghost e attivi è sostanziale. Anche un tasso di attivazione modesto (20%) produrrebbe completamenti aggiuntivi significativi perché il segmento è grande e il gap è ampio.\n", - ">\n", - "> **Trasparenza sulle assunzioni:** Lo scenario assume che gli studenti convertiti raggiungano il tasso di completamento *medio della piattaforma* — una stima conservativa. In pratica, gli studenti che si attivano in ritardo potrebbero avere performance peggiori rispetto a quelli che hanno iniziato puntualmente. Questo ridurrebbe l'impatto stimato." - ] - }, - { - "cell_type": "markdown", - "id": "16", - "metadata": {}, - "source": [ - "## 5. Raccomandazione 2 — Checkpoint della prima valutazione\n", - "\n", - "### Il problema\n", - "\n", - "Gli studenti che mancano la scadenza della prima valutazione stanno segnalando il disimpegno. Mancare questo milestone precoce interrompe il ciclo di feedback che tiene gli studenti connessi al corso — perdono il senso di progresso che la valutazione precoce fornisce.\n", - "\n", - "### Evidenze da BQ1–BQ4\n", - "\n", - "- **BQ2 (NB04):** `submitted_first_assessment` è un potente predittore binario del completamento. L'effect size è tra i più grandi di tutti i segnali precoci.\n", - "- **BQ4 (NB06):** I corsi con maggiore densità di valutazioni (checkpoint più frequenti) mostrano pattern suggestivi con la retention. Valutazioni regolari potrebbero fornire una struttura che aiuta gli studenti a restare in carreggiata.\n", - "- **BQ1 (NB03):** I cliff di dropout spesso coincidono con le scadenze delle valutazioni, suggerendo che le valutazioni sono punti decisionali dove gli studenti si impegnano o abbandonano.\n", - "\n", - "### Intervento proposto\n", - "\n", - "| Elemento | Dettagli |\n", - "|----------|----------|\n", - "| **Trigger** | La scadenza della prima valutazione è tra 3 giorni e lo studente non ha consegnato |\n", - "| **Azione** | Promemoria automatizzato con anteprima della valutazione: \"Ecco cosa aspettarti — la prima valutazione copre X e richiede approssimativamente Y minuti\" |\n", - "| **Canale** | Email + notifica in-platform + SMS opzionale |\n", - "| **Costo** | **Medio** — richiede un sistema di notifiche consapevole delle scadenze integrato con il calendario del corso |\n", - "\n", - "### Stima dell'impatto" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "17", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Impact estimation: First assessment checkpoint ---\n", - "# Compare completion rates between students who submitted the first\n", - "# assessment (due within 28 days) and those who did not.\n", - "df_assess_impact = execute_query('''\n", - " SELECT\n", - " CASE\n", - " WHEN ea.id_student IS NOT NULL THEN 'Submitted'\n", - " ELSE 'Not submitted'\n", - " END AS assessment_status,\n", - " COUNT(*) AS n,\n", - " SUM(se.completed) AS n_completed,\n", - " ROUND(100.0 * SUM(se.completed) / COUNT(*), 1) AS completion_rate_pct\n", - " FROM v_student_enriched se\n", - " LEFT JOIN (\n", - " SELECT DISTINCT sa.id_student, a.code_module, a.code_presentation\n", - " FROM studentAssessment sa\n", - " JOIN assessments a ON sa.id_assessment = a.id_assessment\n", - " WHERE a.date <= 28\n", - " ) ea\n", - " ON se.id_student = ea.id_student\n", - " AND se.code_module = ea.code_module\n", - " AND se.code_presentation = ea.code_presentation\n", - " GROUP BY 1\n", - "''')\n", - "\n", - "sub_row = df_assess_impact[df_assess_impact['assessment_status'] == 'Submitted'].iloc[0]\n", - "nosub_row = df_assess_impact[df_assess_impact['assessment_status'] == 'Not submitted'].iloc[0]\n", - "\n", - "nosub_n = int(nosub_row['n'])\n", - "nosub_rate = float(nosub_row['completion_rate_pct'])\n", - "sub_rate = float(sub_row['completion_rate_pct'])\n", - "\n", - "print('=== Submitter vs Non-submitter Completion Rates ===')\n", - "print(df_assess_impact.to_string(index=False))\n", - "print()\n", - "\n", - "# Scenario analysis: if reminders convert X% of non-submitters to submitters\n", - "print(HEADER_SCENARIO)\n", - "print(f'Non-submitters: {nosub_n:,} with {nosub_rate:.1f}% completion rate')\n", - "print(f'Submitters: {sub_rate:.1f}% completion rate')\n", - "print()\n", - "\n", - "assess_scenarios = {}\n", - "for conversion_pct in [10, 15, 25]:\n", - " converted = int(nosub_n * conversion_pct / 100)\n", - " # Converted students achieve submitter rate (they actually submitted)\n", - " additional_completions = int(converted * (sub_rate - nosub_rate) / 100.0)\n", - " assess_scenarios[conversion_pct] = additional_completions\n", - " print(f' If {conversion_pct}% of non-submitters submit → ~{additional_completions:,} '\n", - " f'additional completions ({converted:,} converted)')\n", - "\n", - "# Save the middle scenario for the priority matrix\n", - "rec2_impact = assess_scenarios[15]" - ] - }, - { - "cell_type": "markdown", - "id": "18", - "metadata": {}, - "source": [ - "> **Interpretazione:** Il gap tra chi consegna e chi non consegna è netto. La consegna della valutazione è sia un *segnale* (rivela l'impegno) sia un *meccanismo* (crea accountability). Questa doppia natura la rende un punto di intervento ideale.\n", - ">\n", - "> **Cautela sulla causalità:** Consegnare la valutazione potrebbe non *causare* il completamento — entrambi potrebbero essere guidati da un fattore motivazionale sottostante. L'intervento funziona solo se il promemoria spinge studenti che *avrebbero consegnato* con una piccola spinta, non se costringe studenti riluttanti attraverso un cancello. Per questo le assunzioni sul tasso di conversione sono conservative (10–25%)." - ] - }, - { - "cell_type": "markdown", - "id": "19", - "metadata": {}, - "source": [ - "## 6. Raccomandazione 3 — Campagna di re-engagement alla settimana 3\n", - "\n", - "### Il problema\n", - "\n", - "I disimpegnati precoci sono studenti che hanno *iniziato* il corso — hanno avuto attività VLE nelle prime due settimane — ma poi si sono fermati completamente nelle settimane 3–4. A differenza degli studenti ghost che non hanno mai iniziato, questi studenti hanno dimostrato un interesse iniziale ma hanno perso il momentum.\n", - "\n", - "### Evidenze da BQ1–BQ4\n", - "\n", - "- **BQ1 (NB03):** Le curve di dropout mostrano cliff a metà corso che spesso si allineano con le scadenze delle valutazioni o la transizione dai contenuti introduttivi a quelli core. Le settimane 3–4 sono un punto di inflessione critico.\n", - "- **BQ2 (NB04):** `last_active_day_in_window` è un predittore significativo — gli studenti la cui ultima attività è precoce nella finestra hanno bassa probabilità di completare.\n", - "- **BQ3 (NB05):** Il segnale comportamentale (calo di attività) è più predittivo di qualsiasi fattore demografico. L'intervento dovrebbe mirare al comportamento, non al profilo dello studente.\n", - "\n", - "### Intervento proposto\n", - "\n", - "| Elemento | Dettagli |\n", - "|----------|----------|\n", - "| **Trigger** | Lo studente ha avuto ≥1 giorno attivo nei giorni 0–14 ma zero attività nei giorni 15–17 (3 giorni di inattività dopo l'engagement iniziale) |\n", - "| **Azione** | Email \"Ci manchi\" al giorno 18: riepilogo personalizzato dei progressi (\"Hai completato il X% delle attività delle settimane 1–2\"), social proof (\"Studenti come te che si sono ri-impegnati a questo punto hanno completato il corso il Y% delle volte\") e un link diretto alla prossima risorsa |\n", - "| **Canale** | Email + notifica in-platform |\n", - "| **Costo** | **Medio-Alto** — richiede pipeline di tracciamento dell'attività in tempo reale e generazione di messaggi personalizzati |\n", - "\n", - "### Stima dell'impatto" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "20", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Impact estimation: Week 3 re-engagement ---\n", - "# Compare completion rates between students who sustained activity through\n", - "# weeks 3-4 and those who disengaged (had activity in days 0-14 only).\n", - "# We restrict to students who had at least SOME initial activity (days 0-14),\n", - "# since ghost students are addressed by Recommendation 1.\n", - "df_disengage_impact = execute_query('''\n", - " WITH initially_active AS (\n", - " SELECT DISTINCT id_student, code_module, code_presentation\n", - " FROM studentVle\n", - " WHERE date BETWEEN 0 AND 14\n", - " ),\n", - " sustained AS (\n", - " SELECT DISTINCT id_student, code_module, code_presentation\n", - " FROM studentVle\n", - " WHERE date BETWEEN 15 AND 28\n", - " )\n", - " SELECT\n", - " CASE\n", - " WHEN sus.id_student IS NULL THEN 'Disengaged (weeks 3-4)'\n", - " ELSE 'Sustained activity'\n", - " END AS engagement_pattern,\n", - " COUNT(*) AS n,\n", - " SUM(se.completed) AS n_completed,\n", - " ROUND(100.0 * SUM(se.completed) / COUNT(*), 1) AS completion_rate_pct\n", - " FROM v_student_enriched se\n", - " JOIN initially_active ia\n", - " ON se.id_student = ia.id_student\n", - " AND se.code_module = ia.code_module\n", - " AND se.code_presentation = ia.code_presentation\n", - " LEFT JOIN sustained sus\n", - " ON se.id_student = sus.id_student\n", - " AND se.code_module = sus.code_module\n", - " AND se.code_presentation = sus.code_presentation\n", - " GROUP BY 1\n", - "''')\n", - "\n", - "dis_row = df_disengage_impact[\n", - " df_disengage_impact['engagement_pattern'] == 'Disengaged (weeks 3-4)'\n", - "].iloc[0]\n", - "sus_row = df_disengage_impact[\n", - " df_disengage_impact['engagement_pattern'] == 'Sustained activity'\n", - "].iloc[0]\n", - "\n", - "dis_n = int(dis_row['n'])\n", - "dis_rate = float(dis_row['completion_rate_pct'])\n", - "sus_rate = float(sus_row['completion_rate_pct'])\n", - "\n", - "print('=== Sustained vs Disengaged Completion Rates ===')\n", - "print('(restricted to students with initial activity in days 0-14)')\n", - "print(df_disengage_impact.to_string(index=False))\n", - "print()\n", - "\n", - "# Scenario analysis\n", - "print(HEADER_SCENARIO)\n", - "print(f'Early disengagers: {dis_n:,} with {dis_rate:.1f}% completion rate')\n", - "print(f'Sustained students: {sus_rate:.1f}% completion rate')\n", - "print()\n", - "\n", - "disengage_scenarios = {}\n", - "for conversion_pct in [10, 15, 25]:\n", - " converted = int(dis_n * conversion_pct / 100)\n", - " # Re-engaged students achieve a rate between disengaged and sustained\n", - " # (conservative: halfway, not the full sustained rate)\n", - " target_rate = (dis_rate + sus_rate) / 2.0 / 100.0\n", - " current_rate = dis_rate / 100.0\n", - " additional_completions = int(converted * (target_rate - current_rate))\n", - " disengage_scenarios[conversion_pct] = additional_completions\n", - " print(f' If {conversion_pct}% re-engage → ~{additional_completions:,} '\n", - " f'additional completions ({converted:,} re-engaged)')\n", - "\n", - "# Save the middle scenario for the priority matrix\n", - "rec3_impact = disengage_scenarios[15]" - ] - }, - { - "cell_type": "markdown", - "id": "21", - "metadata": {}, - "source": [ - "> **Interpretazione:** I disimpegnati precoci hanno già dimostrato disponibilità a interagire — non sono studenti ghost. Questo significa che un nudge di re-engagement ha un meccanismo plausibile: ricordare a qualcuno che *era* attivo di tornare. La stima dell'impatto usa un target conservativo (a metà strada tra i tassi dei disimpegnati e di chi ha mantenuto l'attività) perché il re-engagement dopo una pausa è più difficile del momentum sostenuto.\n", - ">\n", - "> **Perché questo è l'intervento più complesso:** A differenza della semplice automazione email (Racc. 1) o dei promemoria consapevoli delle scadenze (Racc. 2), questo intervento richiede il tracciamento dei pattern di attività individuali in quasi-tempo reale e la generazione di messaggi personalizzati. Il costo di implementazione più alto è giustificato solo se il segmento è sufficientemente grande — il che è confermato dal dimensionamento BQ5." - ] - }, - { - "cell_type": "markdown", - "id": "22", - "metadata": {}, - "source": [ - "## 7. Matrice di priorità\n", - "\n", - "La matrice di priorità classifica i tre interventi per **impatto stimato** (completamenti aggiuntivi nello scenario intermedio) vs **costo di implementazione** (Basso / Medio / Medio-Alto). Questo framework aiuta un operatore di piattaforma a decidere *cosa costruire per primo*.\n", - "\n", - "Il costo è valutato su una scala 1–3:\n", - "- **1 (Basso):** Solo automazione email, nessuna integrazione con la piattaforma\n", - "- **2 (Medio):** Richiede trigger consapevoli delle scadenze o integrazione con il calendario del corso\n", - "- **3 (Medio-Alto):** Richiede tracciamento dell'attività in tempo reale e messaggistica personalizzata" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "23", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Priority matrix data ---\n", - "priority = pd.DataFrame({\n", - " 'recommendation': [\n", - " 'Ghost Student Activation',\n", - " 'First Assessment Checkpoint',\n", - " 'Week 3 Re-engagement',\n", - " ],\n", - " 'segment': SEGMENT_ORDER,\n", - " 'segment_size': [\n", - " segments.loc[0, 'n'],\n", - " segments.loc[1, 'n'],\n", - " segments.loc[2, 'n'],\n", - " ],\n", - " 'non_completion_rate': [\n", - " segments.loc[0, 'non_completion_rate'],\n", - " segments.loc[1, 'non_completion_rate'],\n", - " segments.loc[2, 'non_completion_rate'],\n", - " ],\n", - " 'est_additional_completions': [rec1_impact, rec2_impact, rec3_impact],\n", - " 'cost_score': [1, 2, 3],\n", - " 'cost_label': ['Low', 'Medium', 'Medium-High'],\n", - "})\n", - "\n", - "# Rank by impact-to-cost ratio so the ordering is data-driven, not hard-coded\n", - "priority['impact_per_cost'] = (\n", - " priority['est_additional_completions'] / priority['cost_score']\n", - ")\n", - "priority = priority.sort_values('impact_per_cost', ascending=False).reset_index(drop=True)\n", - "priority['rank'] = priority.index + 1\n", - "\n", - "print('=== Priority Matrix (ranked by impact/cost ratio) ===')\n", - "print()\n", - "print(priority[[\n", - " 'rank', 'recommendation', 'segment_size',\n", - " 'non_completion_rate', 'est_additional_completions',\n", - " 'cost_label', 'impact_per_cost',\n", - "]].to_string(index=False))\n", - "\n", - "print()\n", - "total_impact = priority['est_additional_completions'].sum()\n", - "print(f'Total estimated additional completions (all 3 interventions): ~{total_impact:,}')\n", - "print('''Note: actual total will be lower due to segment overlap\n", - " (ghost ∩ non-submitter).''')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "24", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Figure: Priority matrix ---\n", - "# Bubble chart: x = cost (categorical), y = estimated impact,\n", - "# bubble size proportional to segment size, colored by segment.\n", - "fig, ax = plt.subplots(figsize=FIG_SIZE)\n", - "\n", - "# Scale bubble sizes for visual clarity (200-800 pixel range)\n", - "sizes = priority['segment_size'].values.astype(float)\n", - "size_min, size_max = sizes.min(), sizes.max()\n", - "# Handle edge case where all segments are the same size\n", - "if size_max > size_min:\n", - " bubble_sizes = 200 + 600 * (sizes - size_min) / (size_max - size_min)\n", - "else:\n", - " bubble_sizes = np.full_like(sizes, 400.0)\n", - "\n", - "colors = [PALETTE_SEGMENT[s] for s in priority['segment']]\n", - "\n", - "# Plot scatter points first so axes scale to the actual data\n", - "for i, (_, p_row) in enumerate(priority.iterrows()):\n", - " ax.scatter(\n", - " p_row['cost_score'], p_row['est_additional_completions'],\n", - " s=bubble_sizes[i], color=colors[i],\n", - " edgecolor='white', linewidth=2, zorder=3, alpha=0.85,\n", - " )\n", - " # Label each bubble with recommendation name\n", - " ax.annotate(\n", - " p_row['recommendation'],\n", - " (p_row['cost_score'], p_row['est_additional_completions']),\n", - " fontsize=9, fontweight='bold', ha='center', va='bottom',\n", - " xytext=(0, 12), textcoords='offset points',\n", - " )\n", - " # Annotate with impact number inside/below bubble\n", - " ax.annotate(\n", - " f\"~{p_row['est_additional_completions']:,}\",\n", - " (p_row['cost_score'], p_row['est_additional_completions']),\n", - " fontsize=8, ha='center', va='top',\n", - " xytext=(0, -10), textcoords='offset points', color='#555555',\n", - " )\n", - "\n", - "# Add subtle quadrant shading AFTER plotting so ylim reflects actual data\n", - "ax.axhspan(\n", - " ymin=0, ymax=ax.get_ylim()[1],\n", - " xmin=0, xmax=0.33, alpha=0.04, color='green',\n", - ")\n", - "\n", - "ax.set_xticks([1, 2, 3])\n", - "ax.set_xticklabels(['Low', 'Medium', 'Medium-High'])\n", - "ax.set_xlabel('Implementation Cost')\n", - "ax.set_ylabel('Estimated Additional Completions\\n(middle scenario)')\n", - "ax.set_title(\n", - " 'Priority Matrix — Impact vs Cost\\n'\n", - " '(bubble size = segment size)'\n", - ")\n", - "ax.set_xlim(0.4, 3.6)\n", - "# Ensure y-axis starts at 0 for honest visual comparison\n", - "ax.set_ylim(bottom=0)\n", - "sns.despine()\n", - "fig.tight_layout()\n", - "save_fig(fig, '07_priority_matrix')\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "25", - "metadata": {}, - "source": [ - "> **Leggere la matrice:** L'intervento ideale si trova nell'angolo in alto a sinistra — alto impatto, basso costo. L'Attivazione degli Studenti Ghost è il chiaro \"quick win\": mira al segmento più grande con il tasso di non-completamento più alto e richiede solo automazione email. Il Checkpoint della Prima Valutazione offre un impatto solido a costo moderato. La Campagna di Re-engagement alla Settimana 3 ha la complessità di implementazione più alta ma mira a una popolazione distinta (non sovrapposta con i ghost), rendendola un'aggiunta preziosa.\n", - ">\n", - "> **Classifica delle priorità:** (1) Attivazione Ghost — iniziare da qui, (2) Checkpoint Valutazione — costruire dopo, (3) Campagna Re-engagement — investire quando l'infrastruttura è pronta." - ] - }, - { - "cell_type": "markdown", - "id": "26", - "metadata": {}, - "source": [ - "## 8. Roadmap di implementazione\n", - "\n", - "Un rollout per fasi minimizza il rischio e permette di validare ogni intervento prima di investire nel successivo.\n", - "\n", - "### Fase 1: Settimane 1–2 — Attivazione degli studenti ghost (Quick Win)\n", - "\n", - "| Step | Azione | Responsabile |\n", - "|------|--------|-------------|\n", - "| 1.1 | Definire il template email: messaggio di benvenuto + link \"primo passo\" a una risorsa | Team contenuti |\n", - "| 1.2 | Configurare il trigger al giorno 3: lo studente ha zero attività VLE dall'iscrizione | Engineering |\n", - "| 1.3 | Configurare il follow-up al giorno 7: ancora inattivo dopo la prima email | Engineering |\n", - "| 1.4 | A/B test: 50% riceve le email, 50% gruppo di controllo | Team dati |\n", - "| 1.5 | Misurare: tasso di attivazione (% di ghost che accedono al VLE entro 14 giorni) | Team dati |\n", - "\n", - "**Metrica di successo:** ≥15% degli studenti ghost targetizzati accede al VLE entro 7 giorni dalla ricezione dell'email.\n", - "\n", - "### Fase 2: Settimane 3–4 — Checkpoint della prima valutazione\n", - "\n", - "| Step | Azione | Responsabile |\n", - "|------|--------|-------------|\n", - "| 2.1 | Integrare il calendario delle valutazioni con il sistema di notifiche | Engineering |\n", - "| 2.2 | Definire il template del promemoria: anteprima della valutazione + stima del tempo | Team contenuti |\n", - "| 2.3 | Configurare il trigger: 3 giorni prima della prima scadenza, lo studente non ha consegnato | Engineering |\n", - "| 2.4 | A/B test contro il gruppo di controllo | Team dati |\n", - "| 2.5 | Misurare: incremento del tasso di consegna e tasso di completamento a valle | Team dati |\n", - "\n", - "**Metrica di successo:** ≥10% di incremento nel tasso di consegna della prima valutazione tra gli studenti targetizzati.\n", - "\n", - "### Fase 3: Settimane 5–8 — Campagna di re-engagement alla settimana 3\n", - "\n", - "| Step | Azione | Responsabile |\n", - "|------|--------|-------------|\n", - "| 3.1 | Costruire la pipeline di tracciamento dell'attività in tempo reale (flag di attività giornaliera) | Engineering |\n", - "| 3.2 | Implementare la generazione di messaggi personalizzati (riepilogo progressi, statistiche peer) | Engineering + Contenuti |\n", - "| 3.3 | Configurare il trigger: 3 giorni consecutivi di inattività dopo l'engagement iniziale | Engineering |\n", - "| 3.4 | A/B test con messaggi di re-engagement personalizzati vs generici | Team dati |\n", - "| 3.5 | Misurare: tasso di re-engagement e incremento del tasso di completamento | Team dati |\n", - "\n", - "**Metrica di successo:** ≥10% dei disimpegnati targetizzati torna all'attività VLE entro 7 giorni.\n", - "\n", - "### Principio trasversale\n", - "\n", - "Tutti e tre gli interventi mirano al **comportamento, non ai dati demografici** — coerente con il risultato di BQ3 che i segnali comportamentali sono predittori più forti. Questo è sia più efficace (mirare alla variabile azionabile) sia più etico (evitare la profilazione demografica)." - ] - }, - { - "cell_type": "markdown", - "id": "27", - "metadata": {}, - "source": [ - "## 9. Limitazioni e avvertenze\n", - "\n", - "Queste raccomandazioni sono proiezioni basate sulle evidenze, non risultati garantiti. Diverse limitazioni devono essere riconosciute:\n", - "\n", - "### Assunzioni sulla stima dell'impatto\n", - "\n", - "- **I tassi di conversione sono assunti, non misurati.** Gli scenari di conversione del 10–25% sono plausibili sulla base di benchmark di settore per interventi basati su email nell'istruzione, ma non sono stati validati con i dati OULAD (non esistono dati di A/B test).\n", - "- **I tassi di completamento target sono conservativi.** Per l'attivazione ghost, assumiamo che gli studenti convertiti raggiungano la media della piattaforma (non il tasso degli studenti attivi). Per il re-engagement, assumiamo un punto intermedio tra i tassi dei disimpegnati e di chi ha mantenuto l'attività. I risultati effettivi potrebbero essere superiori o inferiori.\n", - "- **La sovrapposizione dei segmenti gonfia le somme ingenue degli impatti.** Studenti ghost e non-submitter delle valutazioni si sovrappongono pesantemente. L'impatto combinato di tutti e tre gli interventi è inferiore alla somma delle stime individuali.\n", - "\n", - "### Limitazioni dei dati\n", - "\n", - "- **Solo dati osservazionali.** Tutti gli effect size e le differenze nei tassi di completamento sono associativi, non causali. Uno studente che consegna la prima valutazione potrebbe completare per motivazione, non per la consegna in sé.\n", - "- **I pattern storici potrebbero non reggere.** OULAD copre le coorti 2013–2014. Il comportamento degli studenti, le funzionalità delle piattaforme e il panorama dell'apprendimento online sono cambiati significativamente da allora.\n", - "- **Nessun dato sui costi.** Le stime dei costi di implementazione (Basso/Medio/Medio-Alto) sono qualitative. L'effort ingegneristico effettivo dipende dall'infrastruttura esistente della piattaforma.\n", - "\n", - "### Considerazioni etiche\n", - "\n", - "- Tutti gli interventi mirano al comportamento, non ai dati demografici — nessuno studente viene profilato per genere, età, disabilità o status socioeconomico.\n", - "- Gli interventi automatizzati dovrebbero includere un meccanismo di opt-out per rispettare l'autonomia dello studente.\n", - "- Gli \"studenti ghost\" potrebbero avere ragioni valide per non interagire (circostanze cambiate, iscrizione per errore). L'intervento dovrebbe informare, non pressare." - ] - }, - { - "cell_type": "markdown", - "id": "28", - "metadata": {}, - "source": [ - "## 10. Conclusioni chiave\n", - "\n", - "### Risposta a BQ5: Le 3 principali raccomandazioni operative\n", - "\n", - "| Priorità | Intervento | Dimensione segmento | Tasso di non-completamento | Costo | Evidenza chiave |\n", - "|----------|-----------|---------------------|---------------------------:|-------|----------------|\n", - "| 1 | **Attivazione studenti ghost** | Vedi dimensionamento sopra | Vedi tasso sopra | Basso | BQ2: l'engagement precoce è il predittore più forte |\n", - "| 2 | **Checkpoint prima valutazione** | Vedi dimensionamento sopra | Vedi tasso sopra | Medio | BQ2: la consegna della valutazione è un segnale chiave |\n", - "| 3 | **Re-engagement settimana 3** | Vedi dimensionamento sopra | Vedi tasso sopra | Medio-Alto | BQ1: cliff di dropout a metà corso nelle settimane 3–4 |\n", - "\n", - "### Sintesi di tutte e 5 le business question\n", - "\n", - "| NB | BQ | Risultato chiave |\n", - "|----|-----|-----------------|\n", - "| 03 | BQ1 | Il dropout non è uniforme — si concentra in cliff in corrispondenza di specifici milestone del corso. Il ritiro pre-corso è una frazione significativa. |\n", - "| 04 | BQ2 | I segnali comportamentali precoci (giorni attivi, click, consegna delle valutazioni) predicono il dropout con effect size elevati. I primi 28 giorni sono la finestra critica. |\n", - "| 05 | BQ3 | Il comportamento predice l'esito 2–5× più fortemente dei dati demografici. Gli interventi dovrebbero mirare a cosa *fanno* gli studenti, non a chi *sono*. |\n", - "| 06 | BQ4 | I tassi di completamento variano sostanzialmente tra i corsi. Le feature di progettazione del corso (densità delle valutazioni, diversità delle risorse) mostrano associazioni suggestive con la retention. |\n", - "| 07 | BQ5 | Tre raccomandazioni operative — attivazione ghost, checkpoint valutazione, campagna di re-engagement — mirano ai più grandi segmenti a rischio con impatto stimato sui dati. |\n", - "\n", - "### La pipeline analitica è completa\n", - "\n", - "Questo notebook conclude il lavoro analitico. Tutti i risultati saranno consolidati in `REPORT.md` per la comunicazione agli stakeholder. Il prossimo passo è il deployment: A/B testing di ogni intervento, misurazione dei tassi di conversione effettivi e iterazione.\n", - "\n", - "---\n", - "\n", - "**Riproducibilità:** Tutte le figure sono salvate in `reports/figures/`. Per riprodurre questo notebook, eseguire prima `python -m run_pipeline`, poi eseguire tutte le celle in ordine." - ] - }, - { - "cell_type": "markdown", - "id": "29", - "metadata": {}, - "source": [ - "> **Dall'analisi all'azione:** Questo progetto è iniziato con un dataset e cinque domande. Sette notebook dopo, abbiamo un quadro completo: quando gli studenti se ne vanno, cosa lo predice, cosa conta di più (il comportamento), come differiscono i corsi e — cosa più importante — cosa può fare un operatore di piattaforma. I tre interventi proposti qui non sono speculativi: sono dimensionati su dati reali, supportati da evidenze statistiche e classificati per fattibilità.\n", - ">\n", - "> Il gap tra analisi e impatto è un A/B test. Questo è il prossimo passo." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.13.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/reports/REPORT_IT.md b/reports/it/REPORT.md similarity index 94% rename from reports/REPORT_IT.md rename to reports/it/REPORT.md index 76f3255..983216c 100644 --- a/reports/REPORT_IT.md +++ b/reports/it/REPORT.md @@ -50,7 +50,7 @@ modulo, presentazioni (coorti) diverse seguono traiettorie sostanzialmente simil suggerendo che è il design del corso, non la variazione casuale della coorte, a determinare la forma dell'abbandono. -![Curve cumulative di abbandono per tutti i 7 corsi](figures/03_dropout_curves_overlaid.png) +![Curve cumulative di abbandono per tutti i 7 corsi](../figures/03_dropout_curves_overlaid.png) *Le curve cumulative di abbandono mostrano profili temporali distinti per corso. Ogni linea rappresenta una presentazione del corso, colorata per modulo.* @@ -60,7 +60,7 @@ I **cliff event**, giorni con un numero sproporzionatamente alto di ritiri (sopr dei voti. Questi sono azionabili: gli interventi possono essere programmati prima delle date dei cliff event. -![Principali cliff event di abbandono](figures/03_dropout_cliffs.png) +![Principali cliff event di abbandono](../figures/03_dropout_cliffs.png) *Cliff event rilevati tramite soglia p95. I picchi di abbandono più grandi in un singolo giorno corrispondono a tappe del corso.* @@ -71,7 +71,7 @@ puro churn di registrazione: studenti che si sono iscritti ma non hanno mai frui di alcun contenuto. Si tratta di un problema di attivazione, non accademico. -![Ritiri pre-corso per modulo](figures/03_precourse_withdrawals.png) +![Ritiri pre-corso per modulo](../figures/03_precourse_withdrawals.png) *Ritiri pre-corso per modulo. Questi studenti necessitano di nudge di onboarding, non di supporto accademico.* @@ -100,7 +100,7 @@ dall'ultimo giorno attivo, dal punteggio della prima valutazione e dall'intensit dei click (d tra 0,52 e 0,55; i segnali basati sulle valutazioni sono calcolati sulla sola sottopopolazione dei submitter). -![Forest plot degli effect size](figures/04_forest_plot_effect_sizes.png) +![Forest plot degli effect size](../figures/04_forest_plot_effect_sizes.png) *Tutti gli 8 segnali classificati per d di Cohen. I punti verdi indicano significatività dopo correzione Benjamini-Hochberg. Le linee di riferimento verticali segnano le soglie @@ -114,7 +114,7 @@ sovrappongono. (Nota: BQ5 amplia questa definizione per includere attività quas cioè al massimo 1 giorno attivo e meno di 10 click, per catturare l'intero segmento a rischio ai fini del targeting degli interventi.) -![Tasso di completamento ghost vs attivi](figures/04_ghost_vs_active_completion.png) +![Tasso di completamento ghost vs attivi](../figures/04_ghost_vs_active_completion.png) *Gli studenti ghost (zero attività VLE nei primi 28 giorni) hanno tassi di completamento prossimi allo zero. Le barre di errore mostrano intervalli di confidenza bootstrap al 95%.* @@ -123,7 +123,7 @@ La relazione dose-risposta è **monotonica**: più engagement predice costanteme completamento più alto, senza soglia né rendimenti decrescenti. Questo significa che il segnale è utile lungo tutto il suo range, non solo agli estremi. -![Dose-risposta per i segnali principali](figures/04_top_signal_dose_response.png) +![Dose-risposta per i segnali principali](../figures/04_top_signal_dose_response.png) *Tasso di completamento per quartile del segnale per i 3 predittori principali. La relazione è graduata, non binaria.* @@ -157,7 +157,7 @@ valutazioni, intensità click) mostrano effect size diverse volte superiori. Il netto: i segnali comportamentali predicono l'esito molto più fortemente di qualsiasi variabile demografica. -![Confronto demografia vs comportamento](figures/05_demographics_vs_behavior_comparison.png) +![Confronto demografia vs comportamento](../figures/05_demographics_vs_behavior_comparison.png) *Confronto diretto degli effect size demografici e comportamentali. Il divario è sostanziale: i segnali comportamentali sono costantemente più forti.* @@ -169,7 +169,7 @@ studente con un livello di istruzione formale inferiore ma alto engagement ha pi probabilità di completare rispetto a uno studente altamente istruito che non interagisce con la piattaforma. -![Interazione istruzione × engagement](figures/05_education_engagement_interaction.png) +![Interazione istruzione × engagement](../figures/05_education_engagement_interaction.png) *All'interno di ogni livello di istruzione, il gap di engagement sovrasta il gap educativo. Il comportamento è il fattore determinante, non il background.* @@ -193,7 +193,7 @@ Il design del corso stesso influenza i livelli di engagement? Il grafico di ranking sottostante mostra l'intera distribuzione. Il modulo AAA trattiene quasi tre quarti dei suoi studenti; il modulo CCC ne perde quasi due terzi. -![Ranking completamento per corso](figures/06_course_completion_ranking.png) +![Ranking completamento per corso](../figures/06_course_completion_ranking.png) *I tassi di completamento vanno dal 37,4% al 70,9% tra i 7 moduli OULAD.* @@ -202,7 +202,7 @@ del corso (densità valutazioni, durata) e i tassi di completamento. Tuttavia, c qualsiasi correlazione è descrittiva, non inferenziale: la correlazione di Spearman richiede |rho| > 0,79 per la significatività a questa dimensione campionaria. -![Design del corso vs completamento](figures/06_course_design_vs_completion.png) +![Design del corso vs completamento](../figures/06_course_design_vs_completion.png) *La densità delle valutazioni e la durata del corso mostrano associazioni suggestive con il completamento. Ogni punto è un modulo (mediato sulle sue presentazioni).* @@ -268,12 +268,12 @@ impatto non va sommato ingenuamente. Gli early disengager, per definizione, hann attività iniziale: si sovrappongono meno con i ghost, rendendo l'intervento 3 una leva indipendente che targetizza un failure mode diverso. -![Matrice delle priorità](figures/07_priority_matrix.png) +![Matrice delle priorità](../figures/07_priority_matrix.png) *Matrice priorità impatto-vs-costo. L'Attivazione Ghost è il chiaro quick win: segmento più grande, eccesso di non completamento più alto, costo più basso.* -![Sovrapposizione dei segmenti](figures/07_segment_overlap.png) +![Sovrapposizione dei segmenti](../figures/07_segment_overlap.png) *Analisi della sovrapposizione dei segmenti. Le barre grigie mostrano studenti appartenenti a più segmenti. La sovrapposizione ghost–non-submitter è sostanziale.* diff --git a/run_pipeline.py b/run_pipeline.py index f31d17d..d99a2a9 100644 --- a/run_pipeline.py +++ b/run_pipeline.py @@ -6,6 +6,7 @@ python -m run_pipeline --step ingest # single step only python -m run_pipeline --step transform # single step only python -m run_pipeline --step export # single step only + python -m run_pipeline --step stats # single step only Each step is idempotent, so re-running the pipeline always produces a consistent result. The --sample flag is essential for CI and testing, @@ -18,6 +19,7 @@ from src.pipeline.step_01_ingest import ingest from src.pipeline.step_02_transform import transform from src.pipeline.step_03_export import export +from src.pipeline.step_04_stats import compute_stats from src.utils.logging import setup_logging from src.utils.runtime import log_environment, step_timer @@ -31,12 +33,13 @@ def main() -> None: ) parser.add_argument( "--step", - choices=["ingest", "transform", "export"], + choices=["ingest", "transform", "export", "stats"], default=None, help=( "Run a single pipeline step instead of the full pipeline. " "'transform' requires a previous 'ingest' or full pipeline run. " - "'export' requires a previous 'transform' or full pipeline run." + "'export' and 'stats' require a previous 'transform' or " + "full pipeline run." ), ) parser.add_argument( @@ -76,6 +79,10 @@ def main() -> None: with step_timer("Step 03 — Export"): export() + if run_step in (None, "stats"): + with step_timer("Step 04: Stats"): + compute_stats() + logger.info("Pipeline complete") diff --git a/src/pipeline/step_04_stats.py b/src/pipeline/step_04_stats.py new file mode 100644 index 0000000..cdb05e5 --- /dev/null +++ b/src/pipeline/step_04_stats.py @@ -0,0 +1,380 @@ +"""Step 04: statistical inference export, BQ2/BQ3 test results to CSV. + +Materializes the statistical evidence behind BQ2 and BQ3 as CSV files +under data/analysis/. Notebook outputs are stripped by nbstripout, so +without this step the published p-values and effect sizes would have no +committed, reproducible artifact to be checked against. Every inferential +number quoted in reports/REPORT.md for BQ2/BQ3 is traceable to these files. + +The computations mirror notebooks 04 and 05 exactly: same SQL queries, +same test wrappers from src/stats/tests.py, same correction families. +This guarantees the exported values match the published figures by +construction, not by transcription. +""" + +import logging +from pathlib import Path + +import duckdb +import numpy as np +import pandas as pd + +from src.config import ANALYSIS_DIR, QUERIES_DIR +from src.db.connection import execute_query, get_default_connection + +# Reuse step 03's CSV writer so every exported artifact shares the same +# conventions (UTF-8, no index). Private by naming, but the pipeline +# steps form one cohesive package with a single export style. +from src.pipeline.step_03_export import _export_dataframe +from src.stats.tests import ( + TestResult, + apply_multiple_comparison_correction, + bootstrap_ci, + chi_square_test, + independent_t_test, +) + +logger = logging.getLogger(__name__) + +# Significance threshold for the significant_* flags in the exported CSVs. +# 0.05 matches the notebooks; the flags are a convenience for dashboard +# consumers — effect size remains the primary ranking criterion. +ALPHA: float = 0.05 + +# Bootstrap resamples for the ghost/active completion-rate CIs. +# 2000 matches notebook 04, and bootstrap_ci uses a fixed seed, so the +# exported intervals reproduce the published figure exactly. +N_BOOTSTRAP: int = 2000 + +# The 8 early behavioral signals tested in BQ2 (mirrors notebook 04). +SIGNAL_COLUMNS: list[str] = [ + "active_days_first_28", + "total_clicks_first_28", + "avg_clicks_per_active_day", + "last_active_day_in_window", + "engagement_decile_in_course", + "first_score", + "first_submit_day", + "date_registration", +] + +# BQ3 feature groups (mirrors notebook 05). Multiple-comparison +# corrections are applied WITHIN each family, never across families: +# merging families would change every adjusted p-value. +DEMO_CATEGORICAL: list[str] = [ + "gender", + "age_band", + "highest_education", + "imd_band", + "disability", + "region", +] +DEMO_NUMERIC: list[str] = ["num_of_prev_attempts", "studied_credits"] +BEHAV_COLUMNS: list[str] = [ + "active_days_first_28", + "total_clicks_first_28", + "avg_clicks_per_active_day", + "engagement_decile_in_course", + "submitted_first_assessment", + "first_score", +] + +# Features measured on a reduced population: their NULLs mark a meaningful +# segment (no VLE activity, no early submission), not random missingness. +# Flagged in the export so downstream consumers do not compare them +# head-to-head with full-population features. +CONDITIONAL_FEATURES: set[str] = {"engagement_decile_in_course", "first_score"} + + +def _load_query(filename: str, conn: duckdb.DuckDBPyConnection) -> pd.DataFrame: + """Read a BQ query from sql/queries/ and return its result set. + + Rows are pinned to a canonical order because DuckDB's parallel + execution does not guarantee it. Order matters here in two ways: + bootstrap resampling draws by index, and floating-point summation + is not associative: without a fixed order, repeated runs would + produce slightly different (non-reproducible) exported numbers. + """ + sql: str = (QUERIES_DIR / filename).read_text(encoding="utf-8") + df: pd.DataFrame = execute_query(sql, conn=conn) + return df.sort_values( + ["id_student", "code_module", "code_presentation"] + ).reset_index(drop=True) + + +def _t_test_or_none( + group1: pd.Series, + group2: pd.Series, + name: str, +) -> TestResult | None: + """Run a t-test, returning None instead of raising on degenerate input. + + The wrapper raises when a group has fewer than 2 finite values, which + can happen on the synthetic sample (few submitters). The pipeline must + not crash on the sample: the variable is skipped with a warning, and + the correction family then covers only the tests actually run. + """ + try: + return independent_t_test(group1, group2, variable_name=name) + except ValueError as exc: + logger.warning("Skipping t-test %s: %s", name, exc) + return None + + +def _bq2_signal_tests(df: pd.DataFrame) -> pd.DataFrame: + """Welch t-tests on the 8 BQ2 signals, with Bonferroni and BH. + + Mirrors notebook 04 sections 4-5: group1 = completed, group2 = not + completed, so a positive Cohen's d means completers score higher. + """ + completed: pd.DataFrame = df[df["completed"] == 1] + not_completed: pd.DataFrame = df[df["completed"] == 0] + + rows: list[dict] = [] + for col in SIGNAL_COLUMNS: + g1: pd.Series = completed[col].dropna() + g2: pd.Series = not_completed[col].dropna() + result: TestResult | None = _t_test_or_none(g1, g2, col) + if result is None: + continue + rows.append( + { + "signal": col, + "n_completed": result.n_group1, + "n_not_completed": result.n_group2, + "mean_completed": float(g1.mean()), + "mean_not_completed": float(g2.mean()), + "t_statistic": result.statistic, + "p_value": result.p_value, + "cohens_d": result.effect_size, + "ci_lower": result.ci_lower, + "ci_upper": result.ci_upper, + } + ) + + out: pd.DataFrame = pd.DataFrame(rows) + if out.empty: + return out + + raw_p: list[float] = out["p_value"].tolist() + out["p_bonferroni"] = apply_multiple_comparison_correction(raw_p, "bonferroni") + out["p_bh"] = apply_multiple_comparison_correction(raw_p, "benjamini-hochberg") + out["significant_bonferroni"] = out["p_bonferroni"] < ALPHA + out["significant_bh"] = out["p_bh"] < ALPHA + + # Rank by |d|: with ~32K enrollments nearly everything is significant, + # so effect size, not p-value, is the ordering that matters downstream. + out = out.sort_values( + "cohens_d", key=lambda s: s.abs(), ascending=False + ).reset_index(drop=True) + return out[ + [ + "signal", + "n_completed", + "n_not_completed", + "mean_completed", + "mean_not_completed", + "t_statistic", + "p_value", + "p_bonferroni", + "p_bh", + "significant_bonferroni", + "significant_bh", + "cohens_d", + "ci_lower", + "ci_upper", + ] + ] + + +def _bq2_ghost_segments(df: pd.DataFrame) -> pd.DataFrame: + """Ghost vs active completion rates with 95% bootstrap CIs. + + Mirrors notebook 04 section 9. Ghost = zero VLE activity in the first + 28 days (the BQ2 query COALESCEs missing activity to 0). Bootstrap is + used because the ghost completion rate sits near the [0, 1] boundary, + where parametric CI assumptions are weakest. + """ + is_ghost: pd.Series = df["active_days_first_28"] == 0 + + rows: list[dict] = [] + for segment, mask in (("ghost", is_ghost), ("active", ~is_ghost)): + outcomes: pd.Series = df.loc[mask, "completed"] + if len(outcomes) == 0: + logger.warning("Skipping segment '%s': no enrollments", segment) + continue + ci_low, ci_up = bootstrap_ci( + outcomes, statistic_fn=np.mean, n_bootstrap=N_BOOTSTRAP + ) + rows.append( + { + "segment": segment, + "n_enrollments": int(len(outcomes)), + "completion_rate": float(outcomes.mean()), + "ci_lower_95": ci_low, + "ci_upper_95": ci_up, + } + ) + return pd.DataFrame(rows) + + +def _bq3_demographic_tests(df: pd.DataFrame) -> pd.DataFrame: + """Chi-square (categorical) and t-tests (numeric) on demographics. + + Mirrors notebook 05 Part A: one BH family across all 8 demographic + tests, chi-square and t-test together. NULLs are dropped per variable + (imd_band has missing values in the source data). + """ + rows: list[dict] = [] + for col in DEMO_CATEGORICAL: + valid: pd.DataFrame = df[[col, "completed"]].dropna() + contingency: pd.DataFrame = pd.crosstab(valid[col], valid["completed"]) + try: + result: TestResult = chi_square_test(contingency, variable_name=col) + except ValueError as exc: + logger.warning("Skipping chi-square %s: %s", col, exc) + continue + rows.append( + { + "feature": col, + "test": "chi-square", + "n": result.n_group1, + "n_categories": contingency.shape[0], + "statistic": result.statistic, + "p_value": result.p_value, + # Cramér's V is non-negative by construction, so the + # signed and absolute columns coincide for chi-square rows. + "effect_size": result.effect_size, + "abs_effect_size": result.effect_size, + "effect_size_name": result.effect_size_name, + } + ) + + completed: pd.DataFrame = df[df["completed"] == 1] + not_completed: pd.DataFrame = df[df["completed"] == 0] + for col in DEMO_NUMERIC: + t_result: TestResult | None = _t_test_or_none( + completed[col].dropna(), not_completed[col].dropna(), col + ) + if t_result is None: + continue + rows.append( + { + "feature": col, + "test": "t-test", + "n": t_result.n_group1 + t_result.n_group2, + "n_categories": np.nan, + "statistic": t_result.statistic, + "p_value": t_result.p_value, + "effect_size": t_result.effect_size, + "abs_effect_size": abs(t_result.effect_size), + "effect_size_name": t_result.effect_size_name, + } + ) + + out: pd.DataFrame = pd.DataFrame(rows) + if out.empty: + return out + + out["p_bh"] = apply_multiple_comparison_correction( + out["p_value"].tolist(), "benjamini-hochberg" + ) + out["significant_bh"] = out["p_bh"] < ALPHA + return out.sort_values("abs_effect_size", ascending=False).reset_index(drop=True) + + +def _bq3_behavioral_tests(df: pd.DataFrame) -> pd.DataFrame: + """Welch t-tests on the 6 BQ3 behavioral features, with BH. + + Mirrors notebook 05 Part B. Conditional features (engagement decile, + first score) are tested on reduced populations and flagged as such. + """ + completed: pd.DataFrame = df[df["completed"] == 1] + not_completed: pd.DataFrame = df[df["completed"] == 0] + + rows: list[dict] = [] + for col in BEHAV_COLUMNS: + result: TestResult | None = _t_test_or_none( + completed[col].dropna(), not_completed[col].dropna(), col + ) + if result is None: + continue + rows.append( + { + "feature": col, + "conditional_population": col in CONDITIONAL_FEATURES, + "n_completed": result.n_group1, + "n_not_completed": result.n_group2, + "t_statistic": result.statistic, + "p_value": result.p_value, + "cohens_d": result.effect_size, + "abs_cohens_d": abs(result.effect_size), + "ci_lower": result.ci_lower, + "ci_upper": result.ci_upper, + } + ) + + out: pd.DataFrame = pd.DataFrame(rows) + if out.empty: + return out + + out["p_bh"] = apply_multiple_comparison_correction( + out["p_value"].tolist(), "benjamini-hochberg" + ) + out["significant_bh"] = out["p_bh"] < ALPHA + return out.sort_values("abs_cohens_d", ascending=False).reset_index(drop=True) + + +def compute_stats( + conn: duckdb.DuckDBPyConnection | None = None, + output_dir: Path | None = None, +) -> list[Path]: + """Compute all BQ2/BQ3 statistical tests and export them to CSV. + + Parameters + ---------- + conn : DuckDBPyConnection or None + Database connection. If None, opens the default project DB read-only. + output_dir : Path or None + Output directory for CSVs. Defaults to data/analysis/. + + Returns + ------- + list[Path] + Paths to all exported CSV files. + """ + if output_dir is None: + output_dir = ANALYSIS_DIR + + own_conn: bool = conn is None + if own_conn: + conn = get_default_connection(read_only=True) + + exported: list[Path] = [] + + try: + df_bq2: pd.DataFrame = _load_query("q_bq2_early_signals.sql", conn) + df_bq3: pd.DataFrame = _load_query("q_bq3_demographics_vs_behavior.sql", conn) + + outputs: dict[str, pd.DataFrame] = { + "stats_bq2_early_signals": _bq2_signal_tests(df_bq2), + "stats_bq2_ghost_segments": _bq2_ghost_segments(df_bq2), + "stats_bq3_demographics": _bq3_demographic_tests(df_bq3), + "stats_bq3_behavior": _bq3_behavioral_tests(df_bq3), + } + + for name, df in outputs.items(): + # An empty frame means every test in the family was skipped; + # writing a header-only CSV would look like a valid artifact. + if df.empty: + logger.warning("No results for %s: file not written", name) + continue + exported.append(_export_dataframe(df, name, output_dir)) + + logger.info("Stats export complete: %d files → %s", len(exported), output_dir) + + finally: + if own_conn: + conn.close() + + return exported diff --git a/src/stats/tests.py b/src/stats/tests.py index 160525e..1642e23 100644 --- a/src/stats/tests.py +++ b/src/stats/tests.py @@ -91,8 +91,12 @@ def independent_t_test( # which is safer for groups that may have very different sizes t_stat, p_val = stats.ttest_ind(g1, g2, equal_var=False) - # Cohen's d: standardized mean difference - # Uses pooled standard deviation as the denominator + # Cohen's d: standardized mean difference. + # Deliberate asymmetry with the test above: the p-value comes from + # Welch (robust to unequal variances), while d keeps Cohen's original + # pooled-SD denominator. Pooled d is the convention effect-size + # benchmarks (0.2/0.5/0.8) were calibrated on, so switching the + # denominator would make our values non-comparable with literature. pooled_std: float = np.sqrt( ((len(g1) - 1) * np.var(g1, ddof=1) + (len(g2) - 1) * np.var(g2, ddof=1)) / (len(g1) + len(g2) - 2) @@ -187,6 +191,11 @@ def chi_square_test( "chi-square test is not applicable." ) + # scipy applies Yates' continuity correction automatically on 2×2 + # tables (e.g. gender × outcome) and skips it on larger ones. We keep + # the default: with our sample sizes (~32K) the correction barely + # moves chi2, but disabling it would silently diverge from the + # standard textbook treatment of 2×2 tables. chi2, p_val, dof, _ = stats.chi2_contingency(observed) # Cramér's V: effect size for chi-square @@ -265,7 +274,13 @@ def apply_multiple_comparison_correction( next_idx = sorted_indices[i + 1] adjusted[idx] = min(adjusted[idx], adjusted[next_idx]) - return adjusted + # Floating-point guard: for the largest p-value the formula is + # p * n / n, and the two roundings can land one ulp BELOW the raw + # p. Theory guarantees adjusted >= raw (n/rank >= 1), so clamp to + # restore the invariant. The shift is at most one ulp: invisible + # at any reported precision, and it cannot break monotonicity + # because raw p-values are themselves non-decreasing in rank. + return [max(adj, p) for adj, p in zip(adjusted, p_values)] raise ValueError(f"Unknown correction method: {method}") diff --git a/tests/test_notebooks_it.py b/tests/test_notebooks_it.py deleted file mode 100644 index 91cfb0d..0000000 --- a/tests/test_notebooks_it.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Structural parity tests for Italian notebook translations. - -Verify that each Italian notebook in notebooks/it/ is a faithful -structural mirror of its English counterpart in notebooks/: - - Same number of cells - - Same cell types in the same order - - Code cells are byte-identical (no accidental edits) - - Markdown cells are non-empty - - Markdown cells actually differ from English (translation happened) - -These tests do NOT require a database connection — they only read -the .ipynb JSON files on disk. -""" - -import json -from pathlib import Path - -import pytest - -# --------------------------------------------------------------------------- -# Paths and notebook list -# --------------------------------------------------------------------------- - -PROJECT_ROOT = Path(__file__).resolve().parent.parent -EN_DIR = PROJECT_ROOT / "notebooks" -IT_DIR = PROJECT_ROOT / "notebooks" / "it" - -# All 7 analysis notebooks (same filenames in both directories) -NOTEBOOK_NAMES: list[str] = [ - "01_eda_student_base.ipynb", - "02_eda_engagement_patterns.ipynb", - "03_bq1_dropout_timing.ipynb", - "04_bq2_early_signals.ipynb", - "05_bq3_demographics_vs_behavior.ipynb", - "06_bq4_course_comparison.ipynb", - "07_bq5_recommendations_synthesis.ipynb", -] - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _load_notebook(path: Path) -> dict: - """Load a .ipynb file and return the parsed JSON.""" - with open(path, encoding="utf-8") as f: - return json.load(f) - - -def _cell_source(cell: dict) -> str: - """Join cell source lines into a single string for comparison.""" - return "".join(cell.get("source", [])) - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestITNotebookExists: - """Each English notebook must have an Italian counterpart.""" - - @pytest.mark.parametrize("nb_name", NOTEBOOK_NAMES) - def test_it_notebook_exists(self, nb_name: str) -> None: - it_path = IT_DIR / nb_name - assert ( - it_path.is_file() - ), f"Italian notebook missing: {it_path.relative_to(PROJECT_ROOT)}" - - -class TestCellStructure: - """Italian notebooks must have the same cell structure as English.""" - - @pytest.mark.parametrize("nb_name", NOTEBOOK_NAMES) - def test_same_number_of_cells(self, nb_name: str) -> None: - """IT notebook must have exactly the same number of cells as EN.""" - en_nb = _load_notebook(EN_DIR / nb_name) - it_nb = _load_notebook(IT_DIR / nb_name) - - n_en = len(en_nb["cells"]) - n_it = len(it_nb["cells"]) - assert n_en == n_it, f"{nb_name}: EN has {n_en} cells but IT has {n_it}" - - @pytest.mark.parametrize("nb_name", NOTEBOOK_NAMES) - def test_same_cell_types(self, nb_name: str) -> None: - """Cell types must match in the same order (markdown, code, ...).""" - en_nb = _load_notebook(EN_DIR / nb_name) - it_nb = _load_notebook(IT_DIR / nb_name) - - en_types = [c["cell_type"] for c in en_nb["cells"]] - it_types = [c["cell_type"] for c in it_nb["cells"]] - assert ( - en_types == it_types - ), f"{nb_name}: cell type sequence differs between EN and IT" - - -class TestCodeCellsIdentical: - """Code cells must be byte-identical — translation touches only markdown.""" - - @pytest.mark.parametrize("nb_name", NOTEBOOK_NAMES) - def test_code_cells_identical(self, nb_name: str) -> None: - en_nb = _load_notebook(EN_DIR / nb_name) - it_nb = _load_notebook(IT_DIR / nb_name) - - for i, (en_cell, it_cell) in enumerate( - zip(en_nb["cells"], it_nb["cells"], strict=True) - ): - if en_cell["cell_type"] != "code": - continue - en_src = _cell_source(en_cell) - it_src = _cell_source(it_cell) - assert en_src == it_src, ( - f"{nb_name} cell {i}: code cell differs between EN and IT. " - f"Code cells must remain byte-identical." - ) - - -class TestMarkdownCellsNotEmpty: - """Every markdown cell in the IT notebook must have non-empty content.""" - - @pytest.mark.parametrize("nb_name", NOTEBOOK_NAMES) - def test_markdown_cells_not_empty(self, nb_name: str) -> None: - it_nb = _load_notebook(IT_DIR / nb_name) - - empty_cells: list[int] = [] - for i, cell in enumerate(it_nb["cells"]): - if cell["cell_type"] != "markdown": - continue - if not _cell_source(cell).strip(): - empty_cells.append(i) - - assert ( - len(empty_cells) == 0 - ), f"{nb_name}: empty markdown cells at indices {empty_cells}" - - -class TestMarkdownCellsTranslated: - """At least 80% of markdown cells must differ from the English version. - - Some short cells (e.g. '### 5a. Gender') may remain identical after - translation because they contain only a section header with no - translatable text. We allow up to 20% identical cells. - """ - - # Minimum fraction of markdown cells that must differ from English - MIN_TRANSLATED_FRACTION: float = 0.80 - - @pytest.mark.parametrize("nb_name", NOTEBOOK_NAMES) - def test_markdown_cells_differ_from_english(self, nb_name: str) -> None: - en_nb = _load_notebook(EN_DIR / nb_name) - it_nb = _load_notebook(IT_DIR / nb_name) - - n_markdown = 0 - n_different = 0 - - for en_cell, it_cell in zip(en_nb["cells"], it_nb["cells"], strict=True): - if en_cell["cell_type"] != "markdown": - continue - n_markdown += 1 - # strip() so whitespace-only diffs don't count as translated - if _cell_source(en_cell).strip() != _cell_source(it_cell).strip(): - n_different += 1 - - # Guard: there must be at least one markdown cell - assert n_markdown > 0, f"{nb_name}: no markdown cells found" - - fraction = n_different / n_markdown - assert fraction >= self.MIN_TRANSLATED_FRACTION, ( - f"{nb_name}: only {n_different}/{n_markdown} " - f"({fraction:.0%}) markdown cells differ from English. " - f"Expected at least {self.MIN_TRANSLATED_FRACTION:.0%}." - ) diff --git a/tests/test_run_pipeline.py b/tests/test_run_pipeline.py index 3f94c90..2ed0bcd 100644 --- a/tests/test_run_pipeline.py +++ b/tests/test_run_pipeline.py @@ -1,8 +1,10 @@ -"""Tests for run_pipeline.py — CLI orchestrator. +"""Tests for run_pipeline.py: CLI orchestrator. Validates argument parsing, step selection logic, and error propagation. All pipeline steps are mocked to test only the orchestration layer, not the actual ETL logic (which is tested in test_pipeline*.py). +Leaving any step unmocked would open the real DuckDB file, which does +not exist in CI (data/ is gitignored) and must never be touched by tests. """ import logging @@ -18,6 +20,7 @@ class TestArgumentParsing: """Verify CLI flags are parsed correctly and forwarded to steps.""" + @patch("run_pipeline.compute_stats") @patch("run_pipeline.export") @patch("run_pipeline.transform") @patch("run_pipeline.ingest") @@ -30,8 +33,9 @@ def test_no_args_runs_all_steps( mock_ingest: MagicMock, mock_transform: MagicMock, mock_export: MagicMock, + mock_stats: MagicMock, ) -> None: - """No arguments → all three steps run.""" + """No arguments → all four steps run.""" from run_pipeline import main with patch("sys.argv", ["run_pipeline"]): @@ -40,7 +44,9 @@ def test_no_args_runs_all_steps( mock_ingest.assert_called_once() mock_transform.assert_called_once() mock_export.assert_called_once() + mock_stats.assert_called_once() + @patch("run_pipeline.compute_stats") @patch("run_pipeline.export") @patch("run_pipeline.transform") @patch("run_pipeline.ingest") @@ -53,6 +59,7 @@ def test_step_ingest_runs_only_ingest( mock_ingest: MagicMock, mock_transform: MagicMock, mock_export: MagicMock, + mock_stats: MagicMock, ) -> None: """--step ingest → only ingest runs.""" from run_pipeline import main @@ -63,7 +70,9 @@ def test_step_ingest_runs_only_ingest( mock_ingest.assert_called_once() mock_transform.assert_not_called() mock_export.assert_not_called() + mock_stats.assert_not_called() + @patch("run_pipeline.compute_stats") @patch("run_pipeline.export") @patch("run_pipeline.transform") @patch("run_pipeline.ingest") @@ -76,6 +85,7 @@ def test_step_transform_runs_only_transform( mock_ingest: MagicMock, mock_transform: MagicMock, mock_export: MagicMock, + mock_stats: MagicMock, ) -> None: """--step transform → only transform runs.""" from run_pipeline import main @@ -86,7 +96,9 @@ def test_step_transform_runs_only_transform( mock_ingest.assert_not_called() mock_transform.assert_called_once() mock_export.assert_not_called() + mock_stats.assert_not_called() + @patch("run_pipeline.compute_stats") @patch("run_pipeline.export") @patch("run_pipeline.transform") @patch("run_pipeline.ingest") @@ -99,6 +111,7 @@ def test_step_export_runs_only_export( mock_ingest: MagicMock, mock_transform: MagicMock, mock_export: MagicMock, + mock_stats: MagicMock, ) -> None: """--step export → only export runs.""" from run_pipeline import main @@ -109,6 +122,33 @@ def test_step_export_runs_only_export( mock_ingest.assert_not_called() mock_transform.assert_not_called() mock_export.assert_called_once() + mock_stats.assert_not_called() + + @patch("run_pipeline.compute_stats") + @patch("run_pipeline.export") + @patch("run_pipeline.transform") + @patch("run_pipeline.ingest") + @patch("run_pipeline.log_environment") + @patch("run_pipeline.setup_logging") + def test_step_stats_runs_only_stats( + self, + mock_setup: MagicMock, + mock_env: MagicMock, + mock_ingest: MagicMock, + mock_transform: MagicMock, + mock_export: MagicMock, + mock_stats: MagicMock, + ) -> None: + """--step stats → only compute_stats runs.""" + from run_pipeline import main + + with patch("sys.argv", ["run_pipeline", "--step", "stats"]): + main() + + mock_ingest.assert_not_called() + mock_transform.assert_not_called() + mock_export.assert_not_called() + mock_stats.assert_called_once() @patch("run_pipeline.log_environment") @patch("run_pipeline.setup_logging") @@ -133,6 +173,7 @@ def test_invalid_step_raises_system_exit( class TestSampleFlag: """Verify --sample flag is forwarded correctly to ingest.""" + @patch("run_pipeline.compute_stats") @patch("run_pipeline.export") @patch("run_pipeline.transform") @patch("run_pipeline.ingest") @@ -145,6 +186,7 @@ def test_sample_flag_passed_to_ingest( mock_ingest: MagicMock, mock_transform: MagicMock, mock_export: MagicMock, + mock_stats: MagicMock, ) -> None: """--sample → ingest(use_sample=True).""" from run_pipeline import main @@ -154,6 +196,7 @@ def test_sample_flag_passed_to_ingest( mock_ingest.assert_called_once_with(use_sample=True) + @patch("run_pipeline.compute_stats") @patch("run_pipeline.export") @patch("run_pipeline.transform") @patch("run_pipeline.ingest") @@ -166,6 +209,7 @@ def test_no_sample_flag_default_false( mock_ingest: MagicMock, mock_transform: MagicMock, mock_export: MagicMock, + mock_stats: MagicMock, ) -> None: """No --sample → ingest(use_sample=False).""" from run_pipeline import main @@ -184,6 +228,7 @@ def test_no_sample_flag_default_false( class TestDebugFlag: """Verify --debug flag sets correct logging level.""" + @patch("run_pipeline.compute_stats") @patch("run_pipeline.export") @patch("run_pipeline.transform") @patch("run_pipeline.ingest") @@ -196,6 +241,7 @@ def test_debug_flag_sets_debug_level( mock_ingest: MagicMock, mock_transform: MagicMock, mock_export: MagicMock, + mock_stats: MagicMock, ) -> None: """--debug → setup_logging(level=logging.DEBUG).""" from run_pipeline import main @@ -205,6 +251,7 @@ def test_debug_flag_sets_debug_level( mock_setup.assert_called_once_with(level=logging.DEBUG) + @patch("run_pipeline.compute_stats") @patch("run_pipeline.export") @patch("run_pipeline.transform") @patch("run_pipeline.ingest") @@ -217,6 +264,7 @@ def test_no_debug_flag_sets_info_level( mock_ingest: MagicMock, mock_transform: MagicMock, mock_export: MagicMock, + mock_stats: MagicMock, ) -> None: """No --debug → setup_logging(level=logging.INFO).""" from run_pipeline import main @@ -283,6 +331,22 @@ def test_export_error_propagates( with pytest.raises(PermissionError, match="read-only"): main() + @patch("run_pipeline.log_environment") + @patch("run_pipeline.setup_logging") + @patch("run_pipeline.compute_stats", side_effect=RuntimeError("stats failed")) + def test_stats_error_propagates( + self, + mock_stats: MagicMock, + mock_setup: MagicMock, + mock_env: MagicMock, + ) -> None: + """RuntimeError in compute_stats should propagate out of main().""" + from run_pipeline import main + + with patch("sys.argv", ["run_pipeline", "--step", "stats"]): + with pytest.raises(RuntimeError, match="stats failed"): + main() + # =================================================================== # Combined flags @@ -292,6 +356,7 @@ def test_export_error_propagates( class TestCombinedFlags: """Verify that multiple flags work together.""" + @patch("run_pipeline.compute_stats") @patch("run_pipeline.export") @patch("run_pipeline.transform") @patch("run_pipeline.ingest") @@ -304,6 +369,7 @@ def test_sample_and_debug_together( mock_ingest: MagicMock, mock_transform: MagicMock, mock_export: MagicMock, + mock_stats: MagicMock, ) -> None: """--sample --debug → sample=True + level=DEBUG.""" from run_pipeline import main @@ -314,6 +380,7 @@ def test_sample_and_debug_together( mock_setup.assert_called_once_with(level=logging.DEBUG) mock_ingest.assert_called_once_with(use_sample=True) + @patch("run_pipeline.compute_stats") @patch("run_pipeline.export") @patch("run_pipeline.transform") @patch("run_pipeline.ingest") @@ -326,6 +393,7 @@ def test_step_and_sample_together( mock_ingest: MagicMock, mock_transform: MagicMock, mock_export: MagicMock, + mock_stats: MagicMock, ) -> None: """--step ingest --sample → only ingest with sample=True.""" from run_pipeline import main @@ -336,3 +404,4 @@ def test_step_and_sample_together( mock_ingest.assert_called_once_with(use_sample=True) mock_transform.assert_not_called() mock_export.assert_not_called() + mock_stats.assert_not_called() diff --git a/tests/test_stats_export.py b/tests/test_stats_export.py new file mode 100644 index 0000000..80ae708 --- /dev/null +++ b/tests/test_stats_export.py @@ -0,0 +1,154 @@ +"""Tests for step 04: statistical export invariants. + +The stats step runs against the in-memory sample database. These tests +do not check specific statistical values (the synthetic sample has no +"correct" answer): they verify structural invariants that must hold on +ANY dataset, such as p-value ranges, correction monotonicity, CI +ordering, family membership, and bootstrap determinism (fixed seed). +""" + +from pathlib import Path + +import duckdb +import pandas as pd +import pytest + +from src.pipeline.step_04_stats import ( + BEHAV_COLUMNS, + CONDITIONAL_FEATURES, + DEMO_CATEGORICAL, + DEMO_NUMERIC, + SIGNAL_COLUMNS, + compute_stats, +) + +# The synthetic sample makes some signals nearly constant within a group, +# so scipy warns about precision loss in the t-test moment calculation. +# Expected on sample data and absent on the real dataset: silenced here +# only, never in production code, where the same warning would be a +# genuine data-quality signal worth surfacing. +pytestmark = pytest.mark.filterwarnings( + "ignore:Precision loss occurred in moment calculation:RuntimeWarning" +) + +EXPECTED_FILES: list[str] = [ + "stats_bq2_early_signals.csv", + "stats_bq2_ghost_segments.csv", + "stats_bq3_demographics.csv", + "stats_bq3_behavior.csv", +] + + +@pytest.fixture(scope="module") +def stats_dir( + db_conn: duckdb.DuckDBPyConnection, + tmp_path_factory: pytest.TempPathFactory, +) -> Path: + """Run the stats export once for the whole module. + + Module-scoped because compute_stats() bootstraps 2000 resamples: + running it once and sharing the output keeps the suite fast while + every test still reads its own CSV independently. + """ + out: Path = tmp_path_factory.mktemp("stats_export") + compute_stats(conn=db_conn, output_dir=out) + return out + + +def test_all_expected_files_created(stats_dir: Path) -> None: + """All four stats artifacts must exist and be non-empty.""" + for filename in EXPECTED_FILES: + path: Path = stats_dir / filename + assert path.exists(), f"Missing stats export: {filename}" + assert path.stat().st_size > 0, f"Empty stats export: {filename}" + + +def test_bq2_signals_families_and_ranges(stats_dir: Path) -> None: + """BQ2 export covers the 8 signals with valid p-values and CIs.""" + df: pd.DataFrame = pd.read_csv(stats_dir / "stats_bq2_early_signals.csv") + + # The sample data must support all 8 tests: a silent skip here would + # mean the published correction family (n=8) no longer matches. + assert set(df["signal"]) == set(SIGNAL_COLUMNS) + + for col in ["p_value", "p_bonferroni", "p_bh"]: + assert df[col].between(0, 1).all(), f"{col} out of [0, 1]" + + # Corrected p-values can never be smaller than the raw ones, and + # Bonferroni is at least as conservative as Benjamini-Hochberg. + assert (df["p_bonferroni"] >= df["p_value"]).all() + assert (df["p_bh"] >= df["p_value"]).all() + assert (df["p_bonferroni"] >= df["p_bh"]).all() + + # The CI is for the mean difference: it must bracket it. + mean_diff: pd.Series = df["mean_completed"] - df["mean_not_completed"] + assert (df["ci_lower"] <= mean_diff + 1e-9).all() + assert (df["ci_upper"] >= mean_diff - 1e-9).all() + + # Export is ranked by |d| descending, the ordering the report uses. + abs_d: pd.Series = df["cohens_d"].abs() + assert abs_d.is_monotonic_decreasing + + +def test_bq2_ghost_segments_invariants(stats_dir: Path) -> None: + """Ghost/active rates are valid proportions inside their own CIs.""" + df: pd.DataFrame = pd.read_csv(stats_dir / "stats_bq2_ghost_segments.csv") + + assert set(df["segment"]) == {"ghost", "active"} + assert df["completion_rate"].between(0, 1).all() + assert (df["ci_lower_95"] <= df["completion_rate"]).all() + assert (df["ci_upper_95"] >= df["completion_rate"]).all() + assert (df["n_enrollments"] > 0).all() + + +def test_bq3_demographics_families_and_tests(stats_dir: Path) -> None: + """Demographic export pairs each feature with the right test type.""" + df: pd.DataFrame = pd.read_csv(stats_dir / "stats_bq3_demographics.csv") + + assert set(df["feature"]) == set(DEMO_CATEGORICAL + DEMO_NUMERIC) + + chi_rows: pd.DataFrame = df[df["test"] == "chi-square"] + t_rows: pd.DataFrame = df[df["test"] == "t-test"] + assert set(chi_rows["feature"]) == set(DEMO_CATEGORICAL) + assert set(t_rows["feature"]) == set(DEMO_NUMERIC) + + # Cramér's V lives in [0, 1] by construction; a value outside means + # the contingency table or the formula went wrong upstream. + assert chi_rows["effect_size"].between(0, 1).all() + assert (chi_rows["n_categories"] >= 2).all() + + assert df["p_value"].between(0, 1).all() + assert (df["p_bh"] >= df["p_value"]).all() + + +def test_bq3_behavior_conditional_flags(stats_dir: Path) -> None: + """Conditional-population features are flagged, and only those.""" + df: pd.DataFrame = pd.read_csv(stats_dir / "stats_bq3_behavior.csv") + + assert set(df["feature"]) == set(BEHAV_COLUMNS) + + flagged: set[str] = set(df[df["conditional_population"]]["feature"]) + assert flagged == CONDITIONAL_FEATURES + + assert df["p_value"].between(0, 1).all() + assert (df["p_bh"] >= df["p_value"]).all() + assert (df["abs_cohens_d"] == df["cohens_d"].abs()).all() + + +def test_stats_export_is_deterministic( + db_conn: duckdb.DuckDBPyConnection, + stats_dir: Path, + tmp_path: Path, +) -> None: + """Two runs produce byte-identical files. + + The bootstrap uses a fixed seed and the queries are deterministic, + so any difference between runs would signal hidden nondeterminism, + which would make the published numbers non-reproducible. + """ + compute_stats(conn=db_conn, output_dir=tmp_path) + + for filename in EXPECTED_FILES: + first: bytes = (stats_dir / filename).read_bytes() + second: bytes = (tmp_path / filename).read_bytes() + assert first == second, f"Non-deterministic export: {filename}"