Spaces:
Sleeping
Sleeping
Update src/module/Indicateurs_DQ.py
Browse files- src/module/Indicateurs_DQ.py +356 -53
src/module/Indicateurs_DQ.py
CHANGED
|
@@ -13,7 +13,7 @@ from Function.Validation_Contract import ETL_Engine
|
|
| 13 |
def show_dq_dashboard(client, sheet_name):
|
| 14 |
st.markdown("## 🛡️ CENTRE DE CONTRÔLE DATA QUALITY")
|
| 15 |
|
| 16 |
-
# On charge via l'ETL
|
| 17 |
etl = ETL_Engine(client, sheet_name)
|
| 18 |
|
| 19 |
# On vérifie si les données sont déjà en session, sinon on charge
|
|
@@ -21,8 +21,6 @@ def show_dq_dashboard(client, sheet_name):
|
|
| 21 |
st.info("Chargement des données pour Audit...")
|
| 22 |
success, data = etl.run_etl()
|
| 23 |
if success:
|
| 24 |
-
# Astuce: on stocke quand même dans le session_state global pour partager
|
| 25 |
-
# Mais ici on s'en sert pour la DQ
|
| 26 |
pass
|
| 27 |
else:
|
| 28 |
st.error(f"Erreur chargement : {data}")
|
|
@@ -30,67 +28,372 @@ def show_dq_dashboard(client, sheet_name):
|
|
| 30 |
else:
|
| 31 |
data = st.session_state.data_explorer.dataframes
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
# --- KPI GLOBAL ---
|
| 34 |
st.markdown("### 📊 Synthèse de Santé des Données")
|
| 35 |
col1, col2, col3, col4 = st.columns(4)
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
st.divider()
|
| 53 |
|
| 54 |
# --- SECTION 1 : COMPLETENESS (Remplissage) ---
|
| 55 |
-
st.subheader("1
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
-
# --- SECTION 2 : ACCURACY (Outliers) ---
|
| 68 |
-
st.subheader("2
|
| 69 |
-
|
|
|
|
| 70 |
|
| 71 |
-
with
|
| 72 |
if df_pat is not None:
|
| 73 |
-
#
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
if df_res is not None:
|
| 83 |
-
#
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
else:
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
def show_dq_dashboard(client, sheet_name):
|
| 14 |
st.markdown("## 🛡️ CENTRE DE CONTRÔLE DATA QUALITY")
|
| 15 |
|
| 16 |
+
# On charge via l'ETL
|
| 17 |
etl = ETL_Engine(client, sheet_name)
|
| 18 |
|
| 19 |
# On vérifie si les données sont déjà en session, sinon on charge
|
|
|
|
| 21 |
st.info("Chargement des données pour Audit...")
|
| 22 |
success, data = etl.run_etl()
|
| 23 |
if success:
|
|
|
|
|
|
|
| 24 |
pass
|
| 25 |
else:
|
| 26 |
st.error(f"Erreur chargement : {data}")
|
|
|
|
| 28 |
else:
|
| 29 |
data = st.session_state.data_explorer.dataframes
|
| 30 |
|
| 31 |
+
# Récupération des DataFrames
|
| 32 |
+
df_pat = data.get('patients')
|
| 33 |
+
df_sej = data.get('sejours')
|
| 34 |
+
df_res = data.get('ressources_jour')
|
| 35 |
+
df_stock = data.get('stock_materiel_jour')
|
| 36 |
+
|
| 37 |
# --- KPI GLOBAL ---
|
| 38 |
st.markdown("### 📊 Synthèse de Santé des Données")
|
| 39 |
col1, col2, col3, col4 = st.columns(4)
|
| 40 |
|
| 41 |
+
# Calcul des indicateurs
|
| 42 |
+
if df_pat is not None:
|
| 43 |
+
anomalies_patients = df_pat['dq_is_anomaly'].sum()
|
| 44 |
+
doublons = df_pat.duplicated(subset=['nom', 'prenom']).sum()
|
| 45 |
+
ages_negatifs = (df_pat['age'] < 0).sum()
|
| 46 |
+
ages_aberrants = (df_pat['age'] > 120).sum()
|
| 47 |
+
missing_birth = df_pat['date_naissance'].isnull().sum()
|
| 48 |
+
missing_ville = df_pat['ville'].isnull().sum()
|
| 49 |
+
else:
|
| 50 |
+
anomalies_patients = doublons = ages_negatifs = ages_aberrants = missing_birth = missing_ville = 0
|
| 51 |
+
|
| 52 |
+
if df_sej is not None:
|
| 53 |
+
anomalies_sejours = df_sej['dq_is_anomaly'].sum()
|
| 54 |
+
dates_inversees = (df_sej['date_sortie'] < df_sej['date_entree']).sum()
|
| 55 |
+
gravite_aberrante = ((df_sej['gravite_1_5'] > 5) | (df_sej['gravite_1_5'] < 1)).sum()
|
| 56 |
+
equipement_manquant = df_sej['equipement_principal_code'].isnull().sum()
|
| 57 |
+
else:
|
| 58 |
+
anomalies_sejours = dates_inversees = gravite_aberrante = equipement_manquant = 0
|
| 59 |
+
|
| 60 |
+
if df_res is not None:
|
| 61 |
+
anomalies_ressources = df_res['dq_is_anomaly'].sum()
|
| 62 |
+
# Conversion de lits_occupes en numérique (peut être string)
|
| 63 |
+
df_res['lits_occupes_num'] = pd.to_numeric(df_res['lits_occupes'], errors='coerce')
|
| 64 |
+
suroccupation = (df_res['lits_occupes_num'] > df_res['lits_total']).sum()
|
| 65 |
+
else:
|
| 66 |
+
anomalies_ressources = suroccupation = 0
|
| 67 |
+
|
| 68 |
+
if df_stock is not None:
|
| 69 |
+
anomalies_stock = df_stock['dq_is_anomaly'].sum()
|
| 70 |
+
else:
|
| 71 |
+
anomalies_stock = 0
|
| 72 |
+
|
| 73 |
+
col1.metric("🔴 Anomalies Patients", f"{anomalies_patients:,}",
|
| 74 |
+
delta=f"{(anomalies_patients/len(df_pat)*100):.1f}%" if df_pat is not None and len(df_pat) > 0 else "N/A",
|
| 75 |
+
delta_color="inverse")
|
| 76 |
+
col2.metric("🔴 Anomalies Séjours", f"{anomalies_sejours:,}",
|
| 77 |
+
delta=f"{(anomalies_sejours/len(df_sej)*100):.1f}%" if df_sej is not None and len(df_sej) > 0 else "N/A",
|
| 78 |
+
delta_color="inverse")
|
| 79 |
+
col3.metric("🔴 Anomalies Ressources", f"{anomalies_ressources:,}",
|
| 80 |
+
delta=f"{(anomalies_ressources/len(df_res)*100):.2f}%" if df_res is not None and len(df_res) > 0 else "N/A",
|
| 81 |
+
delta_color="inverse")
|
| 82 |
+
col4.metric("🔴 Anomalies Stock", f"{anomalies_stock:,}",
|
| 83 |
+
delta=f"{(anomalies_stock/len(df_stock)*100):.2f}%" if df_stock is not None and len(df_stock) > 0 else "N/A",
|
| 84 |
+
delta_color="inverse")
|
| 85 |
|
| 86 |
st.divider()
|
| 87 |
|
| 88 |
# --- SECTION 1 : COMPLETENESS (Remplissage) ---
|
| 89 |
+
st.subheader("1️⃣ Complétude des Données (Missing Values)")
|
| 90 |
+
|
| 91 |
+
tab1, tab2, tab3, tab4 = st.tabs(["📋 Patients", "🏥 Séjours", "⚕️ Ressources", "📦 Stock"])
|
| 92 |
+
|
| 93 |
+
with tab1:
|
| 94 |
+
if df_pat is not None:
|
| 95 |
+
st.markdown("##### Analyse de complétude - Table PATIENTS")
|
| 96 |
+
col_a, col_b = st.columns(2)
|
| 97 |
+
|
| 98 |
+
with col_a:
|
| 99 |
+
# Taux de remplissage par colonne
|
| 100 |
+
missing_df = df_pat.isnull().mean() * 100
|
| 101 |
+
missing_df = missing_df[missing_df > 0].reset_index()
|
| 102 |
+
missing_df.columns = ['Colonne', 'Pourcentage Manquant']
|
| 103 |
+
missing_df = missing_df.sort_values('Pourcentage Manquant', ascending=False)
|
| 104 |
+
|
| 105 |
+
fig_miss = px.bar(missing_df, x='Colonne', y='Pourcentage Manquant',
|
| 106 |
+
title="Données manquantes - Patients",
|
| 107 |
+
color='Pourcentage Manquant',
|
| 108 |
+
color_continuous_scale='Reds',
|
| 109 |
+
text='Pourcentage Manquant')
|
| 110 |
+
fig_miss.update_traces(texttemplate='%{text:.1f}%', textposition='outside')
|
| 111 |
+
fig_miss.update_layout(xaxis_tickangle=-45)
|
| 112 |
+
st.plotly_chart(fig_miss, use_container_width=True)
|
| 113 |
+
|
| 114 |
+
with col_b:
|
| 115 |
+
st.markdown("**Métriques clés :**")
|
| 116 |
+
st.metric("Dates de naissance manquantes", f"{missing_birth:,}")
|
| 117 |
+
st.metric("Villes manquantes", f"{missing_ville:,}")
|
| 118 |
+
st.metric("Doublons nom+prénom", f"{doublons:,}", delta="⚠️ Risque identité")
|
| 119 |
+
|
| 120 |
+
if missing_birth > 0 or missing_ville > 0:
|
| 121 |
+
st.warning(f"⚠️ {missing_birth + missing_ville:,} valeurs critiques manquantes")
|
| 122 |
+
|
| 123 |
+
with tab2:
|
| 124 |
+
if df_sej is not None:
|
| 125 |
+
st.markdown("##### Analyse de complétude - Table SEJOURS")
|
| 126 |
+
missing_sej = df_sej.isnull().mean() * 100
|
| 127 |
+
missing_sej = missing_sej[missing_sej > 0].reset_index()
|
| 128 |
+
missing_sej.columns = ['Colonne', 'Pourcentage Manquant']
|
| 129 |
+
missing_sej = missing_sej.sort_values('Pourcentage Manquant', ascending=False)
|
| 130 |
+
|
| 131 |
+
if len(missing_sej) > 0:
|
| 132 |
+
fig_miss_sej = px.bar(missing_sej, x='Colonne', y='Pourcentage Manquant',
|
| 133 |
+
title="Données manquantes - Séjours",
|
| 134 |
+
color='Pourcentage Manquant',
|
| 135 |
+
color_continuous_scale='Oranges',
|
| 136 |
+
text='Pourcentage Manquant')
|
| 137 |
+
fig_miss_sej.update_traces(texttemplate='%{text:.1f}%', textposition='outside')
|
| 138 |
+
fig_miss_sej.update_layout(xaxis_tickangle=-45)
|
| 139 |
+
st.plotly_chart(fig_miss_sej, use_container_width=True)
|
| 140 |
+
|
| 141 |
+
st.metric("Équipements principaux manquants", f"{equipement_manquant:,}",
|
| 142 |
+
delta=f"{(equipement_manquant/len(df_sej)*100):.1f}%")
|
| 143 |
+
else:
|
| 144 |
+
st.success("✅ Aucune donnée manquante dans les colonnes critiques")
|
| 145 |
+
|
| 146 |
+
with tab3:
|
| 147 |
+
if df_res is not None:
|
| 148 |
+
st.markdown("##### Analyse de complétude - Table RESSOURCES_JOUR")
|
| 149 |
+
missing_res = df_res.isnull().mean() * 100
|
| 150 |
+
missing_res = missing_res[missing_res > 0].reset_index()
|
| 151 |
+
missing_res.columns = ['Colonne', 'Pourcentage Manquant']
|
| 152 |
+
|
| 153 |
+
if len(missing_res) > 0:
|
| 154 |
+
fig_miss_res = px.bar(missing_res, x='Colonne', y='Pourcentage Manquant',
|
| 155 |
+
title="Données manquantes - Ressources",
|
| 156 |
+
color='Pourcentage Manquant',
|
| 157 |
+
color_continuous_scale='Blues',
|
| 158 |
+
text='Pourcentage Manquant')
|
| 159 |
+
fig_miss_res.update_traces(texttemplate='%{text:.1f}%', textposition='outside')
|
| 160 |
+
fig_miss_res.update_layout(xaxis_tickangle=-45)
|
| 161 |
+
st.plotly_chart(fig_miss_res, use_container_width=True)
|
| 162 |
+
else:
|
| 163 |
+
st.success("✅ Aucune donnée manquante détectée")
|
| 164 |
+
|
| 165 |
+
with tab4:
|
| 166 |
+
if df_stock is not None:
|
| 167 |
+
st.markdown("##### Analyse de complétude - Table STOCK_MATERIEL_JOUR")
|
| 168 |
+
missing_stock = df_stock.isnull().mean() * 100
|
| 169 |
+
missing_stock = missing_stock[missing_stock > 0].reset_index()
|
| 170 |
+
missing_stock.columns = ['Colonne', 'Pourcentage Manquant']
|
| 171 |
+
|
| 172 |
+
if len(missing_stock) > 0:
|
| 173 |
+
st.dataframe(missing_stock, use_container_width=True)
|
| 174 |
+
else:
|
| 175 |
+
st.success("✅ Aucune donnée manquante dans le stock")
|
| 176 |
+
|
| 177 |
+
st.divider()
|
| 178 |
|
| 179 |
+
# --- SECTION 2 : ACCURACY (Outliers & Anomalies) ---
|
| 180 |
+
st.subheader("2️⃣ Exactitude & Valeurs Aberrantes")
|
| 181 |
+
|
| 182 |
+
tab_acc1, tab_acc2, tab_acc3 = st.tabs(["👤 Patients", "🏥 Séjours", "🛏️ Ressources"])
|
| 183 |
|
| 184 |
+
with tab_acc1:
|
| 185 |
if df_pat is not None:
|
| 186 |
+
st.markdown("##### Détection d'outliers - PATIENTS")
|
| 187 |
+
c1, c2 = st.columns(2)
|
| 188 |
+
|
| 189 |
+
with c1:
|
| 190 |
+
# Distribution des âges avec zones d'alerte
|
| 191 |
+
fig_age = px.box(df_pat, y='age',
|
| 192 |
+
title="Distribution des Âges (Détection Outliers)",
|
| 193 |
+
points="outliers")
|
| 194 |
+
fig_age.add_hrect(y0=120, y1=df_pat['age'].max() + 10,
|
| 195 |
+
fillcolor="red", opacity=0.2,
|
| 196 |
+
annotation_text="🚨 Âge > 120 ans",
|
| 197 |
+
annotation_position="top left")
|
| 198 |
+
fig_age.add_hrect(y0=df_pat['age'].min() - 10, y1=0,
|
| 199 |
+
fillcolor="red", opacity=0.2,
|
| 200 |
+
annotation_text="🚨 Âge négatif",
|
| 201 |
+
annotation_position="bottom left")
|
| 202 |
+
fig_age.update_layout(height=400)
|
| 203 |
+
st.plotly_chart(fig_age, use_container_width=True)
|
| 204 |
+
|
| 205 |
+
st.metric("Âges négatifs", ages_negatifs, delta="🚨 Critique" if ages_negatifs > 0 else "✅")
|
| 206 |
+
st.metric("Âges > 120 ans", ages_aberrants, delta="🚨 Impossible" if ages_aberrants > 0 else "✅")
|
| 207 |
+
|
| 208 |
+
with c2:
|
| 209 |
+
# Répartition des types d'anomalies
|
| 210 |
+
if 'dq_anomaly_type' in df_pat.columns:
|
| 211 |
+
anomalies_by_type = df_pat[df_pat['dq_is_anomaly'] == 1]['dq_anomaly_type'].value_counts()
|
| 212 |
+
|
| 213 |
+
fig_anom = px.pie(values=anomalies_by_type.values,
|
| 214 |
+
names=anomalies_by_type.index,
|
| 215 |
+
title="Répartition des Types d'Anomalies Patients",
|
| 216 |
+
color_discrete_sequence=px.colors.sequential.Reds_r)
|
| 217 |
+
fig_anom.update_traces(textposition='inside', textinfo='percent+label')
|
| 218 |
+
st.plotly_chart(fig_anom, use_container_width=True)
|
| 219 |
+
|
| 220 |
+
# Affichage des détails
|
| 221 |
+
st.markdown("**Détail par type :**")
|
| 222 |
+
for anom_type, count in anomalies_by_type.items():
|
| 223 |
+
st.write(f"- **{anom_type}** : {count:,} cas")
|
| 224 |
+
|
| 225 |
+
# Top 10 des anomalies les plus critiques
|
| 226 |
+
if df_pat['dq_is_anomaly'].sum() > 0:
|
| 227 |
+
st.markdown("##### 🔍 Top 10 Anomalies Patients")
|
| 228 |
+
anomalies_patients_df = df_pat[df_pat['dq_is_anomaly'] == 1][
|
| 229 |
+
['patient_id', 'nom', 'prenom', 'age', 'date_naissance', 'ville',
|
| 230 |
+
'dq_anomaly_type', 'dq_anomaly_detail']
|
| 231 |
+
].head(10)
|
| 232 |
+
st.dataframe(anomalies_patients_df, use_container_width=True)
|
| 233 |
+
|
| 234 |
+
with tab_acc2:
|
| 235 |
+
if df_sej is not None:
|
| 236 |
+
st.markdown("##### Détection d'anomalies - SEJOURS")
|
| 237 |
+
c1, c2 = st.columns(2)
|
| 238 |
+
|
| 239 |
+
with c1:
|
| 240 |
+
# Distribution de la gravité
|
| 241 |
+
fig_grav = px.histogram(df_sej, x='gravite_1_5',
|
| 242 |
+
title="Distribution Gravité (Échelle 1-5)",
|
| 243 |
+
nbins=15,
|
| 244 |
+
color_discrete_sequence=['#1f77b4'])
|
| 245 |
+
fig_grav.add_vrect(x0=-0.5, x1=0.5, fillcolor="red", opacity=0.2,
|
| 246 |
+
annotation_text="Hors norme", annotation_position="top left")
|
| 247 |
+
fig_grav.add_vrect(x0=5.5, x1=df_sej['gravite_1_5'].max() + 1,
|
| 248 |
+
fillcolor="red", opacity=0.2,
|
| 249 |
+
annotation_text="Hors norme", annotation_position="top right")
|
| 250 |
+
st.plotly_chart(fig_grav, use_container_width=True)
|
| 251 |
+
|
| 252 |
+
st.metric("Gravité < 1", (df_sej['gravite_1_5'] < 1).sum())
|
| 253 |
+
st.metric("Gravité > 5", (df_sej['gravite_1_5'] > 5).sum())
|
| 254 |
+
|
| 255 |
+
with c2:
|
| 256 |
+
# Types d'anomalies séjours
|
| 257 |
+
if 'dq_anomaly_type' in df_sej.columns:
|
| 258 |
+
anomalies_sej = df_sej[df_sej['dq_is_anomaly'] == 1]['dq_anomaly_type'].value_counts()
|
| 259 |
+
|
| 260 |
+
fig_anom_sej = px.bar(x=anomalies_sej.index, y=anomalies_sej.values,
|
| 261 |
+
title="Types d'Anomalies Séjours",
|
| 262 |
+
color=anomalies_sej.values,
|
| 263 |
+
color_continuous_scale='Reds',
|
| 264 |
+
text=anomalies_sej.values)
|
| 265 |
+
fig_anom_sej.update_traces(texttemplate='%{text}', textposition='outside')
|
| 266 |
+
fig_anom_sej.update_layout(showlegend=False, xaxis_title="Type", yaxis_title="Nombre")
|
| 267 |
+
st.plotly_chart(fig_anom_sej, use_container_width=True)
|
| 268 |
+
|
| 269 |
+
# Anomalies critiques
|
| 270 |
+
if df_sej['dq_is_anomaly'].sum() > 0:
|
| 271 |
+
st.markdown("##### 🔍 Top 10 Anomalies Séjours")
|
| 272 |
+
anomalies_sejours_df = df_sej[df_sej['dq_is_anomaly'] == 1][
|
| 273 |
+
['stay_id', 'patient_id', 'date_entree', 'date_sortie', 'duree_sejour_jours',
|
| 274 |
+
'gravite_1_5', 'dq_anomaly_type', 'dq_anomaly_detail']
|
| 275 |
+
].head(10)
|
| 276 |
+
st.dataframe(anomalies_sejours_df, use_container_width=True)
|
| 277 |
+
|
| 278 |
+
with tab_acc3:
|
| 279 |
if df_res is not None:
|
| 280 |
+
st.markdown("##### Détection d'anomalies - RESSOURCES")
|
| 281 |
+
c1, c2 = st.columns(2)
|
| 282 |
+
|
| 283 |
+
with c1:
|
| 284 |
+
# Taux d'occupation dans le temps
|
| 285 |
+
fig_tx = px.scatter(df_res, x='date', y='taux_occupation',
|
| 286 |
+
color='service_code',
|
| 287 |
+
title="Taux d'Occupation par Service",
|
| 288 |
+
opacity=0.6)
|
| 289 |
+
fig_tx.add_hline(y=1.0, line_dash="dash", line_color="red",
|
| 290 |
+
annotation_text="⚠️ Saturation 100%",
|
| 291 |
+
annotation_position="right")
|
| 292 |
+
st.plotly_chart(fig_tx, use_container_width=True)
|
| 293 |
+
|
| 294 |
+
with c2:
|
| 295 |
+
# Cas de suroccupation
|
| 296 |
+
if suroccupation > 0:
|
| 297 |
+
st.error(f"🚨 {suroccupation} cas de SUROCCUPATION détectés !")
|
| 298 |
+
suroccup_df = df_res[df_res['lits_occupes_num'] > df_res['lits_total']][
|
| 299 |
+
['date', 'service_code', 'lits_total', 'lits_occupes', 'taux_occupation']
|
| 300 |
+
].head(10)
|
| 301 |
+
st.dataframe(suroccup_df, use_container_width=True)
|
| 302 |
+
else:
|
| 303 |
+
st.success("✅ Aucune suroccupation détectée")
|
| 304 |
+
|
| 305 |
+
# Types d'anomalies ressources
|
| 306 |
+
if 'dq_anomaly_type' in df_res.columns and df_res['dq_is_anomaly'].sum() > 0:
|
| 307 |
+
st.markdown("**Types d'anomalies ressources :**")
|
| 308 |
+
anom_res = df_res[df_res['dq_is_anomaly'] == 1]['dq_anomaly_type'].value_counts()
|
| 309 |
+
for anom_type, count in anom_res.items():
|
| 310 |
+
st.write(f"- **{anom_type}** : {count} cas")
|
| 311 |
|
| 312 |
+
st.divider()
|
| 313 |
+
|
| 314 |
+
# --- SECTION 3 : CONSISTENCY (Cohérence Temporelle) ---
|
| 315 |
+
st.subheader("3️⃣ Cohérence Temporelle & Intégrité Référentielle")
|
| 316 |
+
|
| 317 |
+
if df_sej is not None:
|
| 318 |
+
st.markdown("##### Incohérences Temporelles - SEJOURS")
|
| 319 |
+
|
| 320 |
+
if dates_inversees > 0:
|
| 321 |
+
st.error(f"🚨 ALERTE CRITIQUE : {dates_inversees} séjours ont une date de sortie antérieure à la date d'entrée !")
|
| 322 |
+
|
| 323 |
+
# Affichage des cas problématiques
|
| 324 |
+
inversions = df_sej[df_sej['date_sortie'] < df_sej['date_entree']][
|
| 325 |
+
['stay_id', 'patient_id', 'service_code', 'date_entree', 'date_sortie', 'duree_sejour_jours']
|
| 326 |
+
].head(20)
|
| 327 |
+
|
| 328 |
+
st.dataframe(inversions, use_container_width=True)
|
| 329 |
+
|
| 330 |
+
# Visualisation
|
| 331 |
+
inversions_temp = df_sej[df_sej['date_sortie'] < df_sej['date_entree']].copy()
|
| 332 |
+
inversions_temp['delta_jours'] = (inversions_temp['date_sortie'] - inversions_temp['date_entree']).dt.days
|
| 333 |
+
|
| 334 |
+
fig_inv = px.histogram(inversions_temp, x='delta_jours',
|
| 335 |
+
title="Distribution des Écarts Temporels (Dates Inversées)",
|
| 336 |
+
nbins=20,
|
| 337 |
+
color_discrete_sequence=['red'])
|
| 338 |
+
fig_inv.update_layout(xaxis_title="Écart en jours (négatif)", yaxis_title="Nombre de cas")
|
| 339 |
+
st.plotly_chart(fig_inv, use_container_width=True)
|
| 340 |
+
else:
|
| 341 |
+
st.success("✅ Aucune incohérence temporelle détectée dans les séjours.")
|
| 342 |
+
|
| 343 |
+
# --- SECTION 4 : RESUME GLOBAL ---
|
| 344 |
+
st.divider()
|
| 345 |
+
st.subheader("4️⃣ Résumé Exécutif Data Quality")
|
| 346 |
+
|
| 347 |
+
col_resume1, col_resume2, col_resume3 = st.columns(3)
|
| 348 |
+
|
| 349 |
+
with col_resume1:
|
| 350 |
+
st.markdown("##### 🎯 Complétude")
|
| 351 |
+
total_missing = 0
|
| 352 |
+
if df_pat is not None:
|
| 353 |
+
total_missing += df_pat.isnull().sum().sum()
|
| 354 |
+
if df_sej is not None:
|
| 355 |
+
total_missing += df_sej.isnull().sum().sum()
|
| 356 |
+
if df_res is not None:
|
| 357 |
+
total_missing += df_res.isnull().sum().sum()
|
| 358 |
+
|
| 359 |
+
st.metric("Total valeurs manquantes", f"{total_missing:,}")
|
| 360 |
+
completeness_score = 100 - (total_missing / (len(df_pat) + len(df_sej) + len(df_res)) * 100) if all([df_pat is not None, df_sej is not None, df_res is not None]) else 0
|
| 361 |
+
st.metric("Score de complétude", f"{completeness_score:.1f}%")
|
| 362 |
+
|
| 363 |
+
with col_resume2:
|
| 364 |
+
st.markdown("##### 🎯 Exactitude")
|
| 365 |
+
total_anomalies = anomalies_patients + anomalies_sejours + anomalies_ressources + anomalies_stock
|
| 366 |
+
st.metric("Total anomalies détectées", f"{total_anomalies:,}")
|
| 367 |
+
|
| 368 |
+
accuracy_score = 100 - (total_anomalies / (len(df_pat) + len(df_sej) + len(df_res) + len(df_stock)) * 100) if all([df_pat is not None, df_sej is not None, df_res is not None, df_stock is not None]) else 0
|
| 369 |
+
st.metric("Score d'exactitude", f"{accuracy_score:.2f}%")
|
| 370 |
+
|
| 371 |
+
with col_resume3:
|
| 372 |
+
st.markdown("##### 🎯 Cohérence")
|
| 373 |
+
consistency_issues = dates_inversees + suroccupation
|
| 374 |
+
st.metric("Violations de cohérence", f"{consistency_issues:,}")
|
| 375 |
+
|
| 376 |
+
if consistency_issues == 0:
|
| 377 |
+
st.success("✅ Données cohérentes")
|
| 378 |
+
else:
|
| 379 |
+
st.error(f"🚨 {consistency_issues} violations détectées")
|
| 380 |
+
|
| 381 |
+
# Score global
|
| 382 |
+
st.markdown("---")
|
| 383 |
+
global_score = (completeness_score + accuracy_score) / 2 if completeness_score > 0 and accuracy_score > 0 else 0
|
| 384 |
+
|
| 385 |
+
if global_score >= 95:
|
| 386 |
+
status = "🟢 EXCELLENT"
|
| 387 |
+
color = "green"
|
| 388 |
+
elif global_score >= 85:
|
| 389 |
+
status = "🟡 BON"
|
| 390 |
+
color = "orange"
|
| 391 |
+
elif global_score >= 70:
|
| 392 |
+
status = "🟠 MOYEN"
|
| 393 |
+
color = "orange"
|
| 394 |
else:
|
| 395 |
+
status = "🔴 CRITIQUE"
|
| 396 |
+
color = "red"
|
| 397 |
+
|
| 398 |
+
st.markdown(f"### Score Global Data Quality : {global_score:.1f}% - {status}")
|
| 399 |
+
st.progress(global_score / 100)
|