jaimalklyde commited on
Commit
1d073f8
·
verified ·
1 Parent(s): bd2f082

Update src/module/Indicateurs_DQ.py

Browse files
Files changed (1) hide show
  1. src/module/Indicateurs_DQ.py +688 -169
src/module/Indicateurs_DQ.py CHANGED
@@ -1,14 +1,27 @@
1
  """
2
- MODULE: INDICATEURS DATA QUALITY (DQ)
3
- =====================================
4
- Responsabilité : Audit, Détection d'anomalies, Visualisation des erreurs.
5
- Ne modifie pas les données, les juge.
 
 
 
 
 
 
 
6
  """
 
7
  import streamlit as st
8
  import pandas as pd
 
9
  import plotly.express as px
10
  import plotly.graph_objects as go
11
- from module.Validation_Contract import ETL_Engine
 
 
 
 
12
 
13
  # =====================================================
14
  # ================== OUTILS SÉCURITÉ ==================
@@ -45,203 +58,709 @@ def safe_pct(num, den):
45
 
46
 
47
  # =====================================================
48
- # ================= DASHBOARD DATA QUALITY ============
49
  # =====================================================
50
 
51
- def show_dq_dashboard(client, sheet_name):
52
- st.markdown("## 🛡️ CENTRE DE CONTRÔLE DATA QUALITY")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
- # ================= CHARGEMENT ETL =================
55
- etl = ETL_Engine(client, sheet_name)
56
 
57
- if 'data_explorer' not in st.session_state or not st.session_state.data_explorer.dataframes:
58
- st.info("Chargement des données pour Audit...")
59
- success, data_explorer = etl.run_etl()
60
- if not success:
61
- st.error(f"Erreur chargement : {data_explorer}")
62
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
- data = data_explorer.dataframes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
- else:
67
- data = st.session_state.data_explorer.dataframes
68
 
69
- # ================= RÉCUPÉRATION TABLES =============
70
- df_pat = data.get('patients')
71
- df_sej = data.get('sejours')
72
- df_res = data.get('ressources_jour')
73
- df_stock = data.get('stock_materiel_jour')
74
 
75
- # ================= NORMALISATION DQ ================
76
- for df in [df_pat, df_sej, df_res, df_stock]:
77
- if df is not None and 'dq_is_anomaly' in df.columns:
78
- df['dq_is_anomaly'] = (df['dq_is_anomaly'] == 1).astype(int)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  # ==================================================
82
  # ================= KPI GLOBAUX ====================
83
  # ==================================================
84
  st.markdown("### 📊 Synthèse de Santé des Données")
 
85
  col1, col2, col3, col4 = st.columns(4)
86
-
87
- # ---------------- PATIENTS ----------------
88
- if df_pat is not None and not df_pat.empty:
89
- anomalies_patients = int((df_pat['dq_is_anomaly'] == 1).sum())
90
- doublons = int(df_pat.duplicated(subset=['nom', 'prenom']).sum())
91
- ages_negatifs = int((df_pat['age'] < 0).sum())
92
- ages_aberrants = int((df_pat['age'] > 120).sum())
93
- missing_birth = int(df_pat['date_naissance'].isnull().sum())
94
- missing_ville = int(df_pat['ville'].isnull().sum())
95
- total_patients = len(df_pat)
96
- else:
97
- anomalies_patients = doublons = ages_negatifs = ages_aberrants = missing_birth = missing_ville = total_patients = 0
98
-
99
- # ---------------- SÉJOURS -----------------
100
- if df_sej is not None and not df_sej.empty:
101
- anomalies_sejours = int((df_sej['dq_is_anomaly'] == 1).sum())
102
- dates_inversees = int((df_sej['date_sortie'] < df_sej['date_entree']).sum())
103
- gravite_aberrante = int(((df_sej['gravite_1_5'] > 5) | (df_sej['gravite_1_5'] < 1)).sum())
104
- equipement_manquant = int(df_sej['equipement_principal_code'].isnull().sum())
105
- total_sejours = len(df_sej)
106
- else:
107
- anomalies_sejours = dates_inversees = gravite_aberrante = equipement_manquant = total_sejours = 0
108
-
109
- # ---------------- RESSOURCES ----------------
110
- if df_res is not None and not df_res.empty:
111
- anomalies_ressources = int((df_res['dq_is_anomaly'] == 1).sum())
112
- df_res['lits_occupes_num'] = pd.to_numeric(df_res['lits_occupes'], errors='coerce').fillna(0)
113
- suroccupation = int((df_res['lits_occupes_num'] > df_res['lits_total']).sum())
114
- total_ressources = len(df_res)
115
- else:
116
- anomalies_ressources = suroccupation = total_ressources = 0
117
-
118
- # ---------------- STOCK --------------------
119
- if df_stock is not None and not df_stock.empty:
120
- anomalies_stock = int((df_stock['dq_is_anomaly'] == 1).sum())
121
- total_stock = len(df_stock)
122
- else:
123
- anomalies_stock = total_stock = 0
124
-
125
- # ---------------- POURCENTAGES -------------
126
- pct_anom_pat = safe_pct(anomalies_patients, total_patients)
127
- pct_anom_sej = safe_pct(anomalies_sejours, total_sejours)
128
- pct_anom_res = safe_pct(anomalies_ressources, total_ressources)
129
- pct_anom_stock = safe_pct(anomalies_stock, total_stock)
130
-
131
- col1.metric("🔴 Anomalies Patients", f"{anomalies_patients:,}", f"{pct_anom_pat}%", delta_color="inverse")
132
- col2.metric("🔴 Anomalies Séjours", f"{anomalies_sejours:,}", f"{pct_anom_sej}%", delta_color="inverse")
133
- col3.metric("🔴 Anomalies Ressources", f"{anomalies_ressources:,}", f"{pct_anom_res}%", delta_color="inverse")
134
- col4.metric("🔴 Anomalies Stock", f"{anomalies_stock:,}", f"{pct_anom_stock}%", delta_color="inverse")
135
-
136
  st.divider()
137
-
138
  # ==================================================
139
- # ================= COMPLÉTUDE =====================
140
  # ==================================================
141
  st.subheader("1️⃣ Complétude des Données (Missing Values)")
 
142
  tab1, tab2, tab3, tab4 = st.tabs(["📋 Patients", "🏥 Séjours", "⚕️ Ressources", "📦 Stock"])
143
-
144
- with tab1:
145
- if df_pat is not None and not df_pat.empty:
146
- total_missing_cells = int(df_pat.isnull().sum().sum())
147
- st.caption(f"Total cellules manquantes : {total_missing_cells}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
- missing_df = (df_pat.isnull().mean() * 100).reset_index()
150
- missing_df.columns = ['Colonne', 'Pourcentage Manquant']
151
- missing_df = missing_df[missing_df['Pourcentage Manquant'] > 0]
152
-
153
- if not missing_df.empty:
154
- fig = px.bar(missing_df, x='Colonne', y='Pourcentage Manquant',
155
- title="Données manquantes - Patients",
156
- color='Pourcentage Manquant', color_continuous_scale='Reds',
157
- text='Pourcentage Manquant')
158
- fig.update_traces(texttemplate='%{text:.1f}%', textposition='outside')
159
- fig.update_layout(xaxis_tickangle=-45)
160
  st.plotly_chart(fig, use_container_width=True)
161
  else:
162
- st.success("✅ Aucune donnée manquante")
163
- else:
164
- st.warning("Aucune donnée patient disponible")
165
-
166
- with tab2:
167
- if df_sej is not None and not df_sej.empty:
168
- missing_df = (df_sej.isnull().mean() * 100).reset_index()
169
- missing_df.columns = ['Colonne', 'Pourcentage Manquant']
170
- missing_df = missing_df[missing_df['Pourcentage Manquant'] > 0]
171
- if not missing_df.empty:
172
- fig = px.bar(missing_df, x='Colonne', y='Pourcentage Manquant',
173
- title="Données manquantes - Séjours",
174
- color='Pourcentage Manquant', color_continuous_scale='Oranges',
175
- text='Pourcentage Manquant')
176
- fig.update_traces(texttemplate='%{text:.1f}%', textposition='outside')
177
- fig.update_layout(xaxis_tickangle=-45)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  st.plotly_chart(fig, use_container_width=True)
179
- else:
180
- st.success("✅ Aucune donnée manquante")
181
- else:
182
- st.warning("Aucune donnée séjour disponible")
183
-
184
- with tab3:
185
- if df_res is not None and not df_res.empty:
186
- missing_df = (df_res.isnull().mean() * 100).reset_index()
187
- missing_df.columns = ['Colonne', 'Pourcentage Manquant']
188
- missing_df = missing_df[missing_df['Pourcentage Manquant'] > 0]
189
- if not missing_df.empty:
190
- fig = px.bar(missing_df, x='Colonne', y='Pourcentage Manquant',
191
- title="Données manquantes - Ressources",
192
- color='Pourcentage Manquant', color_continuous_scale='Blues',
193
- text='Pourcentage Manquant')
194
- fig.update_traces(texttemplate='%{text:.1f}%', textposition='outside')
195
- fig.update_layout(xaxis_tickangle=-45)
 
 
 
196
  st.plotly_chart(fig, use_container_width=True)
197
- else:
198
- st.success("✅ Aucune donnée manquante")
199
- else:
200
- st.warning("Aucune donnée ressource disponible")
201
-
202
- with tab4:
203
- if df_stock is not None and not df_stock.empty:
204
- missing_df = (df_stock.isnull().mean() * 100).reset_index()
205
- missing_df.columns = ['Colonne', 'Pourcentage Manquant']
206
- missing_df = missing_df[missing_df['Pourcentage Manquant'] > 0]
207
- if not missing_df.empty:
208
- st.dataframe(missing_df, use_container_width=True)
209
- else:
210
- st.success("✅ Aucune donnée manquante")
211
- else:
212
- st.warning("Aucune donnée stock disponible")
213
-
214
  # ==================================================
215
- # ================= RÉSUMÉ GLOBAL ==================
216
  # ==================================================
217
- st.divider()
218
- st.subheader("4️⃣ Résumé Exécutif Data Quality")
219
-
220
- total_missing = 0
221
- for df in [df_pat, df_sej, df_res]:
222
- if df is not None:
223
- total_missing += int(df.isnull().sum().sum())
224
-
225
- total_rows = total_patients + total_sejours + total_ressources
226
- completeness_score = safe_pct(total_rows - total_missing, total_rows)
227
-
228
- total_anomalies = anomalies_patients + anomalies_sejours + anomalies_ressources + anomalies_stock
229
- total_all_rows = total_patients + total_sejours + total_ressources + total_stock
230
- accuracy_score = safe_pct(total_all_rows - total_anomalies, total_all_rows)
231
-
232
- global_score = round((completeness_score + accuracy_score) / 2, 2)
233
-
234
- st.metric("Score de complétude", f"{completeness_score}%")
235
- st.metric("Score d'exactitude", f"{accuracy_score}%")
236
-
237
  if global_score >= 95:
238
  status = "🟢 EXCELLENT"
 
239
  elif global_score >= 85:
240
  status = "🟡 BON"
 
241
  elif global_score >= 70:
242
  status = "🟠 MOYEN"
 
243
  else:
244
  status = "🔴 CRITIQUE"
245
-
246
- st.markdown(f"### Score Global Data Quality : {global_score}% — {status}")
 
 
 
 
247
  st.progress(global_score / 100)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ MODULE: DATA QUALITY DASHBOARD - VERSION AMÉLIORÉE
3
+ ===================================================
4
+ Responsabilité : Audit complet, détection d'anomalies, analyses spécifiques hospitalières.
5
+ Style : Gotham UI + Analyses médicales avancées
6
+
7
+ Améliorations :
8
+ - Chargement direct depuis Excel (pas de filtrage ETL)
9
+ - 6 sections complètes d'analyse DQ
10
+ - Métriques hospitalières spécifiques
11
+ - Visualisations enrichies
12
+ - Analyses temporelles des anomalies
13
  """
14
+
15
  import streamlit as st
16
  import pandas as pd
17
+ import numpy as np
18
  import plotly.express as px
19
  import plotly.graph_objects as go
20
+ from plotly.subplots import make_subplots
21
+ from datetime import datetime
22
+ import warnings
23
+ warnings.filterwarnings('ignore')
24
+
25
 
26
  # =====================================================
27
  # ================== OUTILS SÉCURITÉ ==================
 
58
 
59
 
60
  # =====================================================
61
+ # ================= CHARGEMENT DONNÉES ================
62
  # =====================================================
63
 
64
+ @st.cache_data(ttl=3600)
65
+ def load_excel_data_direct(file_path: str) -> dict:
66
+ """
67
+ Charge DIRECTEMENT depuis Excel sans passer par ETL.
68
+ Garantit qu'aucune anomalie n'est filtrée.
69
+ """
70
+ sheets = {}
71
+ try:
72
+ # Chargement de toutes les feuilles nécessaires
73
+ sheets['patients'] = pd.read_excel(file_path, sheet_name='patients')
74
+ sheets['sejours'] = pd.read_excel(file_path, sheet_name='sejours')
75
+ sheets['ressources_jour'] = pd.read_excel(file_path, sheet_name='ressources_jour')
76
+ sheets['stock_materiel_jour'] = pd.read_excel(file_path, sheet_name='stock_materiel_jour')
77
+ sheets['admissions_jour'] = pd.read_excel(file_path, sheet_name='admissions_jour')
78
+ sheets['scenarios_calendrier'] = pd.read_excel(file_path, sheet_name='scenarios_calendrier')
79
+
80
+ # Conversion des dates
81
+ for sheet_name, df in sheets.items():
82
+ if 'date' in df.columns:
83
+ df['date'] = pd.to_datetime(df['date'], errors='coerce')
84
+ if 'date_entree' in df.columns:
85
+ df['date_entree'] = pd.to_datetime(df['date_entree'], errors='coerce')
86
+ if 'date_sortie' in df.columns:
87
+ df['date_sortie'] = pd.to_datetime(df['date_sortie'], errors='coerce')
88
+ if 'date_naissance' in df.columns:
89
+ df['date_naissance'] = pd.to_datetime(df['date_naissance'], errors='coerce')
90
+
91
+ return sheets
92
+ except Exception as e:
93
+ st.error(f"❌ Erreur chargement Excel : {str(e)}")
94
+ return {}
95
 
 
 
96
 
97
+ # =====================================================
98
+ # ============ ANALYSES DATA QUALITY ==================
99
+ # =====================================================
100
+
101
+ class HospitalDataQualityAnalyzer:
102
+ """Analyseur complet de qualité des données hospitalières"""
103
+
104
+ def __init__(self, dataframes: dict):
105
+ self.df_pat = dataframes.get('patients')
106
+ self.df_sej = dataframes.get('sejours')
107
+ self.df_res = dataframes.get('ressources_jour')
108
+ self.df_stock = dataframes.get('stock_materiel_jour')
109
+ self.df_adm = dataframes.get('admissions_jour')
110
+ self.df_scenarios = dataframes.get('scenarios_calendrier')
111
+
112
+ self.metrics = {}
113
+ self._compute_all_metrics()
114
+
115
+ def _compute_all_metrics(self):
116
+ """Calcul de toutes les métriques DQ"""
117
+
118
+ # ============= PATIENTS =============
119
+ if self.df_pat is not None and not self.df_pat.empty:
120
+ self.metrics['patients'] = {
121
+ 'total': len(self.df_pat),
122
+ 'anomalies': safe_int(self.df_pat['dq_is_anomaly'].sum()),
123
+ 'doublons': safe_int(self.df_pat.duplicated(subset=['nom', 'prenom']).sum()),
124
+ 'ages_negatifs': safe_int((self.df_pat['age'] < 0).sum()),
125
+ 'ages_aberrants': safe_int((self.df_pat['age'] > 120).sum()),
126
+ 'missing_birth': safe_int(self.df_pat['date_naissance'].isnull().sum()),
127
+ 'missing_ville': safe_int(self.df_pat['ville'].isnull().sum()),
128
+ 'missing_total': safe_int(self.df_pat.isnull().sum().sum())
129
+ }
130
+ else:
131
+ self.metrics['patients'] = {k: 0 for k in ['total', 'anomalies', 'doublons', 'ages_negatifs',
132
+ 'ages_aberrants', 'missing_birth', 'missing_ville', 'missing_total']}
133
+
134
+ # ============= SÉJOURS =============
135
+ if self.df_sej is not None and not self.df_sej.empty:
136
+ dates_inversees = safe_int((self.df_sej['date_sortie'] < self.df_sej['date_entree']).sum())
137
+ gravite_aberrante = safe_int(((self.df_sej['gravite_1_5'] > 5) | (self.df_sej['gravite_1_5'] < 0)).sum())
138
+
139
+ self.metrics['sejours'] = {
140
+ 'total': len(self.df_sej),
141
+ 'anomalies': safe_int(self.df_sej['dq_is_anomaly'].sum()),
142
+ 'dates_inversees': dates_inversees,
143
+ 'gravite_aberrante': gravite_aberrante,
144
+ 'equipement_manquant': safe_int(self.df_sej['equipement_principal_code'].isnull().sum()),
145
+ 'missing_total': safe_int(self.df_sej.isnull().sum().sum())
146
+ }
147
+ else:
148
+ self.metrics['sejours'] = {k: 0 for k in ['total', 'anomalies', 'dates_inversees',
149
+ 'gravite_aberrante', 'equipement_manquant', 'missing_total']}
150
+
151
+ # ============= RESSOURCES =============
152
+ if self.df_res is not None and not self.df_res.empty:
153
+ df_res_temp = self.df_res.copy()
154
+ df_res_temp['lits_occupes_num'] = pd.to_numeric(df_res_temp['lits_occupes'], errors='coerce').fillna(0)
155
+ suroccupation = safe_int((df_res_temp['lits_occupes_num'] > df_res_temp['lits_total']).sum())
156
+
157
+ self.metrics['ressources'] = {
158
+ 'total': len(self.df_res),
159
+ 'anomalies': safe_int(self.df_res['dq_is_anomaly'].sum()),
160
+ 'suroccupation': suroccupation,
161
+ 'missing_total': safe_int(self.df_res.isnull().sum().sum())
162
+ }
163
+ else:
164
+ self.metrics['ressources'] = {k: 0 for k in ['total', 'anomalies', 'suroccupation', 'missing_total']}
165
+
166
+ # ============= STOCK =============
167
+ if self.df_stock is not None and not self.df_stock.empty:
168
+ self.metrics['stock'] = {
169
+ 'total': len(self.df_stock),
170
+ 'anomalies': safe_int(self.df_stock['dq_is_anomaly'].sum()),
171
+ 'alertes': safe_int((self.df_stock['alerte_stock'] == 1).sum()),
172
+ 'missing_total': safe_int(self.df_stock.isnull().sum().sum())
173
+ }
174
+ else:
175
+ self.metrics['stock'] = {k: 0 for k in ['total', 'anomalies', 'alertes', 'missing_total']}
176
+
177
+ def get_anomalies_by_type(self, df_name: str):
178
+ """Récupère le détail des anomalies par type"""
179
+ df = getattr(self, f'df_{df_name}', None)
180
+
181
+ if df is None or 'dq_anomaly_type' not in df.columns:
182
+ return pd.DataFrame()
183
+
184
+ anomalies = df[df['dq_is_anomaly'] == 1]
185
+
186
+ if anomalies.empty:
187
+ return pd.DataFrame()
188
 
189
+ anomaly_counts = anomalies['dq_anomaly_type'].value_counts().reset_index()
190
+ anomaly_counts.columns = ['Type Anomalie', 'Nombre']
191
+
192
+ return anomaly_counts
193
+
194
+ def get_temporal_anomalies(self, df_name: str):
195
+ """Analyse temporelle des anomalies"""
196
+ df = getattr(self, f'df_{df_name}', None)
197
+
198
+ if df is None or 'date' not in df.columns or 'dq_is_anomaly' not in df.columns:
199
+ return None
200
+
201
+ # Groupement par date
202
+ temp_anomalies = df.groupby(df['date'].dt.to_period('M'))['dq_is_anomaly'].agg(['sum', 'count']).reset_index()
203
+ temp_anomalies['date'] = temp_anomalies['date'].dt.to_timestamp()
204
+ temp_anomalies['pourcentage'] = (temp_anomalies['sum'] / temp_anomalies['count']) * 100
205
+
206
+ return temp_anomalies
207
+
208
+ def get_service_quality_stats(self):
209
+ """Statistiques de qualité par service"""
210
+ if self.df_sej is None:
211
+ return None
212
+
213
+ service_stats = self.df_sej.groupby('service_code').agg({
214
+ 'stay_id': 'count',
215
+ 'dq_is_anomaly': 'sum',
216
+ 'duree_sejour_jours': 'mean',
217
+ 'gravite_1_5': 'mean'
218
+ }).reset_index()
219
+
220
+ service_stats.columns = ['Service', 'Nb Séjours', 'Anomalies', 'Durée Moy', 'Gravité Moy']
221
+ service_stats['Taux Anomalie %'] = (service_stats['Anomalies'] / service_stats['Nb Séjours']) * 100
222
+
223
+ return service_stats.round(2)
224
+
225
+ def compute_global_score(self):
226
+ """Calcul du score global DQ"""
227
+
228
+ # Complétude
229
+ total_rows = (self.metrics['patients']['total'] +
230
+ self.metrics['sejours']['total'] +
231
+ self.metrics['ressources']['total'])
232
+
233
+ total_missing = (self.metrics['patients']['missing_total'] +
234
+ self.metrics['sejours']['missing_total'] +
235
+ self.metrics['ressources']['missing_total'])
236
+
237
+ completeness_score = safe_pct(total_rows - total_missing, total_rows)
238
+
239
+ # Exactitude
240
+ total_all_rows = (self.metrics['patients']['total'] +
241
+ self.metrics['sejours']['total'] +
242
+ self.metrics['ressources']['total'] +
243
+ self.metrics['stock']['total'])
244
+
245
+ total_anomalies = (self.metrics['patients']['anomalies'] +
246
+ self.metrics['sejours']['anomalies'] +
247
+ self.metrics['ressources']['anomalies'] +
248
+ self.metrics['stock']['anomalies'])
249
+
250
+ accuracy_score = safe_pct(total_all_rows - total_anomalies, total_all_rows)
251
+
252
+ # Score global
253
+ global_score = round((completeness_score + accuracy_score) / 2, 2)
254
+
255
+ return {
256
+ 'completeness': completeness_score,
257
+ 'accuracy': accuracy_score,
258
+ 'global': global_score
259
+ }
260
 
 
 
261
 
262
+ # =====================================================
263
+ # ============== VISUALISATIONS =======================
264
+ # =====================================================
 
 
265
 
266
+ def plot_anomalies_distribution(analyzer: HospitalDataQualityAnalyzer):
267
+ """Graphique de distribution des anomalies par table"""
268
+
269
+ data = {
270
+ 'Table': ['Patients', 'Séjours', 'Ressources', 'Stock'],
271
+ 'Anomalies': [
272
+ analyzer.metrics['patients']['anomalies'],
273
+ analyzer.metrics['sejours']['anomalies'],
274
+ analyzer.metrics['ressources']['anomalies'],
275
+ analyzer.metrics['stock']['anomalies']
276
+ ],
277
+ 'Total': [
278
+ analyzer.metrics['patients']['total'],
279
+ analyzer.metrics['sejours']['total'],
280
+ analyzer.metrics['ressources']['total'],
281
+ analyzer.metrics['stock']['total']
282
+ ]
283
+ }
284
+
285
+ df_plot = pd.DataFrame(data)
286
+ df_plot['Pourcentage'] = (df_plot['Anomalies'] / df_plot['Total']) * 100
287
+
288
+ fig = make_subplots(
289
+ rows=1, cols=2,
290
+ subplot_titles=('Nombre d\'Anomalies', 'Taux d\'Anomalies (%)'),
291
+ specs=[[{"type": "bar"}, {"type": "bar"}]]
292
+ )
293
+
294
+ # Nombre absolu
295
+ fig.add_trace(
296
+ go.Bar(
297
+ x=df_plot['Table'],
298
+ y=df_plot['Anomalies'],
299
+ text=df_plot['Anomalies'],
300
+ textposition='outside',
301
+ marker_color='crimson',
302
+ name='Anomalies'
303
+ ),
304
+ row=1, col=1
305
+ )
306
+
307
+ # Pourcentage
308
+ fig.add_trace(
309
+ go.Bar(
310
+ x=df_plot['Table'],
311
+ y=df_plot['Pourcentage'],
312
+ text=[f"{x:.2f}%" for x in df_plot['Pourcentage']],
313
+ textposition='outside',
314
+ marker_color='orange',
315
+ name='Taux %'
316
+ ),
317
+ row=1, col=2
318
+ )
319
+
320
+ fig.update_layout(
321
+ height=400,
322
+ showlegend=False,
323
+ title_text="📊 Distribution des Anomalies par Table"
324
+ )
325
+
326
+ return fig
327
+
328
+
329
+ def plot_temporal_anomalies(analyzer: HospitalDataQualityAnalyzer):
330
+ """Évolution temporelle des anomalies pour séjours"""
331
+
332
+ temp_data = analyzer.get_temporal_anomalies('sej')
333
+
334
+ if temp_data is None or temp_data.empty:
335
+ return None
336
+
337
+ fig = go.Figure()
338
+
339
+ fig.add_trace(go.Scatter(
340
+ x=temp_data['date'],
341
+ y=temp_data['pourcentage'],
342
+ mode='lines+markers',
343
+ name='Taux Anomalies',
344
+ line=dict(color='red', width=2),
345
+ marker=dict(size=8)
346
+ ))
347
+
348
+ fig.update_layout(
349
+ title="📈 Évolution Temporelle des Anomalies (Séjours)",
350
+ xaxis_title="Date",
351
+ yaxis_title="Taux d'Anomalies (%)",
352
+ height=400,
353
+ hovermode='x unified'
354
+ )
355
+
356
+ return fig
357
+
358
+
359
+ def plot_service_quality_heatmap(analyzer: HospitalDataQualityAnalyzer):
360
+ """Heatmap de qualité par service"""
361
+
362
+ service_stats = analyzer.get_service_quality_stats()
363
+
364
+ if service_stats is None or service_stats.empty:
365
+ return None
366
+
367
+ fig = px.bar(
368
+ service_stats.sort_values('Taux Anomalie %', ascending=False).head(10),
369
+ x='Service',
370
+ y='Taux Anomalie %',
371
+ color='Taux Anomalie %',
372
+ color_continuous_scale='Reds',
373
+ title="🏥 Taux d'Anomalies par Service (Top 10)",
374
+ text='Taux Anomalie %'
375
+ )
376
+
377
+ fig.update_traces(texttemplate='%{text:.1f}%', textposition='outside')
378
+ fig.update_layout(height=450)
379
+
380
+ return fig
381
+
382
+
383
+ def plot_completeness_radar(analyzer: HospitalDataQualityAnalyzer):
384
+ """Radar chart de complétude par dimension"""
385
+
386
+ metrics = analyzer.metrics
387
+
388
+ # Calcul des complétudes
389
+ patient_completeness = 100 - safe_pct(metrics['patients']['missing_total'],
390
+ metrics['patients']['total'] * len(analyzer.df_pat.columns) if analyzer.df_pat is not None else 1)
391
+ sejour_completeness = 100 - safe_pct(metrics['sejours']['missing_total'],
392
+ metrics['sejours']['total'] * len(analyzer.df_sej.columns) if analyzer.df_sej is not None else 1)
393
+ ressource_completeness = 100 - safe_pct(metrics['ressources']['missing_total'],
394
+ metrics['ressources']['total'] * len(analyzer.df_res.columns) if analyzer.df_res is not None else 1)
395
+ stock_completeness = 100 - safe_pct(metrics['stock']['missing_total'],
396
+ metrics['stock']['total'] * len(analyzer.df_stock.columns) if analyzer.df_stock is not None else 1)
397
+
398
+ categories = ['Patients', 'Séjours', 'Ressources', 'Stock']
399
+ values = [patient_completeness, sejour_completeness, ressource_completeness, stock_completeness]
400
+ values.append(values[0]) # Fermeture
401
+
402
+ fig = go.Figure()
403
+
404
+ fig.add_trace(go.Scatterpolar(
405
+ r=values,
406
+ theta=categories + [categories[0]],
407
+ fill='toself',
408
+ name='Complétude',
409
+ line_color='cyan'
410
+ ))
411
+
412
+ fig.update_layout(
413
+ polar=dict(
414
+ radialaxis=dict(
415
+ visible=True,
416
+ range=[0, 100]
417
+ )
418
+ ),
419
+ showlegend=False,
420
+ title="🎯 Radar de Complétude des Données",
421
+ height=400
422
+ )
423
+
424
+ return fig
425
 
426
 
427
+ # =====================================================
428
+ # ================= DASHBOARD PRINCIPAL ===============
429
+ # =====================================================
430
+
431
+ def show_enhanced_dq_dashboard():
432
+ """
433
+ Dashboard Data Quality amélioré avec toutes les sections.
434
+ """
435
+ st.markdown("## 🛡️ CENTRE DE CONTRÔLE DATA QUALITY")
436
+ st.caption("Analyse complète de la qualité des données hospitalières")
437
+
438
+ # ================= UPLOAD FICHIER =================
439
+ st.sidebar.header("⚙️ Configuration")
440
+
441
+ uploaded_file = st.sidebar.file_uploader(
442
+ "Charger le fichier Excel",
443
+ type=['xlsx'],
444
+ help="Fichier bdd_mvp_faker.xlsx"
445
+ )
446
+
447
+ if uploaded_file is None:
448
+ st.info("👈 Veuillez charger le fichier Excel dans la barre latérale")
449
+ return
450
+
451
+ # ================= CHARGEMENT =================
452
+ with st.spinner("🔄 Chargement des données..."):
453
+ dataframes = load_excel_data_direct(uploaded_file)
454
+
455
+ if not dataframes:
456
+ st.error("❌ Erreur lors du chargement des données")
457
+ return
458
+
459
+ # ================= ANALYSE =================
460
+ analyzer = HospitalDataQualityAnalyzer(dataframes)
461
+ scores = analyzer.compute_global_score()
462
+
463
+ st.success(f"✅ Données chargées : {sum(m['total'] for m in analyzer.metrics.values()):,} lignes au total")
464
+
465
  # ==================================================
466
  # ================= KPI GLOBAUX ====================
467
  # ==================================================
468
  st.markdown("### 📊 Synthèse de Santé des Données")
469
+
470
  col1, col2, col3, col4 = st.columns(4)
471
+
472
+ metrics = analyzer.metrics
473
+
474
+ col1.metric(
475
+ "🔴 Anomalies Patients",
476
+ f"{metrics['patients']['anomalies']:,}",
477
+ f"{safe_pct(metrics['patients']['anomalies'], metrics['patients']['total'])}%",
478
+ delta_color="inverse"
479
+ )
480
+
481
+ col2.metric(
482
+ "🔴 Anomalies Séjours",
483
+ f"{metrics['sejours']['anomalies']:,}",
484
+ f"{safe_pct(metrics['sejours']['anomalies'], metrics['sejours']['total'])}%",
485
+ delta_color="inverse"
486
+ )
487
+
488
+ col3.metric(
489
+ "🔴 Anomalies Ressources",
490
+ f"{metrics['ressources']['anomalies']:,}",
491
+ f"{safe_pct(metrics['ressources']['anomalies'], metrics['ressources']['total'])}%",
492
+ delta_color="inverse"
493
+ )
494
+
495
+ col4.metric(
496
+ "🔴 Anomalies Stock",
497
+ f"{metrics['stock']['anomalies']:,}",
498
+ f"{safe_pct(metrics['stock']['anomalies'], metrics['stock']['total'])}%",
499
+ delta_color="inverse"
500
+ )
501
+
502
+ # Graphique distribution anomalies
503
+ st.plotly_chart(plot_anomalies_distribution(analyzer), use_container_width=True)
504
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
  st.divider()
506
+
507
  # ==================================================
508
+ # ========== 1️⃣ COMPLÉTUDE DES DONNÉES ============
509
  # ==================================================
510
  st.subheader("1️⃣ Complétude des Données (Missing Values)")
511
+
512
  tab1, tab2, tab3, tab4 = st.tabs(["📋 Patients", "🏥 Séjours", "⚕️ Ressources", "📦 Stock"])
513
+
514
+ for tab, df_name, df in [(tab1, 'Patients', analyzer.df_pat),
515
+ (tab2, 'Séjours', analyzer.df_sej),
516
+ (tab3, 'Ressources', analyzer.df_res),
517
+ (tab4, 'Stock', analyzer.df_stock)]:
518
+ with tab:
519
+ if df is not None and not df.empty:
520
+ missing_df = (df.isnull().mean() * 100).reset_index()
521
+ missing_df.columns = ['Colonne', 'Pourcentage Manquant']
522
+ missing_df = missing_df[missing_df['Pourcentage Manquant'] > 0]
523
+
524
+ if not missing_df.empty:
525
+ st.caption(f"📊 {len(missing_df)} colonnes avec données manquantes")
526
+
527
+ fig = px.bar(
528
+ missing_df.sort_values('Pourcentage Manquant', ascending=False),
529
+ x='Colonne',
530
+ y='Pourcentage Manquant',
531
+ title=f"Données manquantes - {df_name}",
532
+ color='Pourcentage Manquant',
533
+ color_continuous_scale='Reds',
534
+ text='Pourcentage Manquant'
535
+ )
536
+ fig.update_traces(texttemplate='%{text:.1f}%', textposition='outside')
537
+ fig.update_layout(xaxis_tickangle=-45, height=400)
538
+ st.plotly_chart(fig, use_container_width=True)
539
+ else:
540
+ st.success(f"✅ Aucune donnée manquante dans {df_name}")
541
+ else:
542
+ st.warning(f"⚠️ Aucune donnée {df_name} disponible")
543
+
544
+ # Radar de complétude
545
+ st.plotly_chart(plot_completeness_radar(analyzer), use_container_width=True)
546
+
547
+ st.divider()
548
+
549
+ # ==================================================
550
+ # ========== 2️⃣ COHÉRENCE DES DONNÉES =============
551
+ # ==================================================
552
+ st.subheader("2️⃣ Cohérence et Validité des Données")
553
+
554
+ col1, col2 = st.columns(2)
555
+
556
+ with col1:
557
+ st.markdown("#### 👥 Cohérence Patients")
558
+ st.metric("Âges négatifs", metrics['patients']['ages_negatifs'], delta_color="inverse")
559
+ st.metric("Âges aberrants (>120 ans)", metrics['patients']['ages_aberrants'], delta_color="inverse")
560
+ st.metric("Doublons (nom+prénom)", metrics['patients']['doublons'], delta_color="inverse")
561
+
562
+ with col2:
563
+ st.markdown("#### 🏥 Cohérence Séjours")
564
+ st.metric("Dates inversées (sortie < entrée)", metrics['sejours']['dates_inversees'], delta_color="inverse")
565
+ st.metric("Gravité aberrante (<0 ou >5)", metrics['sejours']['gravite_aberrante'], delta_color="inverse")
566
+ st.metric("Équipement manquant", metrics['sejours']['equipement_manquant'], delta_color="inverse")
567
+
568
+ # Graphique qualité par service
569
+ fig_service = plot_service_quality_heatmap(analyzer)
570
+ if fig_service:
571
+ st.plotly_chart(fig_service, use_container_width=True)
572
+
573
+ st.divider()
574
+
575
+ # ==================================================
576
+ # ========== 3️⃣ ANALYSE DES ANOMALIES =============
577
+ # ==================================================
578
+ st.subheader("3️⃣ Analyse Détaillée des Anomalies")
579
+
580
+ tab1, tab2, tab3, tab4 = st.tabs(["📋 Patients", "🏥 Séjours", "⚕️ Ressources", "📦 Stock"])
581
+
582
+ for tab, df_label in [(tab1, 'pat'), (tab2, 'sej'), (tab3, 'res'), (tab4, 'stock')]:
583
+ with tab:
584
+ anomaly_details = analyzer.get_anomalies_by_type(df_label)
585
 
586
+ if not anomaly_details.empty:
587
+ st.dataframe(anomaly_details, use_container_width=True)
588
+
589
+ fig = px.pie(
590
+ anomaly_details,
591
+ values='Nombre',
592
+ names='Type Anomalie',
593
+ title=f"Répartition des Types d'Anomalies"
594
+ )
 
 
595
  st.plotly_chart(fig, use_container_width=True)
596
  else:
597
+ st.success(f"✅ Aucune anomalie détectée")
598
+
599
+ # Évolution temporelle
600
+ fig_temporal = plot_temporal_anomalies(analyzer)
601
+ if fig_temporal:
602
+ st.plotly_chart(fig_temporal, use_container_width=True)
603
+
604
+ st.divider()
605
+
606
+ # ==================================================
607
+ # ========== 4️⃣ SPÉCIFICITÉS HOSPITALIÈRES ========
608
+ # ==================================================
609
+ st.subheader("4️⃣ Analyses Spécifiques Hospitalières")
610
+
611
+ col1, col2 = st.columns(2)
612
+
613
+ with col1:
614
+ st.markdown("#### 🛏️ Ressources")
615
+ if analyzer.df_res is not None:
616
+ st.metric("Cas de suroccupation", metrics['ressources']['suroccupation'], delta_color="inverse")
617
+
618
+ # Taux occupation moyen par service
619
+ if 'service_code' in analyzer.df_res.columns and 'taux_occupation' in analyzer.df_res.columns:
620
+ taux_moy = analyzer.df_res.groupby('service_code')['taux_occupation'].mean().reset_index()
621
+ taux_moy.columns = ['Service', 'Taux Occupation Moyen']
622
+ taux_moy = taux_moy.sort_values('Taux Occupation Moyen', ascending=False)
623
+
624
+ fig = px.bar(
625
+ taux_moy.head(10),
626
+ x='Service',
627
+ y='Taux Occupation Moyen',
628
+ title="Top 10 Services - Taux Occupation",
629
+ color='Taux Occupation Moyen',
630
+ color_continuous_scale='RdYlGn_r'
631
+ )
632
  st.plotly_chart(fig, use_container_width=True)
633
+
634
+ with col2:
635
+ st.markdown("#### 📦 Stock")
636
+ st.metric("Alertes stock actives", metrics['stock']['alertes'], delta_color="inverse")
637
+
638
+ # Distribution des alertes par matériel
639
+ if analyzer.df_stock is not None and 'alerte_stock' in analyzer.df_stock.columns:
640
+ alertes = analyzer.df_stock[analyzer.df_stock['alerte_stock'] == 1]
641
+ if not alertes.empty and 'materiel_code' in alertes.columns:
642
+ alert_counts = alertes['materiel_code'].value_counts().reset_index()
643
+ alert_counts.columns = ['Matériel', 'Nombre Alertes']
644
+
645
+ fig = px.bar(
646
+ alert_counts.head(10),
647
+ x='Matériel',
648
+ y='Nombre Alertes',
649
+ title="Top 10 Matériels en Alerte",
650
+ color='Nombre Alertes',
651
+ color_continuous_scale='Oranges'
652
+ )
653
  st.plotly_chart(fig, use_container_width=True)
654
+
655
+ st.divider()
656
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
657
  # ==================================================
658
+ # ========== 5️⃣ RÉSUMÉ EXÉCUTIF ===================
659
  # ==================================================
660
+ st.subheader("5️⃣ Résumé Exécutif Data Quality")
661
+
662
+ col1, col2, col3 = st.columns(3)
663
+
664
+ col1.metric("Score de Complétude", f"{scores['completeness']:.2f}%")
665
+ col2.metric("Score d'Exactitude", f"{scores['accuracy']:.2f}%")
666
+
667
+ global_score = scores['global']
668
+
 
 
 
 
 
 
 
 
 
 
 
669
  if global_score >= 95:
670
  status = "🟢 EXCELLENT"
671
+ color = "green"
672
  elif global_score >= 85:
673
  status = "🟡 BON"
674
+ color = "blue"
675
  elif global_score >= 70:
676
  status = "🟠 MOYEN"
677
+ color = "orange"
678
  else:
679
  status = "🔴 CRITIQUE"
680
+ color = "red"
681
+
682
+ col3.markdown(f"**Score Global**")
683
+ col3.markdown(f"<h2 style='color: {color};'>{global_score:.2f}%</h2>", unsafe_allow_html=True)
684
+
685
+ st.markdown(f"### {status}")
686
  st.progress(global_score / 100)
687
+
688
+ # Recommandations
689
+ st.markdown("#### 💡 Recommandations")
690
+
691
+ recommendations = []
692
+
693
+ if metrics['patients']['anomalies'] > 0:
694
+ recommendations.append(f"⚠️ **{metrics['patients']['anomalies']} anomalies patients** détectées - Vérifier les données de naissance et villes manquantes")
695
+
696
+ if metrics['sejours']['dates_inversees'] > 0:
697
+ recommendations.append(f"🔴 **{metrics['sejours']['dates_inversees']} séjours avec dates inversées** - Corriger les dates d'entrée/sortie")
698
+
699
+ if metrics['ressources']['suroccupation'] > 0:
700
+ recommendations.append(f"🚨 **{metrics['ressources']['suroccupation']} cas de suroccupation** - Revoir la capacité des services")
701
+
702
+ if metrics['stock']['alertes'] > 0:
703
+ recommendations.append(f"📦 **{metrics['stock']['alertes']} alertes stock** - Réapprovisionner les matériels critiques")
704
+
705
+ if global_score < 85:
706
+ recommendations.append("📊 **Score global < 85%** - Lancer un plan d'action Data Quality urgent")
707
+
708
+ if recommendations:
709
+ for rec in recommendations:
710
+ st.markdown(rec)
711
+ else:
712
+ st.success("✅ Aucune action critique nécessaire - Qualité des données satisfaisante")
713
+
714
+ st.divider()
715
+
716
+ # ==================================================
717
+ # ========== 6️⃣ EXPORT DES RÉSULTATS ==============
718
+ # ==================================================
719
+ st.subheader("6️⃣ Export et Documentation")
720
+
721
+ # Création du rapport
722
+ report_data = {
723
+ 'Métrique': [],
724
+ 'Valeur': []
725
+ }
726
+
727
+ for category, cat_metrics in analyzer.metrics.items():
728
+ for metric_name, value in cat_metrics.items():
729
+ report_data['Métrique'].append(f"{category}_{metric_name}")
730
+ report_data['Valeur'].append(value)
731
+
732
+ # Ajout scores
733
+ report_data['Métrique'].extend(['score_completeness', 'score_accuracy', 'score_global'])
734
+ report_data['Valeur'].extend([scores['completeness'], scores['accuracy'], scores['global']])
735
+
736
+ report_df = pd.DataFrame(report_data)
737
+
738
+ col1, col2 = st.columns(2)
739
+
740
+ with col1:
741
+ csv = report_df.to_csv(index=False).encode('utf-8')
742
+ st.download_button(
743
+ label="📥 Télécharger Rapport DQ (CSV)",
744
+ data=csv,
745
+ file_name=f'data_quality_report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv',
746
+ mime='text/csv'
747
+ )
748
+
749
+ with col2:
750
+ # Export des anomalies détectées
751
+ if metrics['patients']['anomalies'] > 0:
752
+ anomalies_df = analyzer.df_pat[analyzer.df_pat['dq_is_anomaly'] == 1][
753
+ ['patient_id', 'nom', 'prenom', 'dq_anomaly_type', 'dq_anomaly_detail']
754
+ ]
755
+ anomalies_csv = anomalies_df.to_csv(index=False).encode('utf-8')
756
+ st.download_button(
757
+ label="📥 Télécharger Anomalies Patients (CSV)",
758
+ data=anomalies_csv,
759
+ file_name=f'anomalies_patients_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv',
760
+ mime='text/csv'
761
+ )
762
+
763
+
764
+ # Point d'entrée
765
+ if __name__ == "__main__":
766
+ show_enhanced_dq_dashboard()