"""Credit Scoring Monitoring Dashboard. Four tabs: - Operational: volume, latency p50/p95, error rate, score distribution - Drift: embedded Evidently HTML + summary - Business: GRANTED vs REFUSED, top-driver features - Advanced: output drift, critical features, weighted drift score Reads from Supabase (predictions_log) — never touches the test table. """ from __future__ import annotations from pathlib import Path import numpy as np import pandas as pd import plotly.express as px import plotly.graph_objects as go import streamlit as st from scipy import stats as scipy_stats from queries import ( fetch_latency_breakdown, fetch_proba_distribution, fetch_recent, fetch_summary, fetch_volume_by_hour, load_drift_report_json, load_feature_importance, load_proba_reference, parse_drift_results, ) DRIFT_REPORT_PATH = Path(__file__).parent / "static" / "drift_report.html" st.set_page_config( page_title="OC P8 Monitoring", page_icon="📊", layout="wide", ) st.title("📊 Credit Scoring — Monitoring") st.caption("PrĂȘt Ă  DĂ©penser · prod observability + data drift") with st.sidebar: st.header("Filtres") hours = st.slider( "FenĂȘtre (heures)", min_value=1, max_value=168, value=24, help="Plage temporelle pour toutes les mĂ©triques. 24h = 1 jour, 168h = 7 jours.", ) st.markdown("---") st.markdown( "**Sources**\n\n" "- Logs : Supabase `predictions_log`\n" "- Drift : `static/drift_report.html`\n" "- RĂ©gĂ©nĂ©rer le rapport : `uv run python scripts/generate_drift_report.py`" ) tab_ops, tab_drift, tab_business, tab_advanced = st.tabs( ["⚙ OpĂ©rationnel", "🌊 Data Drift Report", "đŸ’Œ Business", "🧠 Data Drift avancĂ©"] ) # Fetched once and reused across the Operational and Business tabs. The # @st.cache_data decorator on fetch_recent already deduplicates the DB # round-trip, but computing the boolean mask twice would still cost two # DataFrame allocations. try: _recent_df = fetch_recent(hours) _ok_df = _recent_df[_recent_df["status_code"] == 200] except Exception: # If Supabase is unreachable, the tab_ops error path below already shows # the message; just keep these empty so downstream blocks degrade gracefully. _recent_df = pd.DataFrame() _ok_df = pd.DataFrame() # -------------------------------------------------------------------- Ops -- with tab_ops: try: summary = fetch_summary(hours) except Exception as exc: st.error(f"Impossible de joindre Supabase : {exc}") st.stop() if not summary["total"]: st.warning(f"Aucune prĂ©diction enregistrĂ©e sur les {hours} derniĂšres heures.") st.stop() # Headline: total server-side wall-clock = handler + DB log. The detail # decomposition lives in the dedicated section below. _total_p50_top = int(round(float(summary["p50"] or 0))) + int( round(float(summary["db_log_p50"] or 0)) ) _total_p95_top = int(round(float(summary["p95"] or 0))) + int( round(float(summary["db_log_p95"] or 0)) ) cols = st.columns(6) cols[0].metric("Total requĂȘtes", f"{summary['total']:,}") cols[1].metric( "Erreurs", f"{summary['errors']:,}", delta=f"{(summary['errors'] / summary['total']) * 100:.1f} %", delta_color="inverse", ) cols[2].metric( "Total p50", f"{_total_p50_top} ms", help="Wall-clock serveur complet = handler (`latency_ms`) + DB log (`db_log_ms`). DĂ©tail dans la section *DĂ©composition* plus bas.", ) cols[3].metric( "Total p95", f"{_total_p95_top} ms", help="Wall-clock serveur p95 = handler p95 + DB log p95.", ) cols[4].metric( "% REFUSED", f"{(summary['refused'] / max(summary['total'], 1)) * 100:.1f} %", ) cols[5].metric( "% Nouveaux clients", f"{(summary['unknowns'] / max(summary['total'], 1)) * 100:.1f} %", help="Part de clients sans entrĂ©e dans le feature store (no_history_template).", ) st.subheader("Volume & latence par heure") hourly = fetch_volume_by_hour(hours) if not hourly.empty: c1, c2 = st.columns(2) with c1: st.plotly_chart( px.bar(hourly, x="hour", y="total", title="RequĂȘtes / heure"), use_container_width=True, ) with c2: fig = px.line( hourly.melt(id_vars="hour", value_vars=["p50", "p95"]), x="hour", y="value", color="variable", title="Latence (ms)", ) st.plotly_chart(fig, use_container_width=True) st.subheader("DĂ©composition de la latence") def _ms(v) -> int: """Format helper — round to int ms, default 0 when SQL returns NULL.""" return 0 if v is None else int(round(float(v))) handler_p50 = _ms(summary["p50"]) handler_p95 = _ms(summary["p95"]) asm_p50 = _ms(summary["asm_p50"]) asm_p95 = _ms(summary["asm_p95"]) inf_p50 = _ms(summary["inf_p50"]) inf_p95 = _ms(summary["inf_p95"]) inf_cpu_p50 = _ms(summary["inf_cpu_p50"]) inf_cpu_p95 = _ms(summary["inf_cpu_p95"]) db_log_p50 = _ms(summary["db_log_p50"]) db_log_p95 = _ms(summary["db_log_p95"]) plumb_p50 = _ms(summary["plumbing_p50"]) plumb_p95 = _ms(summary["plumbing_p95"]) total_p50 = handler_p50 + db_log_p50 total_p95 = handler_p95 + db_log_p95 st.caption( f"**Latence client perçue ≈ handler ({handler_p50} ms p50).** " f"Le **DB log** ({db_log_p50} ms p50) s'exĂ©cute en `BackgroundTask` " "aprĂšs l'envoi de la rĂ©ponse — il n'impacte plus le client (Ă©tape 4). \n" "Le **handler** (`latency_ms`) couvre l'assembly + l'infĂ©rence + la construction " "de la rĂ©ponse. Le **DB log** (`db_log_ms`) est mesurĂ© sĂ©parĂ©ment dans `api/logger.py` " "autour de l'INSERT Supabase, et reste affichĂ© comme mĂ©trique de santĂ© serveur. " "Le **plumbing Δ** = `latency_ms - assembly - inference` isole le rĂ©sidu Python " "entre les sous-mesures (inits de variables, return statement, entrĂ©e dans le " "`finally`) — typiquement < 1 ms." ) cols_perf = st.columns(7) cols_perf[0].metric( "Total p50 / p95", f"{total_p50} / {total_p95} ms", help="Wall-clock serveur complet = `latency_ms` (handler) + `db_log_ms` (INSERT). C'est le temps rĂ©el passĂ© cĂŽtĂ© serveur sur une requĂȘte.", ) cols_perf[1].metric( "Handler p50 / p95", f"{handler_p50} / {handler_p95} ms", help="`latency_ms` = assembly + inference + plumbing. **N'inclut pas** le DB log.", ) cols_perf[2].metric( "Feature assembly p50 / p95", f"{asm_p50} / {asm_p95} ms", help="Lookup feature store + transforms + ratios + reindex.", ) cols_perf[3].metric( "Inference wall p50 / p95", f"{inf_p50} / {inf_p95} ms", help="`model.predict_proba` (wall-clock).", ) cols_perf[4].metric( "Inference CPU p50 / p95", f"{inf_cpu_p50} / {inf_cpu_p95} ms", help="CPU time consommĂ© pendant l'infĂ©rence (peut lire 0 sur paths trĂšs rapides — rĂ©solution de `time.process_time`).", ) cols_perf[5].metric( "DB log p50 / p95", f"{db_log_p50} / {db_log_p95} ms", help="INSERT Supabase mesurĂ© autour de `conn.execute(insert(...))` dans `api/logger.py`. Domine gĂ©nĂ©ralement l'overhead total.", ) cols_perf[6].metric( "Plumbing Δ p50 / p95", f"{plumb_p50} / {plumb_p95} ms", help="`latency_ms - feature_assembly_ms - inference_ms`. RĂ©sidu Python entre les sous-mesures (typiquement < 1 ms).", ) breakdown = fetch_latency_breakdown(hours) if not breakdown.empty: breakdown = breakdown.copy() # Plumbing per hour = handler - assembly - inference, clamped at 0 to # absorb sub-ms rounding artefacts. We then stack 4 components whose # total equals handler + db_log = full server wall-clock. breakdown["plumbing_p50"] = ( breakdown["total_p50"].fillna(0) - breakdown["feature_assembly_p50"].fillna(0) - breakdown["inference_p50"].fillna(0) ).clip(lower=0) long_df = breakdown.melt( id_vars="hour", value_vars=[ "feature_assembly_p50", "inference_p50", "plumbing_p50", "db_log_p50", ], var_name="composant", value_name="ms", ) long_df["composant"] = long_df["composant"].map({ "feature_assembly_p50": "Feature assembly", "inference_p50": "Model inference", "plumbing_p50": "Plumbing Python (rĂ©sidu)", "db_log_p50": "DB log (INSERT Supabase)", }) fig_breakdown = px.area( long_df, x="hour", y="ms", color="composant", title="DĂ©composition p50 par heure (stacked = wall-clock serveur)", ) fig_breakdown.update_layout(yaxis_title="latence p50 (ms)") st.plotly_chart(fig_breakdown, use_container_width=True) else: st.info( "Pas encore de donnĂ©es instrumentĂ©es sur la fenĂȘtre. " "Lance du trafic via `scripts/seed_traffic.py` aprĂšs le deploy de l'API Ă©tape 4." ) st.subheader("Distribution des probabilitĂ©s") if not _ok_df.empty: st.plotly_chart( px.histogram( _ok_df, x="probability_default", nbins=40, color="decision", title="probability_default — split par dĂ©cision", ), use_container_width=True, ) # ------------------------------------------------------------------ Drift -- with tab_drift: st.subheader("Rapport Data Drift (Evidently)") if DRIFT_REPORT_PATH.exists(): st.caption(f"Source : {DRIFT_REPORT_PATH.name}") html = DRIFT_REPORT_PATH.read_text(encoding="utf-8") st.components.v1.html(html, height=900, scrolling=True) else: st.info( "Aucun rapport Evidently disponible. GĂ©nĂšre-le avec :\n\n" "`uv run python scripts/generate_drift_report.py --days 30`\n\n" "Puis redĂ©ploie le Space ou copie le HTML dans `dashboard/static/`." ) # --------------------------------------------------------------- Business -- with tab_business: if _recent_df.empty: st.warning("Pas de donnĂ©es pour la pĂ©riode.") else: ok = _ok_df c1, c2 = st.columns(2) with c1: decision_counts = ok["decision"].value_counts().reset_index() decision_counts.columns = ["decision", "count"] st.plotly_chart( px.pie(decision_counts, names="decision", values="count", title="DĂ©cisions"), use_container_width=True, ) with c2: known = ok["client_known"].value_counts().rename({True: "Connu", False: "Inconnu"}) st.plotly_chart( px.pie( pd.DataFrame({"type": known.index, "count": known.values}), names="type", values="count", title="Clients connus vs inconnus", ), use_container_width=True, ) st.subheader("Derniers appels") st.dataframe( ok[["timestamp", "sk_id_curr", "client_known", "probability_default", "decision", "latency_ms", "model_version"]].head(50), use_container_width=True, hide_index=True, ) # --------------------------------------------------------- Advanced KPIs -- with tab_advanced: st.caption( "Indicateurs avancĂ©s au-delĂ  du drift par feature : drift de la sortie " "modĂšle, suivi des features critiques, et score de drift pondĂ©rĂ© par " "importance SHAP." ) proba_ref = load_proba_reference() importance = load_feature_importance() drift_json = load_drift_report_json() drift_results = parse_drift_results(drift_json) # ---------------------------------------------------- Output drift -- st.subheader("1. Output drift — distribution de probability_default") if proba_ref is None: st.info( "`dashboard/static/proba_reference.json` introuvable. " "GĂ©nĂšre-le avec `uv run python scripts/build_monitoring_artefacts.py`." ) else: try: current_proba = fetch_proba_distribution(limit=500) except Exception as exc: st.error(f"Impossible de rĂ©cupĂ©rer les probas prod : {exc}") current_proba = [] if not current_proba: st.warning("Pas de prĂ©diction logguĂ©e pour calculer la distribution prod.") else: ref_values = np.array(proba_ref.get("values", [])) cur_values = np.array(current_proba) # K-S test on raw samples — robust comparison of distributions. # scipy returns a KstestResult NamedTuple (statistic, pvalue); the # type stubs are weak, hence the ignore comment. ks_result = scipy_stats.ks_2samp(ref_values, cur_values) ks_p = float(ks_result.pvalue) # type: ignore[attr-defined] detected = ks_p < 0.05 c1, c2, c3, c4 = st.columns(4) c1.metric("Reference mean", f"{ref_values.mean():.3f}") c2.metric( "Current mean", f"{cur_values.mean():.3f}", delta=f"{(cur_values.mean() - ref_values.mean()):+.3f}", ) c3.metric("K-S p-value", f"{ks_p:.2e}") c4.metric( "Output drift", "✓ dĂ©tectĂ©" if detected else "✗ stable", delta_color="inverse" if detected else "normal", ) # Overlay histogram. fig = go.Figure() fig.add_trace( go.Histogram( x=ref_values, name="Reference (training)", opacity=0.55, nbinsx=40, histnorm="probability", marker_color="#888", ) ) fig.add_trace( go.Histogram( x=cur_values, name=f"Current (last {len(cur_values)})", opacity=0.7, nbinsx=40, histnorm="probability", marker_color="#e74c3c", ) ) fig.update_layout( barmode="overlay", xaxis_title="probability_default", yaxis_title="density", title="Distribution de la proba de dĂ©faut — reference vs current", height=350, ) st.plotly_chart(fig, use_container_width=True) st.caption( "Le K-S test compare les deux Ă©chantillons sur leur forme de " "distribution. Un drift de la sortie modĂšle est l'indicateur le " "plus direct d'un comportement modĂšle altĂ©rĂ© en prod — il " "agrĂšge l'effet de tous les drifts d'inputs simultanĂ©ment." ) # ------------------------------------------------- Critical features -- st.subheader("2. Features critiques (top 10 SHAP)") if importance is None: st.info( "`dashboard/static/feature_importance.json` introuvable. " "GĂ©nĂšre-le avec `uv run python scripts/build_monitoring_artefacts.py`." ) elif not drift_results: st.info( "`dashboard/static/drift_report.json` introuvable. " "RĂ©gĂ©nĂšre le drift report avec `uv run python scripts/generate_drift_report.py`." ) else: top_n = 10 rows = [] for entry in importance["top"][:top_n]: feat = entry["feature"] imp = entry["importance"] result = drift_results.get(feat, {}) detected = result.get("detected") score = result.get("score") stattest = result.get("stattest") or "—" rows.append({ "Rank": entry["rank"], "Feature": feat, "SHAP importance": round(imp, 4), "Drift": "🔮 DĂ©tectĂ©" if detected else ("🟱 Stable" if detected is False else "—"), "Drift score": (f"{score:.4f}" if score is not None else "—"), "Stat test": stattest, }) df_critical = pd.DataFrame(rows) n_drifted = sum(1 for r in rows if "DĂ©tectĂ©" in r["Drift"]) c1, c2 = st.columns([1, 3]) c1.metric( f"Drifted parmi top {top_n}", f"{n_drifted}/{top_n}", delta_color="inverse", ) c2.caption( f"MĂ©thode : {importance['method']} sur {importance['sample_size']} " "lignes de reference. Le nombre de features critiques qui ont driftĂ© " "est l'indicateur le plus actionnable — un drift sur un top-feature " "demande un retraining prioritaire." ) st.dataframe(df_critical, use_container_width=True, hide_index=True) # -------------------------------------------------- Weighted drift -- st.subheader("3. Score de drift pondĂ©rĂ© par importance") if importance is None or not drift_results: st.info( "Indicateur indisponible tant que `feature_importance.json` et " "`drift_report.json` ne sont pas tous les deux prĂ©sents." ) else: total_importance = 0.0 drifted_importance = 0.0 n_features_seen = 0 for entry in importance["top"]: feat = entry["feature"] imp = float(entry["importance"]) total_importance += imp result = drift_results.get(feat) if result is None: continue n_features_seen += 1 if result.get("detected"): drifted_importance += imp weighted_ratio = (drifted_importance / total_importance) if total_importance > 0 else 0.0 threshold = 0.30 c1, c2, c3 = st.columns(3) c1.metric( "Drift pondĂ©rĂ©", f"{weighted_ratio:.1%}", delta=f"seuil {threshold:.0%}", delta_color="inverse" if weighted_ratio >= threshold else "normal", ) c2.metric( "Importance couverte", f"{n_features_seen} / {len(importance['top'])} features", ) c3.metric( "Verdict", "🔮 Alerte" if weighted_ratio >= threshold else "🟱 OK", ) st.caption( "**Formule** : ÎŁ(importance × drift_detected) / ÎŁ(importance) sur les " f"top-{len(importance['top'])} features SHAP. PondĂšre le verdict " "binaire d'Evidently par l'impact rĂ©el de chaque feature sur le " "modĂšle. Seuil : 30% de l'importance totale qui drift → alerte. " "Indicateur plus fin que le ratio brut affichĂ© par Evidently dans " "l'onglet Data Drift." )