KLEB38's picture
Upload folder using huggingface_hub
95de681 verified
Raw
History Blame Contribute Delete
19.9 kB
"""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."
)