jaimalklyde commited on
Commit
a719099
·
verified ·
1 Parent(s): 55da826

Update src/module/Indicateurs_DQ.py

Browse files
Files changed (1) hide show
  1. src/module/Indicateurs_DQ.py +93 -270
src/module/Indicateurs_DQ.py CHANGED
@@ -1,24 +1,8 @@
1
  """
2
- MODULE: DATA QUALITY DASHBOARD - VERSION COMPLÈTE
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
- Fonctionnalités :
8
- - ✅ Accès Google Sheets (via _client)
9
- - ✅ Upload fichier Excel local
10
- - ✅ 6 sections complètes d'analyse DQ
11
- - ✅ Métriques hospitalières spécifiques
12
- - ✅ Visualisations enrichies
13
- - ✅ Analyses temporelles des anomalies
14
- - ✅ Recommandations automatiques
15
- - ✅ Export rapports CSV
16
-
17
- Améliorations vs version originale :
18
- - Chargement direct (pas de filtrage ETL) pour voir toutes les anomalies
19
- - 6 sections au lieu de 2
20
- - Analyses spécifiques au contexte hospitalier
21
- - Visualisations interactives avancées
22
  """
23
 
24
  import streamlit as st
@@ -41,7 +25,7 @@ def safe_int(value):
41
  try:
42
  if pd.isna(value):
43
  return 0
44
- return int(value)
45
  except (TypeError, ValueError, OverflowError):
46
  return 0
47
 
@@ -70,24 +54,12 @@ def safe_pct(num, den):
70
  # ================= CHARGEMENT DONNÉES ================
71
  # =====================================================
72
 
73
- @st.cache_data(ttl=3600)
74
  def load_from_google_sheets(_client, sheet_name: str) -> dict:
75
-
76
- """
77
- Charge les données depuis Google Sheets SANS passer par ETL.
78
- Garantit qu'aucune anomalie n'est filtrée.
79
-
80
- Args:
81
- _client: _client Google Sheets
82
- sheet_name: Nom de la feuille Google Sheets
83
-
84
- Returns:
85
- Dictionnaire de DataFrames
86
- """
87
  sheets = {}
88
 
89
  try:
90
- # Liste des feuilles à charger
91
  sheet_names = [
92
  'patients', 'sejours', 'ressources_jour',
93
  'stock_materiel_jour', 'admissions_jour', 'scenarios_calendrier'
@@ -95,50 +67,34 @@ def load_from_google_sheets(_client, sheet_name: str) -> dict:
95
 
96
  for name in sheet_names:
97
  try:
98
- # Récupération directe depuis Google Sheets
99
  worksheet = _client.open(sheet_name).worksheet(name)
100
  data = worksheet.get_all_records()
101
  df = pd.DataFrame(data)
102
 
103
  # Conversion des dates
104
- if 'date' in df.columns:
105
- df['date'] = pd.to_datetime(df['date'], errors='coerce')
106
- if 'date_entree' in df.columns:
107
- df['date_entree'] = pd.to_datetime(df['date_entree'], errors='coerce')
108
- if 'date_sortie' in df.columns:
109
- df['date_sortie'] = pd.to_datetime(df['date_sortie'], errors='coerce')
110
- if 'date_naissance' in df.columns:
111
- df['date_naissance'] = pd.to_datetime(df['date_naissance'], errors='coerce')
112
 
113
  sheets[name] = df
114
 
115
  except Exception as e:
116
- st.warning(f"⚠️ Impossible de charger la feuille '{name}': {str(e)}")
117
  continue
118
 
119
  return sheets
120
 
121
  except Exception as e:
122
- st.error(f"❌ Erreur lors du chargement depuis Google Sheets : {str(e)}")
123
  return {}
124
 
125
 
126
  @st.cache_data(ttl=3600)
127
  def load_from_excel_file(file) -> dict:
128
- """
129
- Charge les données depuis un fichier Excel uploadé.
130
- Garantit qu'aucune anomalie n'est filtrée.
131
-
132
- Args:
133
- file: Fichier uploadé via st.file_uploader
134
-
135
- Returns:
136
- Dictionnaire de DataFrames
137
- """
138
  sheets = {}
139
-
140
  try:
141
- # Chargement de toutes les feuilles nécessaires
142
  sheets['patients'] = pd.read_excel(file, sheet_name='patients')
143
  sheets['sejours'] = pd.read_excel(file, sheet_name='sejours')
144
  sheets['ressources_jour'] = pd.read_excel(file, sheet_name='ressources_jour')
@@ -146,21 +102,13 @@ def load_from_excel_file(file) -> dict:
146
  sheets['admissions_jour'] = pd.read_excel(file, sheet_name='admissions_jour')
147
  sheets['scenarios_calendrier'] = pd.read_excel(file, sheet_name='scenarios_calendrier')
148
 
149
- # Conversion des dates
150
  for sheet_name, df in sheets.items():
151
- if 'date' in df.columns:
152
- df['date'] = pd.to_datetime(df['date'], errors='coerce')
153
- if 'date_entree' in df.columns:
154
- df['date_entree'] = pd.to_datetime(df['date_entree'], errors='coerce')
155
- if 'date_sortie' in df.columns:
156
- df['date_sortie'] = pd.to_datetime(df['date_sortie'], errors='coerce')
157
- if 'date_naissance' in df.columns:
158
- df['date_naissance'] = pd.to_datetime(df['date_naissance'], errors='coerce')
159
-
160
  return sheets
161
-
162
  except Exception as e:
163
- st.error(f"❌ Erreur lors du chargement du fichier Excel : {str(e)}")
164
  return {}
165
 
166
 
@@ -182,20 +130,33 @@ class HospitalDataQualityAnalyzer:
182
  self.metrics = {}
183
  self._compute_all_metrics()
184
 
 
 
 
 
 
 
 
185
  def _compute_all_metrics(self):
186
  """Calcul de toutes les métriques DQ"""
187
 
 
 
 
 
 
 
188
  # ============= PATIENTS =============
189
  if self.df_pat is not None and not self.df_pat.empty:
190
  self.metrics['patients'] = {
191
  'total': len(self.df_pat),
192
- 'anomalies': safe_int(self.df_pat['dq_is_anomaly'].sum()),
193
- 'doublons': safe_int(self.df_pat.duplicated(subset=['nom', 'prenom']).sum()),
194
- 'ages_negatifs': safe_int((self.df_pat['age'] < 0).sum()),
195
- 'ages_aberrants': safe_int((self.df_pat['age'] > 120).sum()),
196
- 'missing_birth': safe_int(self.df_pat['date_naissance'].isnull().sum()),
197
- 'missing_ville': safe_int(self.df_pat['ville'].isnull().sum()),
198
- 'missing_total': safe_int(self.df_pat.isnull().sum().sum())
199
  }
200
  else:
201
  self.metrics['patients'] = {k: 0 for k in ['total', 'anomalies', 'doublons', 'ages_negatifs',
@@ -203,16 +164,17 @@ class HospitalDataQualityAnalyzer:
203
 
204
  # ============= SÉJOURS =============
205
  if self.df_sej is not None and not self.df_sej.empty:
206
- dates_inversees = safe_int((self.df_sej['date_sortie'] < self.df_sej['date_entree']).sum())
207
- gravite_aberrante = safe_int(((self.df_sej['gravite_1_5'] > 5) | (self.df_sej['gravite_1_5'] < 0)).sum())
 
208
 
209
  self.metrics['sejours'] = {
210
  'total': len(self.df_sej),
211
- 'anomalies': safe_int(self.df_sej['dq_is_anomaly'].sum()),
212
  'dates_inversees': dates_inversees,
213
  'gravite_aberrante': gravite_aberrante,
214
- 'equipement_manquant': safe_int(self.df_sej['equipement_principal_code'].isnull().sum()),
215
- 'missing_total': safe_int(self.df_sej.isnull().sum().sum())
216
  }
217
  else:
218
  self.metrics['sejours'] = {k: 0 for k in ['total', 'anomalies', 'dates_inversees',
@@ -222,13 +184,14 @@ class HospitalDataQualityAnalyzer:
222
  if self.df_res is not None and not self.df_res.empty:
223
  df_res_temp = self.df_res.copy()
224
  df_res_temp['lits_occupes_num'] = pd.to_numeric(df_res_temp['lits_occupes'], errors='coerce').fillna(0)
225
- suroccupation = safe_int((df_res_temp['lits_occupes_num'] > df_res_temp['lits_total']).sum())
 
226
 
227
  self.metrics['ressources'] = {
228
  'total': len(self.df_res),
229
- 'anomalies': safe_int(self.df_res['dq_is_anomaly'].sum()),
230
  'suroccupation': suroccupation,
231
- 'missing_total': safe_int(self.df_res.isnull().sum().sum())
232
  }
233
  else:
234
  self.metrics['ressources'] = {k: 0 for k in ['total', 'anomalies', 'suroccupation', 'missing_total']}
@@ -237,9 +200,9 @@ class HospitalDataQualityAnalyzer:
237
  if self.df_stock is not None and not self.df_stock.empty:
238
  self.metrics['stock'] = {
239
  'total': len(self.df_stock),
240
- 'anomalies': safe_int(self.df_stock['dq_is_anomaly'].sum()),
241
- 'alertes': safe_int((self.df_stock['alerte_stock'] == 1).sum()),
242
- 'missing_total': safe_int(self.df_stock.isnull().sum().sum())
243
  }
244
  else:
245
  self.metrics['stock'] = {k: 0 for k in ['total', 'anomalies', 'alertes', 'missing_total']}
@@ -297,25 +260,25 @@ class HospitalDataQualityAnalyzer:
297
 
298
  # Complétude
299
  total_rows = (self.metrics['patients']['total'] +
300
- self.metrics['sejours']['total'] +
301
- self.metrics['ressources']['total'])
302
 
303
  total_missing = (self.metrics['patients']['missing_total'] +
304
- self.metrics['sejours']['missing_total'] +
305
- self.metrics['ressources']['missing_total'])
306
 
307
  completeness_score = safe_pct(total_rows - total_missing, total_rows)
308
 
309
  # Exactitude
310
  total_all_rows = (self.metrics['patients']['total'] +
311
- self.metrics['sejours']['total'] +
312
- self.metrics['ressources']['total'] +
313
- self.metrics['stock']['total'])
314
 
315
  total_anomalies = (self.metrics['patients']['anomalies'] +
316
- self.metrics['sejours']['anomalies'] +
317
- self.metrics['ressources']['anomalies'] +
318
- self.metrics['stock']['anomalies'])
319
 
320
  accuracy_score = safe_pct(total_all_rows - total_anomalies, total_all_rows)
321
 
@@ -459,11 +422,11 @@ def plot_completeness_radar(analyzer: HospitalDataQualityAnalyzer):
459
  patient_completeness = 100 - safe_pct(metrics['patients']['missing_total'],
460
  metrics['patients']['total'] * len(analyzer.df_pat.columns) if analyzer.df_pat is not None else 1)
461
  sejour_completeness = 100 - safe_pct(metrics['sejours']['missing_total'],
462
- metrics['sejours']['total'] * len(analyzer.df_sej.columns) if analyzer.df_sej is not None else 1)
463
  ressource_completeness = 100 - safe_pct(metrics['ressources']['missing_total'],
464
- metrics['ressources']['total'] * len(analyzer.df_res.columns) if analyzer.df_res is not None else 1)
465
  stock_completeness = 100 - safe_pct(metrics['stock']['missing_total'],
466
- metrics['stock']['total'] * len(analyzer.df_stock.columns) if analyzer.df_stock is not None else 1)
467
 
468
  categories = ['Patients', 'Séjours', 'Ressources', 'Stock']
469
  values = [patient_completeness, sejour_completeness, ressource_completeness, stock_completeness]
@@ -501,10 +464,6 @@ def plot_completeness_radar(analyzer: HospitalDataQualityAnalyzer):
501
  def show_dq_dashboard(_client=None, sheet_name=None):
502
  """
503
  Dashboard Data Quality complet avec support Google Sheets et Upload Fichier.
504
-
505
- Args:
506
- _client: _client Google Sheets (optionnel)
507
- sheet_name: Nom de la feuille Google Sheets (optionnel)
508
  """
509
  st.markdown("## 🛡️ CENTRE DE CONTRÔLE DATA QUALITY")
510
  st.caption("Analyse complète de la qualité des données hospitalières")
@@ -513,16 +472,13 @@ def show_dq_dashboard(_client=None, sheet_name=None):
513
 
514
  # ================= MODE DE CHARGEMENT =================
515
  if _client is not None and sheet_name is not None:
516
- # Mode Google Sheets (prioritaire si _client fourni)
517
  st.info("📊 **Mode : Google Sheets**")
518
 
519
  with st.spinner("🔄 Chargement depuis Google Sheets..."):
520
  dataframes = load_from_google_sheets(_client, sheet_name)
521
 
522
  else:
523
- # Mode Upload Fichier
524
  st.sidebar.header("⚙️ Configuration")
525
-
526
  uploaded_file = st.sidebar.file_uploader(
527
  "Charger le fichier Excel",
528
  type=['xlsx'],
@@ -531,7 +487,6 @@ def show_dq_dashboard(_client=None, sheet_name=None):
531
 
532
  if uploaded_file is not None:
533
  st.info("📁 **Mode : Fichier Excel Local**")
534
-
535
  with st.spinner("🔄 Chargement du fichier Excel..."):
536
  dataframes = load_from_excel_file(uploaded_file)
537
  else:
@@ -556,40 +511,14 @@ def show_dq_dashboard(_client=None, sheet_name=None):
556
  st.markdown("### 📊 Synthèse de Santé des Données")
557
 
558
  col1, col2, col3, col4 = st.columns(4)
559
-
560
  metrics = analyzer.metrics
561
 
562
- col1.metric(
563
- "🔴 Anomalies Patients",
564
- f"{metrics['patients']['anomalies']:,}",
565
- f"{safe_pct(metrics['patients']['anomalies'], metrics['patients']['total'])}%",
566
- delta_color="inverse"
567
- )
568
-
569
- col2.metric(
570
- "🔴 Anomalies Séjours",
571
- f"{metrics['sejours']['anomalies']:,}",
572
- f"{safe_pct(metrics['sejours']['anomalies'], metrics['sejours']['total'])}%",
573
- delta_color="inverse"
574
- )
575
-
576
- col3.metric(
577
- "🔴 Anomalies Ressources",
578
- f"{metrics['ressources']['anomalies']:,}",
579
- f"{safe_pct(metrics['ressources']['anomalies'], metrics['ressources']['total'])}%",
580
- delta_color="inverse"
581
- )
582
-
583
- col4.metric(
584
- "🔴 Anomalies Stock",
585
- f"{metrics['stock']['anomalies']:,}",
586
- f"{safe_pct(metrics['stock']['anomalies'], metrics['stock']['total'])}%",
587
- delta_color="inverse"
588
- )
589
 
590
- # Graphique distribution anomalies
591
  st.plotly_chart(plot_anomalies_distribution(analyzer), use_container_width=True)
592
-
593
  st.divider()
594
 
595
  # ==================================================
@@ -611,16 +540,10 @@ def show_dq_dashboard(_client=None, sheet_name=None):
611
 
612
  if not missing_df.empty:
613
  st.caption(f"📊 {len(missing_df)} colonnes avec données manquantes")
614
-
615
- fig = px.bar(
616
- missing_df.sort_values('Pourcentage Manquant', ascending=False),
617
- x='Colonne',
618
- y='Pourcentage Manquant',
619
- title=f"Données manquantes - {df_name}",
620
- color='Pourcentage Manquant',
621
- color_continuous_scale='Reds',
622
- text='Pourcentage Manquant'
623
- )
624
  fig.update_traces(texttemplate='%{text:.1f}%', textposition='outside')
625
  fig.update_layout(xaxis_tickangle=-45, height=400)
626
  st.plotly_chart(fig, use_container_width=True)
@@ -629,226 +552,126 @@ def show_dq_dashboard(_client=None, sheet_name=None):
629
  else:
630
  st.warning(f"⚠️ Aucune donnée {df_name} disponible")
631
 
632
- # Radar de complétude
633
  st.plotly_chart(plot_completeness_radar(analyzer), use_container_width=True)
634
-
635
  st.divider()
636
 
637
  # ==================================================
638
  # ========== 2️⃣ COHÉRENCE DES DONNÉES =============
639
  # ==================================================
640
  st.subheader("2️⃣ Cohérence et Validité des Données")
641
-
642
  col1, col2 = st.columns(2)
643
-
644
  with col1:
645
  st.markdown("#### 👥 Cohérence Patients")
646
  st.metric("Âges négatifs", metrics['patients']['ages_negatifs'], delta_color="inverse")
647
  st.metric("Âges aberrants (>120 ans)", metrics['patients']['ages_aberrants'], delta_color="inverse")
648
  st.metric("Doublons (nom+prénom)", metrics['patients']['doublons'], delta_color="inverse")
649
-
650
  with col2:
651
  st.markdown("#### 🏥 Cohérence Séjours")
652
  st.metric("Dates inversées (sortie < entrée)", metrics['sejours']['dates_inversees'], delta_color="inverse")
653
  st.metric("Gravité aberrante (<0 ou >5)", metrics['sejours']['gravite_aberrante'], delta_color="inverse")
654
  st.metric("Équipement manquant", metrics['sejours']['equipement_manquant'], delta_color="inverse")
655
 
656
- # Graphique qualité par service
657
  fig_service = plot_service_quality_heatmap(analyzer)
658
- if fig_service:
659
- st.plotly_chart(fig_service, use_container_width=True)
660
-
661
  st.divider()
662
 
663
  # ==================================================
664
  # ========== 3️⃣ ANALYSE DES ANOMALIES =============
665
  # ==================================================
666
  st.subheader("3️⃣ Analyse Détaillée des Anomalies")
667
-
668
  tab1, tab2, tab3, tab4 = st.tabs(["📋 Patients", "🏥 Séjours", "⚕️ Ressources", "📦 Stock"])
669
-
670
  for tab, df_label in [(tab1, 'pat'), (tab2, 'sej'), (tab3, 'res'), (tab4, 'stock')]:
671
  with tab:
672
  anomaly_details = analyzer.get_anomalies_by_type(df_label)
673
-
674
  if not anomaly_details.empty:
675
  st.dataframe(anomaly_details, use_container_width=True)
676
-
677
- fig = px.pie(
678
- anomaly_details,
679
- values='Nombre',
680
- names='Type Anomalie',
681
- title=f"Répartition des Types d'Anomalies"
682
- )
683
  st.plotly_chart(fig, use_container_width=True)
684
  else:
685
  st.success(f"✅ Aucune anomalie détectée")
686
 
687
- # Évolution temporelle
688
  fig_temporal = plot_temporal_anomalies(analyzer)
689
- if fig_temporal:
690
- st.plotly_chart(fig_temporal, use_container_width=True)
691
-
692
  st.divider()
693
 
694
  # ==================================================
695
  # ========== 4️⃣ SPÉCIFICITÉS HOSPITALIÈRES ========
696
  # ==================================================
697
  st.subheader("4️⃣ Analyses Spécifiques Hospitalières")
698
-
699
  col1, col2 = st.columns(2)
700
-
701
  with col1:
702
  st.markdown("#### 🛏️ Ressources")
703
  if analyzer.df_res is not None:
704
  st.metric("Cas de suroccupation", metrics['ressources']['suroccupation'], delta_color="inverse")
705
-
706
- # Taux occupation moyen par service
707
  if 'service_code' in analyzer.df_res.columns and 'taux_occupation' in analyzer.df_res.columns:
708
  taux_moy = analyzer.df_res.groupby('service_code')['taux_occupation'].mean().reset_index()
709
  taux_moy.columns = ['Service', 'Taux Occupation Moyen']
710
- taux_moy = taux_moy.sort_values('Taux Occupation Moyen', ascending=False)
711
-
712
- fig = px.bar(
713
- taux_moy.head(10),
714
- x='Service',
715
- y='Taux Occupation Moyen',
716
- title="Top 10 Services - Taux Occupation",
717
- color='Taux Occupation Moyen',
718
- color_continuous_scale='RdYlGn_r'
719
- )
720
  st.plotly_chart(fig, use_container_width=True)
721
-
722
  with col2:
723
  st.markdown("#### 📦 Stock")
724
  st.metric("Alertes stock actives", metrics['stock']['alertes'], delta_color="inverse")
725
-
726
- # Distribution des alertes par matériel
727
  if analyzer.df_stock is not None and 'alerte_stock' in analyzer.df_stock.columns:
728
- alertes = analyzer.df_stock[analyzer.df_stock['alerte_stock'] == 1]
729
  if not alertes.empty and 'materiel_code' in alertes.columns:
730
  alert_counts = alertes['materiel_code'].value_counts().reset_index()
731
  alert_counts.columns = ['Matériel', 'Nombre Alertes']
732
-
733
- fig = px.bar(
734
- alert_counts.head(10),
735
- x='Matériel',
736
- y='Nombre Alertes',
737
- title="Top 10 Matériels en Alerte",
738
- color='Nombre Alertes',
739
- color_continuous_scale='Oranges'
740
- )
741
  st.plotly_chart(fig, use_container_width=True)
742
-
743
  st.divider()
744
 
745
  # ==================================================
746
  # ========== 5️⃣ RÉSUMÉ EXÉCUTIF ===================
747
  # ==================================================
748
  st.subheader("5️⃣ Résumé Exécutif Data Quality")
749
-
750
  col1, col2, col3 = st.columns(3)
751
-
752
  col1.metric("Score de Complétude", f"{scores['completeness']:.2f}%")
753
  col2.metric("Score d'Exactitude", f"{scores['accuracy']:.2f}%")
754
-
755
  global_score = scores['global']
756
-
757
- if global_score >= 95:
758
- status = "🟢 EXCELLENT"
759
- color = "green"
760
- elif global_score >= 85:
761
- status = "🟡 BON"
762
- color = "blue"
763
- elif global_score >= 70:
764
- status = "🟠 MOYEN"
765
- color = "orange"
766
- else:
767
- status = "🔴 CRITIQUE"
768
- color = "red"
769
-
770
  col3.markdown(f"**Score Global**")
771
  col3.markdown(f"<h2 style='color: {color};'>{global_score:.2f}%</h2>", unsafe_allow_html=True)
772
-
773
  st.markdown(f"### {status}")
774
  st.progress(global_score / 100)
775
 
776
- # Recommandations
777
  st.markdown("#### 💡 Recommandations")
778
-
779
  recommendations = []
780
-
781
- if metrics['patients']['anomalies'] > 0:
782
- recommendations.append(f"⚠️ **{metrics['patients']['anomalies']} anomalies patients** détectées - Vérifier les données de naissance et villes manquantes")
783
-
784
- if metrics['sejours']['dates_inversees'] > 0:
785
- recommendations.append(f"🔴 **{metrics['sejours']['dates_inversees']} séjours avec dates inversées** - Corriger les dates d'entrée/sortie")
786
-
787
- if metrics['ressources']['suroccupation'] > 0:
788
- recommendations.append(f"🚨 **{metrics['ressources']['suroccupation']} cas de suroccupation** - Revoir la capacité des services")
789
-
790
- if metrics['stock']['alertes'] > 0:
791
- recommendations.append(f"📦 **{metrics['stock']['alertes']} alertes stock** - Réapprovisionner les matériels critiques")
792
-
793
- if global_score < 85:
794
- recommendations.append("📊 **Score global < 85%** - Lancer un plan d'action Data Quality urgent")
795
 
796
  if recommendations:
797
- for rec in recommendations:
798
- st.markdown(rec)
799
- else:
800
- st.success("✅ Aucune action critique nécessaire - Qualité des données satisfaisante")
801
-
802
  st.divider()
803
 
804
  # ==================================================
805
  # ========== 6️⃣ EXPORT DES RÉSULTATS ==============
806
  # ==================================================
807
  st.subheader("6️⃣ Export et Documentation")
808
-
809
- # Création du rapport
810
- report_data = {
811
- 'Métrique': [],
812
- 'Valeur': []
813
- }
814
-
815
  for category, cat_metrics in analyzer.metrics.items():
816
  for metric_name, value in cat_metrics.items():
817
  report_data['Métrique'].append(f"{category}_{metric_name}")
818
  report_data['Valeur'].append(value)
819
-
820
- # Ajout scores
821
  report_data['Métrique'].extend(['score_completeness', 'score_accuracy', 'score_global'])
822
  report_data['Valeur'].extend([scores['completeness'], scores['accuracy'], scores['global']])
823
-
824
  report_df = pd.DataFrame(report_data)
825
 
826
  col1, col2 = st.columns(2)
827
-
828
  with col1:
829
  csv = report_df.to_csv(index=False).encode('utf-8')
830
- st.download_button(
831
- label="📥 Télécharger Rapport DQ (CSV)",
832
- data=csv,
833
- file_name=f'data_quality_report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv',
834
- mime='text/csv'
835
- )
836
-
837
  with col2:
838
- # Export des anomalies détectées
839
  if metrics['patients']['anomalies'] > 0 and analyzer.df_pat is not None:
840
- anomalies_df = analyzer.df_pat[analyzer.df_pat['dq_is_anomaly'] == 1][
841
- ['patient_id', 'nom', 'prenom', 'dq_anomaly_type', 'dq_anomaly_detail']
842
- ]
843
  anomalies_csv = anomalies_df.to_csv(index=False).encode('utf-8')
844
- st.download_button(
845
- label="📥 Télécharger Anomalies Patients (CSV)",
846
- data=anomalies_csv,
847
- file_name=f'anomalies_patients_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv',
848
- mime='text/csv'
849
- )
850
-
851
 
852
- # Point d'entrée
853
  if __name__ == "__main__":
854
  show_dq_dashboard()
 
1
  """
2
+ MODULE: DATA QUALITY DASHBOARD - VERSION CORRIGÉE
3
+ =================================================
4
+ Responsabilité : Audit complet, détection d'anomalies.
5
+ Correction : Gestion stricte des types numériques pour éviter les crashs (int/str).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  """
7
 
8
  import streamlit as st
 
25
  try:
26
  if pd.isna(value):
27
  return 0
28
+ return int(float(value)) # Double cast pour gérer les strings '1.0'
29
  except (TypeError, ValueError, OverflowError):
30
  return 0
31
 
 
54
  # ================= CHARGEMENT DONNÉES ================
55
  # =====================================================
56
 
57
+ @st.cache_data(ttl=60) # TTL court pour voir les nouvelles données injectées
58
  def load_from_google_sheets(_client, sheet_name: str) -> dict:
59
+ """Charge les données depuis Google Sheets SANS passer par ETL."""
 
 
 
 
 
 
 
 
 
 
 
60
  sheets = {}
61
 
62
  try:
 
63
  sheet_names = [
64
  'patients', 'sejours', 'ressources_jour',
65
  'stock_materiel_jour', 'admissions_jour', 'scenarios_calendrier'
 
67
 
68
  for name in sheet_names:
69
  try:
 
70
  worksheet = _client.open(sheet_name).worksheet(name)
71
  data = worksheet.get_all_records()
72
  df = pd.DataFrame(data)
73
 
74
  # Conversion des dates
75
+ date_cols = ['date', 'date_entree', 'date_sortie', 'date_naissance']
76
+ for col in date_cols:
77
+ if col in df.columns:
78
+ df[col] = pd.to_datetime(df[col], errors='coerce')
 
 
 
 
79
 
80
  sheets[name] = df
81
 
82
  except Exception as e:
83
+ # On continue même si une feuille manque
84
  continue
85
 
86
  return sheets
87
 
88
  except Exception as e:
89
+ st.error(f"Erreur lors du chargement depuis Google Sheets : {str(e)}")
90
  return {}
91
 
92
 
93
  @st.cache_data(ttl=3600)
94
  def load_from_excel_file(file) -> dict:
95
+ """Charge les données depuis un fichier Excel uploadé."""
 
 
 
 
 
 
 
 
 
96
  sheets = {}
 
97
  try:
 
98
  sheets['patients'] = pd.read_excel(file, sheet_name='patients')
99
  sheets['sejours'] = pd.read_excel(file, sheet_name='sejours')
100
  sheets['ressources_jour'] = pd.read_excel(file, sheet_name='ressources_jour')
 
102
  sheets['admissions_jour'] = pd.read_excel(file, sheet_name='admissions_jour')
103
  sheets['scenarios_calendrier'] = pd.read_excel(file, sheet_name='scenarios_calendrier')
104
 
 
105
  for sheet_name, df in sheets.items():
106
+ for col in df.columns:
107
+ if 'date' in col.lower():
108
+ df[col] = pd.to_datetime(df[col], errors='coerce')
 
 
 
 
 
 
109
  return sheets
 
110
  except Exception as e:
111
+ st.error(f"Erreur lors du chargement du fichier Excel : {str(e)}")
112
  return {}
113
 
114
 
 
130
  self.metrics = {}
131
  self._compute_all_metrics()
132
 
133
+ def _force_numeric_anomaly(self, df):
134
+ """Force la colonne dq_is_anomaly en numérique pour éviter les crashs"""
135
+ if df is not None and not df.empty and 'dq_is_anomaly' in df.columns:
136
+ # Conversion robuste : force tout en nombre, remplace les erreurs par 0
137
+ df['dq_is_anomaly'] = pd.to_numeric(df['dq_is_anomaly'], errors='coerce').fillna(0).astype(int)
138
+ return df
139
+
140
  def _compute_all_metrics(self):
141
  """Calcul de toutes les métriques DQ"""
142
 
143
+ # NETTOYAGE PRÉALABLE : Conversion des types pour éviter le crash int+str
144
+ self.df_pat = self._force_numeric_anomaly(self.df_pat)
145
+ self.df_sej = self._force_numeric_anomaly(self.df_sej)
146
+ self.df_res = self._force_numeric_anomaly(self.df_res)
147
+ self.df_stock = self._force_numeric_anomaly(self.df_stock)
148
+
149
  # ============= PATIENTS =============
150
  if self.df_pat is not None and not self.df_pat.empty:
151
  self.metrics['patients'] = {
152
  'total': len(self.df_pat),
153
+ 'anomalies': int(self.df_pat['dq_is_anomaly'].sum()),
154
+ 'doublons': int(self.df_pat.duplicated(subset=['nom', 'prenom']).sum()),
155
+ 'ages_negatifs': int((pd.to_numeric(self.df_pat['age'], errors='coerce') < 0).sum()),
156
+ 'ages_aberrants': int((pd.to_numeric(self.df_pat['age'], errors='coerce') > 120).sum()),
157
+ 'missing_birth': int(self.df_pat['date_naissance'].isnull().sum()),
158
+ 'missing_ville': int(self.df_pat['ville'].isnull().sum()),
159
+ 'missing_total': int(self.df_pat.isnull().sum().sum())
160
  }
161
  else:
162
  self.metrics['patients'] = {k: 0 for k in ['total', 'anomalies', 'doublons', 'ages_negatifs',
 
164
 
165
  # ============= SÉJOURS =============
166
  if self.df_sej is not None and not self.df_sej.empty:
167
+ dates_inversees = int((self.df_sej['date_sortie'] < self.df_sej['date_entree']).sum())
168
+ gravite = pd.to_numeric(self.df_sej['gravite_1_5'], errors='coerce').fillna(3)
169
+ gravite_aberrante = int(((gravite > 5) | (gravite < 0)).sum())
170
 
171
  self.metrics['sejours'] = {
172
  'total': len(self.df_sej),
173
+ 'anomalies': int(self.df_sej['dq_is_anomaly'].sum()),
174
  'dates_inversees': dates_inversees,
175
  'gravite_aberrante': gravite_aberrante,
176
+ 'equipement_manquant': int(self.df_sej['equipement_principal_code'].isnull().sum()),
177
+ 'missing_total': int(self.df_sej.isnull().sum().sum())
178
  }
179
  else:
180
  self.metrics['sejours'] = {k: 0 for k in ['total', 'anomalies', 'dates_inversees',
 
184
  if self.df_res is not None and not self.df_res.empty:
185
  df_res_temp = self.df_res.copy()
186
  df_res_temp['lits_occupes_num'] = pd.to_numeric(df_res_temp['lits_occupes'], errors='coerce').fillna(0)
187
+ df_res_temp['lits_total_num'] = pd.to_numeric(df_res_temp['lits_total'], errors='coerce').fillna(1)
188
+ suroccupation = int((df_res_temp['lits_occupes_num'] > df_res_temp['lits_total_num']).sum())
189
 
190
  self.metrics['ressources'] = {
191
  'total': len(self.df_res),
192
+ 'anomalies': int(self.df_res['dq_is_anomaly'].sum()),
193
  'suroccupation': suroccupation,
194
+ 'missing_total': int(self.df_res.isnull().sum().sum())
195
  }
196
  else:
197
  self.metrics['ressources'] = {k: 0 for k in ['total', 'anomalies', 'suroccupation', 'missing_total']}
 
200
  if self.df_stock is not None and not self.df_stock.empty:
201
  self.metrics['stock'] = {
202
  'total': len(self.df_stock),
203
+ 'anomalies': int(self.df_stock['dq_is_anomaly'].sum()),
204
+ 'alertes': int((self.df_stock['alerte_stock'].astype(str) == '1').sum()),
205
+ 'missing_total': int(self.df_stock.isnull().sum().sum())
206
  }
207
  else:
208
  self.metrics['stock'] = {k: 0 for k in ['total', 'anomalies', 'alertes', 'missing_total']}
 
260
 
261
  # Complétude
262
  total_rows = (self.metrics['patients']['total'] +
263
+ self.metrics['sejours']['total'] +
264
+ self.metrics['ressources']['total'])
265
 
266
  total_missing = (self.metrics['patients']['missing_total'] +
267
+ self.metrics['sejours']['missing_total'] +
268
+ self.metrics['ressources']['missing_total'])
269
 
270
  completeness_score = safe_pct(total_rows - total_missing, total_rows)
271
 
272
  # Exactitude
273
  total_all_rows = (self.metrics['patients']['total'] +
274
+ self.metrics['sejours']['total'] +
275
+ self.metrics['ressources']['total'] +
276
+ self.metrics['stock']['total'])
277
 
278
  total_anomalies = (self.metrics['patients']['anomalies'] +
279
+ self.metrics['sejours']['anomalies'] +
280
+ self.metrics['ressources']['anomalies'] +
281
+ self.metrics['stock']['anomalies'])
282
 
283
  accuracy_score = safe_pct(total_all_rows - total_anomalies, total_all_rows)
284
 
 
422
  patient_completeness = 100 - safe_pct(metrics['patients']['missing_total'],
423
  metrics['patients']['total'] * len(analyzer.df_pat.columns) if analyzer.df_pat is not None else 1)
424
  sejour_completeness = 100 - safe_pct(metrics['sejours']['missing_total'],
425
+ metrics['sejours']['total'] * len(analyzer.df_sej.columns) if analyzer.df_sej is not None else 1)
426
  ressource_completeness = 100 - safe_pct(metrics['ressources']['missing_total'],
427
+ metrics['ressources']['total'] * len(analyzer.df_res.columns) if analyzer.df_res is not None else 1)
428
  stock_completeness = 100 - safe_pct(metrics['stock']['missing_total'],
429
+ metrics['stock']['total'] * len(analyzer.df_stock.columns) if analyzer.df_stock is not None else 1)
430
 
431
  categories = ['Patients', 'Séjours', 'Ressources', 'Stock']
432
  values = [patient_completeness, sejour_completeness, ressource_completeness, stock_completeness]
 
464
  def show_dq_dashboard(_client=None, sheet_name=None):
465
  """
466
  Dashboard Data Quality complet avec support Google Sheets et Upload Fichier.
 
 
 
 
467
  """
468
  st.markdown("## 🛡️ CENTRE DE CONTRÔLE DATA QUALITY")
469
  st.caption("Analyse complète de la qualité des données hospitalières")
 
472
 
473
  # ================= MODE DE CHARGEMENT =================
474
  if _client is not None and sheet_name is not None:
 
475
  st.info("📊 **Mode : Google Sheets**")
476
 
477
  with st.spinner("🔄 Chargement depuis Google Sheets..."):
478
  dataframes = load_from_google_sheets(_client, sheet_name)
479
 
480
  else:
 
481
  st.sidebar.header("⚙️ Configuration")
 
482
  uploaded_file = st.sidebar.file_uploader(
483
  "Charger le fichier Excel",
484
  type=['xlsx'],
 
487
 
488
  if uploaded_file is not None:
489
  st.info("📁 **Mode : Fichier Excel Local**")
 
490
  with st.spinner("🔄 Chargement du fichier Excel..."):
491
  dataframes = load_from_excel_file(uploaded_file)
492
  else:
 
511
  st.markdown("### 📊 Synthèse de Santé des Données")
512
 
513
  col1, col2, col3, col4 = st.columns(4)
 
514
  metrics = analyzer.metrics
515
 
516
+ col1.metric("🔴 Anomalies Patients", f"{metrics['patients']['anomalies']:,}", delta_color="inverse")
517
+ col2.metric("🔴 Anomalies Séjours", f"{metrics['sejours']['anomalies']:,}", delta_color="inverse")
518
+ col3.metric("🔴 Anomalies Ressources", f"{metrics['ressources']['anomalies']:,}", delta_color="inverse")
519
+ col4.metric("🔴 Anomalies Stock", f"{metrics['stock']['anomalies']:,}", delta_color="inverse")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520
 
 
521
  st.plotly_chart(plot_anomalies_distribution(analyzer), use_container_width=True)
 
522
  st.divider()
523
 
524
  # ==================================================
 
540
 
541
  if not missing_df.empty:
542
  st.caption(f"📊 {len(missing_df)} colonnes avec données manquantes")
543
+ fig = px.bar(missing_df.sort_values('Pourcentage Manquant', ascending=False),
544
+ x='Colonne', y='Pourcentage Manquant',
545
+ title=f"Données manquantes - {df_name}",
546
+ color='Pourcentage Manquant', color_continuous_scale='Reds', text='Pourcentage Manquant')
 
 
 
 
 
 
547
  fig.update_traces(texttemplate='%{text:.1f}%', textposition='outside')
548
  fig.update_layout(xaxis_tickangle=-45, height=400)
549
  st.plotly_chart(fig, use_container_width=True)
 
552
  else:
553
  st.warning(f"⚠️ Aucune donnée {df_name} disponible")
554
 
 
555
  st.plotly_chart(plot_completeness_radar(analyzer), use_container_width=True)
 
556
  st.divider()
557
 
558
  # ==================================================
559
  # ========== 2️⃣ COHÉRENCE DES DONNÉES =============
560
  # ==================================================
561
  st.subheader("2️⃣ Cohérence et Validité des Données")
 
562
  col1, col2 = st.columns(2)
 
563
  with col1:
564
  st.markdown("#### 👥 Cohérence Patients")
565
  st.metric("Âges négatifs", metrics['patients']['ages_negatifs'], delta_color="inverse")
566
  st.metric("Âges aberrants (>120 ans)", metrics['patients']['ages_aberrants'], delta_color="inverse")
567
  st.metric("Doublons (nom+prénom)", metrics['patients']['doublons'], delta_color="inverse")
 
568
  with col2:
569
  st.markdown("#### 🏥 Cohérence Séjours")
570
  st.metric("Dates inversées (sortie < entrée)", metrics['sejours']['dates_inversees'], delta_color="inverse")
571
  st.metric("Gravité aberrante (<0 ou >5)", metrics['sejours']['gravite_aberrante'], delta_color="inverse")
572
  st.metric("Équipement manquant", metrics['sejours']['equipement_manquant'], delta_color="inverse")
573
 
 
574
  fig_service = plot_service_quality_heatmap(analyzer)
575
+ if fig_service: st.plotly_chart(fig_service, use_container_width=True)
 
 
576
  st.divider()
577
 
578
  # ==================================================
579
  # ========== 3️⃣ ANALYSE DES ANOMALIES =============
580
  # ==================================================
581
  st.subheader("3️⃣ Analyse Détaillée des Anomalies")
 
582
  tab1, tab2, tab3, tab4 = st.tabs(["📋 Patients", "🏥 Séjours", "⚕️ Ressources", "📦 Stock"])
 
583
  for tab, df_label in [(tab1, 'pat'), (tab2, 'sej'), (tab3, 'res'), (tab4, 'stock')]:
584
  with tab:
585
  anomaly_details = analyzer.get_anomalies_by_type(df_label)
 
586
  if not anomaly_details.empty:
587
  st.dataframe(anomaly_details, use_container_width=True)
588
+ fig = px.pie(anomaly_details, values='Nombre', names='Type Anomalie', title=f"Répartition des Types d'Anomalies")
 
 
 
 
 
 
589
  st.plotly_chart(fig, use_container_width=True)
590
  else:
591
  st.success(f"✅ Aucune anomalie détectée")
592
 
 
593
  fig_temporal = plot_temporal_anomalies(analyzer)
594
+ if fig_temporal: st.plotly_chart(fig_temporal, use_container_width=True)
 
 
595
  st.divider()
596
 
597
  # ==================================================
598
  # ========== 4️⃣ SPÉCIFICITÉS HOSPITALIÈRES ========
599
  # ==================================================
600
  st.subheader("4️⃣ Analyses Spécifiques Hospitalières")
 
601
  col1, col2 = st.columns(2)
 
602
  with col1:
603
  st.markdown("#### 🛏️ Ressources")
604
  if analyzer.df_res is not None:
605
  st.metric("Cas de suroccupation", metrics['ressources']['suroccupation'], delta_color="inverse")
 
 
606
  if 'service_code' in analyzer.df_res.columns and 'taux_occupation' in analyzer.df_res.columns:
607
  taux_moy = analyzer.df_res.groupby('service_code')['taux_occupation'].mean().reset_index()
608
  taux_moy.columns = ['Service', 'Taux Occupation Moyen']
609
+ fig = px.bar(taux_moy.head(10), x='Service', y='Taux Occupation Moyen', title="Top 10 Services - Taux Occupation", color='Taux Occupation Moyen', color_continuous_scale='RdYlGn_r')
 
 
 
 
 
 
 
 
 
610
  st.plotly_chart(fig, use_container_width=True)
 
611
  with col2:
612
  st.markdown("#### 📦 Stock")
613
  st.metric("Alertes stock actives", metrics['stock']['alertes'], delta_color="inverse")
 
 
614
  if analyzer.df_stock is not None and 'alerte_stock' in analyzer.df_stock.columns:
615
+ alertes = analyzer.df_stock[analyzer.df_stock['alerte_stock'].astype(str) == '1']
616
  if not alertes.empty and 'materiel_code' in alertes.columns:
617
  alert_counts = alertes['materiel_code'].value_counts().reset_index()
618
  alert_counts.columns = ['Matériel', 'Nombre Alertes']
619
+ fig = px.bar(alert_counts.head(10), x='Matériel', y='Nombre Alertes', title="Top 10 Matériels en Alerte", color='Nombre Alertes', color_continuous_scale='Oranges')
 
 
 
 
 
 
 
 
620
  st.plotly_chart(fig, use_container_width=True)
 
621
  st.divider()
622
 
623
  # ==================================================
624
  # ========== 5️⃣ RÉSUMÉ EXÉCUTIF ===================
625
  # ==================================================
626
  st.subheader("5️⃣ Résumé Exécutif Data Quality")
 
627
  col1, col2, col3 = st.columns(3)
 
628
  col1.metric("Score de Complétude", f"{scores['completeness']:.2f}%")
629
  col2.metric("Score d'Exactitude", f"{scores['accuracy']:.2f}%")
 
630
  global_score = scores['global']
631
+ if global_score >= 95: status, color = "🟢 EXCELLENT", "green"
632
+ elif global_score >= 85: status, color = "🟡 BON", "blue"
633
+ elif global_score >= 70: status, color = "🟠 MOYEN", "orange"
634
+ else: status, color = "🔴 CRITIQUE", "red"
 
 
 
 
 
 
 
 
 
 
635
  col3.markdown(f"**Score Global**")
636
  col3.markdown(f"<h2 style='color: {color};'>{global_score:.2f}%</h2>", unsafe_allow_html=True)
 
637
  st.markdown(f"### {status}")
638
  st.progress(global_score / 100)
639
 
 
640
  st.markdown("#### 💡 Recommandations")
 
641
  recommendations = []
642
+ if metrics['patients']['anomalies'] > 0: recommendations.append(f"⚠️ **{metrics['patients']['anomalies']} anomalies patients** détectées")
643
+ if metrics['sejours']['dates_inversees'] > 0: recommendations.append(f"🔴 **{metrics['sejours']['dates_inversees']} séjours avec dates inversées**")
644
+ if metrics['ressources']['suroccupation'] > 0: recommendations.append(f"🚨 **{metrics['ressources']['suroccupation']} cas de suroccupation**")
645
+ if metrics['stock']['alertes'] > 0: recommendations.append(f"📦 **{metrics['stock']['alertes']} alertes stock**")
646
+ if global_score < 85: recommendations.append("📊 **Score global < 85%** - Plan Data Quality requis")
 
 
 
 
 
 
 
 
 
 
647
 
648
  if recommendations:
649
+ for rec in recommendations: st.markdown(rec)
650
+ else: st.success("✅ Qualité des données satisfaisante")
 
 
 
651
  st.divider()
652
 
653
  # ==================================================
654
  # ========== 6️⃣ EXPORT DES RÉSULTATS ==============
655
  # ==================================================
656
  st.subheader("6️⃣ Export et Documentation")
657
+ report_data = {'Métrique': [], 'Valeur': []}
 
 
 
 
 
 
658
  for category, cat_metrics in analyzer.metrics.items():
659
  for metric_name, value in cat_metrics.items():
660
  report_data['Métrique'].append(f"{category}_{metric_name}")
661
  report_data['Valeur'].append(value)
 
 
662
  report_data['Métrique'].extend(['score_completeness', 'score_accuracy', 'score_global'])
663
  report_data['Valeur'].extend([scores['completeness'], scores['accuracy'], scores['global']])
 
664
  report_df = pd.DataFrame(report_data)
665
 
666
  col1, col2 = st.columns(2)
 
667
  with col1:
668
  csv = report_df.to_csv(index=False).encode('utf-8')
669
+ st.download_button(label="📥 Télécharger Rapport DQ (CSV)", data=csv, file_name=f'dq_report_{datetime.now().strftime("%Y%m%d")}.csv', mime='text/csv')
 
 
 
 
 
 
670
  with col2:
 
671
  if metrics['patients']['anomalies'] > 0 and analyzer.df_pat is not None:
672
+ anomalies_df = analyzer.df_pat[analyzer.df_pat['dq_is_anomaly'] == 1][['patient_id', 'nom', 'prenom', 'dq_anomaly_type']]
 
 
673
  anomalies_csv = anomalies_df.to_csv(index=False).encode('utf-8')
674
+ st.download_button(label="📥 Télécharger Anomalies Patients (CSV)", data=anomalies_csv, file_name=f'anomalies_pat_{datetime.now().strftime("%Y%m%d")}.csv', mime='text/csv')
 
 
 
 
 
 
675
 
 
676
  if __name__ == "__main__":
677
  show_dq_dashboard()