""" MODULE: DATA QUALITY DASHBOARD - VERSION CORRIGÉE ================================================= Responsabilité : Audit complet, détection d'anomalies. Correction : Gestion stricte des types numériques pour éviter les crashs (int/str). """ import streamlit as st import pandas as pd import numpy as np import plotly.express as px import plotly.graph_objects as go from plotly.subplots import make_subplots from datetime import datetime import warnings warnings.filterwarnings('ignore') # ===================================================== # ================== OUTILS SÉCURITÉ ================== # ===================================================== def safe_int(value): """Convertit une valeur en int de manière sûre""" try: if pd.isna(value): return 0 return int(float(value)) # Double cast pour gérer les strings '1.0' except (TypeError, ValueError, OverflowError): return 0 def safe_float(value): """Convertit une valeur en float de manière sûre""" try: if pd.isna(value): return 0.0 return float(value) except (TypeError, ValueError, OverflowError): return 0.0 def safe_pct(num, den): """Calcul sécurisé de pourcentage""" try: if den == 0: return 0.0 return round((float(num) / float(den)) * 100, 2) except Exception: return 0.0 # ===================================================== # ================= CHARGEMENT DONNÉES ================ # ===================================================== @st.cache_data(ttl=60) # TTL court pour voir les nouvelles données injectées def load_from_google_sheets(_client, sheet_name: str) -> dict: """Charge les données depuis Google Sheets SANS passer par ETL.""" sheets = {} try: sheet_names = [ 'patients', 'sejours', 'ressources_jour', 'stock_materiel_jour', 'admissions_jour', 'scenarios_calendrier' ] for name in sheet_names: try: worksheet = _client.open(sheet_name).worksheet(name) data = worksheet.get_all_records() df = pd.DataFrame(data) # Conversion des dates date_cols = ['date', 'date_entree', 'date_sortie', 'date_naissance'] for col in date_cols: if col in df.columns: df[col] = pd.to_datetime(df[col], errors='coerce') sheets[name] = df except Exception as e: # On continue même si une feuille manque continue return sheets except Exception as e: st.error(f"Erreur lors du chargement depuis Google Sheets : {str(e)}") return {} @st.cache_data(ttl=3600) def load_from_excel_file(file) -> dict: """Charge les données depuis un fichier Excel uploadé.""" sheets = {} try: sheets['patients'] = pd.read_excel(file, sheet_name='patients') sheets['sejours'] = pd.read_excel(file, sheet_name='sejours') sheets['ressources_jour'] = pd.read_excel(file, sheet_name='ressources_jour') sheets['stock_materiel_jour'] = pd.read_excel(file, sheet_name='stock_materiel_jour') sheets['admissions_jour'] = pd.read_excel(file, sheet_name='admissions_jour') sheets['scenarios_calendrier'] = pd.read_excel(file, sheet_name='scenarios_calendrier') for sheet_name, df in sheets.items(): for col in df.columns: if 'date' in col.lower(): df[col] = pd.to_datetime(df[col], errors='coerce') return sheets except Exception as e: st.error(f"Erreur lors du chargement du fichier Excel : {str(e)}") return {} # ===================================================== # ============ ANALYSES DATA QUALITY ================== # ===================================================== class HospitalDataQualityAnalyzer: """Analyseur complet de qualité des données hospitalières""" def __init__(self, dataframes: dict): self.df_pat = dataframes.get('patients') self.df_sej = dataframes.get('sejours') self.df_res = dataframes.get('ressources_jour') self.df_stock = dataframes.get('stock_materiel_jour') self.df_adm = dataframes.get('admissions_jour') self.df_scenarios = dataframes.get('scenarios_calendrier') self.metrics = {} self._compute_all_metrics() def _force_numeric_anomaly(self, df): """Force la colonne dq_is_anomaly en numérique pour éviter les crashs""" if df is not None and not df.empty and 'dq_is_anomaly' in df.columns: # Conversion robuste : force tout en nombre, remplace les erreurs par 0 df['dq_is_anomaly'] = pd.to_numeric(df['dq_is_anomaly'], errors='coerce').fillna(0).astype(int) return df def _compute_all_metrics(self): """Calcul de toutes les métriques DQ""" # NETTOYAGE PRÉALABLE : Conversion des types pour éviter le crash int+str self.df_pat = self._force_numeric_anomaly(self.df_pat) self.df_sej = self._force_numeric_anomaly(self.df_sej) self.df_res = self._force_numeric_anomaly(self.df_res) self.df_stock = self._force_numeric_anomaly(self.df_stock) # ============= PATIENTS ============= if self.df_pat is not None and not self.df_pat.empty: self.metrics['patients'] = { 'total': len(self.df_pat), 'anomalies': int(self.df_pat['dq_is_anomaly'].sum()), 'doublons': int(self.df_pat.duplicated(subset=['nom', 'prenom']).sum()), 'ages_negatifs': int((pd.to_numeric(self.df_pat['age'], errors='coerce') < 0).sum()), 'ages_aberrants': int((pd.to_numeric(self.df_pat['age'], errors='coerce') > 120).sum()), 'missing_birth': int(self.df_pat['date_naissance'].isnull().sum()), 'missing_ville': int(self.df_pat['ville'].isnull().sum()), 'missing_total': int(self.df_pat.isnull().sum().sum()) } else: self.metrics['patients'] = {k: 0 for k in ['total', 'anomalies', 'doublons', 'ages_negatifs', 'ages_aberrants', 'missing_birth', 'missing_ville', 'missing_total']} # ============= SÉJOURS ============= if self.df_sej is not None and not self.df_sej.empty: dates_inversees = int((self.df_sej['date_sortie'] < self.df_sej['date_entree']).sum()) gravite = pd.to_numeric(self.df_sej['gravite_1_5'], errors='coerce').fillna(3) gravite_aberrante = int(((gravite > 5) | (gravite < 0)).sum()) self.metrics['sejours'] = { 'total': len(self.df_sej), 'anomalies': int(self.df_sej['dq_is_anomaly'].sum()), 'dates_inversees': dates_inversees, 'gravite_aberrante': gravite_aberrante, 'equipement_manquant': int(self.df_sej['equipement_principal_code'].isnull().sum()), 'missing_total': int(self.df_sej.isnull().sum().sum()) } else: self.metrics['sejours'] = {k: 0 for k in ['total', 'anomalies', 'dates_inversees', 'gravite_aberrante', 'equipement_manquant', 'missing_total']} # ============= RESSOURCES ============= if self.df_res is not None and not self.df_res.empty: df_res_temp = self.df_res.copy() df_res_temp['lits_occupes_num'] = pd.to_numeric(df_res_temp['lits_occupes'], errors='coerce').fillna(0) df_res_temp['lits_total_num'] = pd.to_numeric(df_res_temp['lits_total'], errors='coerce').fillna(1) suroccupation = int((df_res_temp['lits_occupes_num'] > df_res_temp['lits_total_num']).sum()) self.metrics['ressources'] = { 'total': len(self.df_res), 'anomalies': int(self.df_res['dq_is_anomaly'].sum()), 'suroccupation': suroccupation, 'missing_total': int(self.df_res.isnull().sum().sum()) } else: self.metrics['ressources'] = {k: 0 for k in ['total', 'anomalies', 'suroccupation', 'missing_total']} # ============= STOCK ============= if self.df_stock is not None and not self.df_stock.empty: self.metrics['stock'] = { 'total': len(self.df_stock), 'anomalies': int(self.df_stock['dq_is_anomaly'].sum()), 'alertes': int((self.df_stock['alerte_stock'].astype(str) == '1').sum()), 'missing_total': int(self.df_stock.isnull().sum().sum()) } else: self.metrics['stock'] = {k: 0 for k in ['total', 'anomalies', 'alertes', 'missing_total']} def get_anomalies_by_type(self, df_name: str): """Récupère le détail des anomalies par type""" df = getattr(self, f'df_{df_name}', None) if df is None or 'dq_anomaly_type' not in df.columns: return pd.DataFrame() anomalies = df[df['dq_is_anomaly'] == 1] if anomalies.empty: return pd.DataFrame() anomaly_counts = anomalies['dq_anomaly_type'].value_counts().reset_index() anomaly_counts.columns = ['Type Anomalie', 'Nombre'] return anomaly_counts def get_temporal_anomalies(self, df_name: str): """Analyse temporelle des anomalies""" df = getattr(self, f'df_{df_name}', None) if df is None or 'date' not in df.columns or 'dq_is_anomaly' not in df.columns: return None # Groupement par date temp_anomalies = df.groupby(df['date'].dt.to_period('M'))['dq_is_anomaly'].agg(['sum', 'count']).reset_index() temp_anomalies['date'] = temp_anomalies['date'].dt.to_timestamp() temp_anomalies['pourcentage'] = (temp_anomalies['sum'] / temp_anomalies['count']) * 100 return temp_anomalies def get_service_quality_stats(self): """Statistiques de qualité par service""" if self.df_sej is None: return None service_stats = self.df_sej.groupby('service_code').agg({ 'stay_id': 'count', 'dq_is_anomaly': 'sum', 'duree_sejour_jours': 'mean', 'gravite_1_5': 'mean' }).reset_index() service_stats.columns = ['Service', 'Nb Séjours', 'Anomalies', 'Durée Moy', 'Gravité Moy'] service_stats['Taux Anomalie %'] = (service_stats['Anomalies'] / service_stats['Nb Séjours']) * 100 return service_stats.round(2) def compute_global_score(self): """Calcul du score global DQ""" # Complétude total_rows = (self.metrics['patients']['total'] + self.metrics['sejours']['total'] + self.metrics['ressources']['total']) total_missing = (self.metrics['patients']['missing_total'] + self.metrics['sejours']['missing_total'] + self.metrics['ressources']['missing_total']) completeness_score = safe_pct(total_rows - total_missing, total_rows) # Exactitude total_all_rows = (self.metrics['patients']['total'] + self.metrics['sejours']['total'] + self.metrics['ressources']['total'] + self.metrics['stock']['total']) total_anomalies = (self.metrics['patients']['anomalies'] + self.metrics['sejours']['anomalies'] + self.metrics['ressources']['anomalies'] + self.metrics['stock']['anomalies']) accuracy_score = safe_pct(total_all_rows - total_anomalies, total_all_rows) # Score global global_score = round((completeness_score + accuracy_score) / 2, 2) return { 'completeness': completeness_score, 'accuracy': accuracy_score, 'global': global_score } # ===================================================== # ============== VISUALISATIONS ======================= # ===================================================== def plot_anomalies_distribution(analyzer: HospitalDataQualityAnalyzer): """Graphique de distribution des anomalies par table""" data = { 'Table': ['Patients', 'Séjours', 'Ressources', 'Stock'], 'Anomalies': [ analyzer.metrics['patients']['anomalies'], analyzer.metrics['sejours']['anomalies'], analyzer.metrics['ressources']['anomalies'], analyzer.metrics['stock']['anomalies'] ], 'Total': [ analyzer.metrics['patients']['total'], analyzer.metrics['sejours']['total'], analyzer.metrics['ressources']['total'], analyzer.metrics['stock']['total'] ] } df_plot = pd.DataFrame(data) df_plot['Pourcentage'] = (df_plot['Anomalies'] / df_plot['Total']) * 100 fig = make_subplots( rows=1, cols=2, subplot_titles=('Nombre d\'Anomalies', 'Taux d\'Anomalies (%)'), specs=[[{"type": "bar"}, {"type": "bar"}]] ) # Nombre absolu fig.add_trace( go.Bar( x=df_plot['Table'], y=df_plot['Anomalies'], text=df_plot['Anomalies'], textposition='outside', marker_color='crimson', name='Anomalies' ), row=1, col=1 ) # Pourcentage fig.add_trace( go.Bar( x=df_plot['Table'], y=df_plot['Pourcentage'], text=[f"{x:.2f}%" for x in df_plot['Pourcentage']], textposition='outside', marker_color='orange', name='Taux %' ), row=1, col=2 ) fig.update_layout( height=400, showlegend=False, title_text="📊 Distribution des Anomalies par Table" ) return fig def plot_temporal_anomalies(analyzer: HospitalDataQualityAnalyzer): """Évolution temporelle des anomalies pour séjours""" temp_data = analyzer.get_temporal_anomalies('sej') if temp_data is None or temp_data.empty: return None fig = go.Figure() fig.add_trace(go.Scatter( x=temp_data['date'], y=temp_data['pourcentage'], mode='lines+markers', name='Taux Anomalies', line=dict(color='red', width=2), marker=dict(size=8) )) fig.update_layout( title="📈 Évolution Temporelle des Anomalies (Séjours)", xaxis_title="Date", yaxis_title="Taux d'Anomalies (%)", height=400, hovermode='x unified' ) return fig def plot_service_quality_heatmap(analyzer: HospitalDataQualityAnalyzer): """Heatmap de qualité par service""" service_stats = analyzer.get_service_quality_stats() if service_stats is None or service_stats.empty: return None fig = px.bar( service_stats.sort_values('Taux Anomalie %', ascending=False).head(10), x='Service', y='Taux Anomalie %', color='Taux Anomalie %', color_continuous_scale='Reds', title="🏥 Taux d'Anomalies par Service (Top 10)", text='Taux Anomalie %' ) fig.update_traces(texttemplate='%{text:.1f}%', textposition='outside') fig.update_layout(height=450) return fig def plot_completeness_radar(analyzer: HospitalDataQualityAnalyzer): """Radar chart de complétude par dimension""" metrics = analyzer.metrics # Calcul des complétudes patient_completeness = 100 - safe_pct(metrics['patients']['missing_total'], metrics['patients']['total'] * len(analyzer.df_pat.columns) if analyzer.df_pat is not None else 1) sejour_completeness = 100 - safe_pct(metrics['sejours']['missing_total'], metrics['sejours']['total'] * len(analyzer.df_sej.columns) if analyzer.df_sej is not None else 1) ressource_completeness = 100 - safe_pct(metrics['ressources']['missing_total'], metrics['ressources']['total'] * len(analyzer.df_res.columns) if analyzer.df_res is not None else 1) stock_completeness = 100 - safe_pct(metrics['stock']['missing_total'], metrics['stock']['total'] * len(analyzer.df_stock.columns) if analyzer.df_stock is not None else 1) categories = ['Patients', 'Séjours', 'Ressources', 'Stock'] values = [patient_completeness, sejour_completeness, ressource_completeness, stock_completeness] values.append(values[0]) # Fermeture fig = go.Figure() fig.add_trace(go.Scatterpolar( r=values, theta=categories + [categories[0]], fill='toself', name='Complétude', line_color='cyan' )) fig.update_layout( polar=dict( radialaxis=dict( visible=True, range=[0, 100] ) ), showlegend=False, title="🎯 Radar de Complétude des Données", height=400 ) return fig # ===================================================== # ================= DASHBOARD PRINCIPAL =============== # ===================================================== def show_dq_dashboard(_client=None, sheet_name=None): """ Dashboard Data Quality complet avec support Google Sheets et Upload Fichier. """ st.markdown("## 🛡️ CENTRE DE CONTRÔLE DATA QUALITY") st.caption("Analyse complète de la qualité des données hospitalières") dataframes = None # ================= MODE DE CHARGEMENT ================= if _client is not None and sheet_name is not None: st.info("📊 **Mode : Google Sheets**") with st.spinner("🔄 Chargement depuis Google Sheets..."): dataframes = load_from_google_sheets(_client, sheet_name) else: st.sidebar.header("⚙️ Configuration") uploaded_file = st.sidebar.file_uploader( "Charger le fichier Excel", type=['xlsx'], help="Fichier bdd_mvp_faker.xlsx" ) if uploaded_file is not None: st.info("📁 **Mode : Fichier Excel Local**") with st.spinner("🔄 Chargement du fichier Excel..."): dataframes = load_from_excel_file(uploaded_file) else: st.info("👈 Veuillez charger le fichier Excel dans la barre latérale") return # ================= VÉRIFICATION CHARGEMENT ================= if not dataframes or len(dataframes) == 0: st.error("❌ Erreur lors du chargement des données") return # ================= ANALYSE ================= analyzer = HospitalDataQualityAnalyzer(dataframes) scores = analyzer.compute_global_score() total_lignes = sum(m['total'] for m in analyzer.metrics.values()) st.success(f"✅ Données chargées : **{total_lignes:,}** lignes au total") # ================================================== # ================= KPI GLOBAUX ==================== # ================================================== st.markdown("### 📊 Synthèse de Santé des Données") col1, col2, col3, col4 = st.columns(4) metrics = analyzer.metrics col1.metric("🔴 Anomalies Patients", f"{metrics['patients']['anomalies']:,}", delta_color="inverse") col2.metric("🔴 Anomalies Séjours", f"{metrics['sejours']['anomalies']:,}", delta_color="inverse") col3.metric("🔴 Anomalies Ressources", f"{metrics['ressources']['anomalies']:,}", delta_color="inverse") col4.metric("🔴 Anomalies Stock", f"{metrics['stock']['anomalies']:,}", delta_color="inverse") st.plotly_chart(plot_anomalies_distribution(analyzer), use_container_width=True) st.divider() # ================================================== # ========== 1️⃣ COMPLÉTUDE DES DONNÉES ============ # ================================================== st.subheader("1️⃣ Complétude des Données (Missing Values)") tab1, tab2, tab3, tab4 = st.tabs(["📋 Patients", "🏥 Séjours", "⚕️ Ressources", "📦 Stock"]) for tab, df_name, df in [(tab1, 'Patients', analyzer.df_pat), (tab2, 'Séjours', analyzer.df_sej), (tab3, 'Ressources', analyzer.df_res), (tab4, 'Stock', analyzer.df_stock)]: with tab: if df is not None and not df.empty: missing_df = (df.isnull().mean() * 100).reset_index() missing_df.columns = ['Colonne', 'Pourcentage Manquant'] missing_df = missing_df[missing_df['Pourcentage Manquant'] > 0] if not missing_df.empty: st.caption(f"📊 {len(missing_df)} colonnes avec données manquantes") fig = px.bar(missing_df.sort_values('Pourcentage Manquant', ascending=False), x='Colonne', y='Pourcentage Manquant', title=f"Données manquantes - {df_name}", color='Pourcentage Manquant', color_continuous_scale='Reds', text='Pourcentage Manquant') fig.update_traces(texttemplate='%{text:.1f}%', textposition='outside') fig.update_layout(xaxis_tickangle=-45, height=400) st.plotly_chart(fig, use_container_width=True) else: st.success(f"✅ Aucune donnée manquante dans {df_name}") else: st.warning(f"⚠️ Aucune donnée {df_name} disponible") st.plotly_chart(plot_completeness_radar(analyzer), use_container_width=True) st.divider() # ================================================== # ========== 2️⃣ COHÉRENCE DES DONNÉES ============= # ================================================== st.subheader("2️⃣ Cohérence et Validité des Données") col1, col2 = st.columns(2) with col1: st.markdown("#### 👥 Cohérence Patients") st.metric("Âges négatifs", metrics['patients']['ages_negatifs'], delta_color="inverse") st.metric("Âges aberrants (>120 ans)", metrics['patients']['ages_aberrants'], delta_color="inverse") st.metric("Doublons (nom+prénom)", metrics['patients']['doublons'], delta_color="inverse") with col2: st.markdown("#### 🏥 Cohérence Séjours") st.metric("Dates inversées (sortie < entrée)", metrics['sejours']['dates_inversees'], delta_color="inverse") st.metric("Gravité aberrante (<0 ou >5)", metrics['sejours']['gravite_aberrante'], delta_color="inverse") st.metric("Équipement manquant", metrics['sejours']['equipement_manquant'], delta_color="inverse") fig_service = plot_service_quality_heatmap(analyzer) if fig_service: st.plotly_chart(fig_service, use_container_width=True) st.divider() # ================================================== # ========== 3️⃣ ANALYSE DES ANOMALIES ============= # ================================================== st.subheader("3️⃣ Analyse Détaillée des Anomalies") tab1, tab2, tab3, tab4 = st.tabs(["📋 Patients", "🏥 Séjours", "⚕️ Ressources", "📦 Stock"]) for tab, df_label in [(tab1, 'pat'), (tab2, 'sej'), (tab3, 'res'), (tab4, 'stock')]: with tab: anomaly_details = analyzer.get_anomalies_by_type(df_label) if not anomaly_details.empty: st.dataframe(anomaly_details, use_container_width=True) fig = px.pie(anomaly_details, values='Nombre', names='Type Anomalie', title=f"Répartition des Types d'Anomalies") st.plotly_chart(fig, use_container_width=True) else: st.success(f"✅ Aucune anomalie détectée") fig_temporal = plot_temporal_anomalies(analyzer) if fig_temporal: st.plotly_chart(fig_temporal, use_container_width=True) st.divider() # ================================================== # ========== 4️⃣ SPÉCIFICITÉS HOSPITALIÈRES ======== # ================================================== st.subheader("4️⃣ Analyses Spécifiques Hospitalières") col1, col2 = st.columns(2) with col1: st.markdown("#### 🛏️ Ressources") if analyzer.df_res is not None: st.metric("Cas de suroccupation", metrics['ressources']['suroccupation'], delta_color="inverse") if 'service_code' in analyzer.df_res.columns and 'taux_occupation' in analyzer.df_res.columns: taux_moy = analyzer.df_res.groupby('service_code')['taux_occupation'].mean().reset_index() taux_moy.columns = ['Service', 'Taux Occupation Moyen'] 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') st.plotly_chart(fig, use_container_width=True) with col2: st.markdown("#### 📦 Stock") st.metric("Alertes stock actives", metrics['stock']['alertes'], delta_color="inverse") if analyzer.df_stock is not None and 'alerte_stock' in analyzer.df_stock.columns: alertes = analyzer.df_stock[analyzer.df_stock['alerte_stock'].astype(str) == '1'] if not alertes.empty and 'materiel_code' in alertes.columns: alert_counts = alertes['materiel_code'].value_counts().reset_index() alert_counts.columns = ['Matériel', 'Nombre Alertes'] 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') st.plotly_chart(fig, use_container_width=True) st.divider() # ================================================== # ========== 5️⃣ RÉSUMÉ EXÉCUTIF =================== # ================================================== st.subheader("5️⃣ Résumé Exécutif Data Quality") col1, col2, col3 = st.columns(3) col1.metric("Score de Complétude", f"{scores['completeness']:.2f}%") col2.metric("Score d'Exactitude", f"{scores['accuracy']:.2f}%") global_score = scores['global'] if global_score >= 95: status, color = "🟢 EXCELLENT", "green" elif global_score >= 85: status, color = "🟡 BON", "blue" elif global_score >= 70: status, color = "🟠 MOYEN", "orange" else: status, color = "🔴 CRITIQUE", "red" col3.markdown(f"**Score Global**") col3.markdown(f"