Spaces:
Sleeping
Sleeping
Download src/module/Indicateurs_DQ.py from jaimalklyde/ProjetData: direct link, hf CLI and curl.
- Browser
- Download file 30 kB
-
https://huggingface.co/spaces/jaimalklyde/ProjetData/resolve/main/src/module/Indicateurs_DQ.py
- Command line
-
hf download hf://spaces/jaimalklyde/ProjetData/src/module/Indicateurs_DQ.py
-
curl -L -o Indicateurs_DQ.py https://huggingface.co/spaces/jaimalklyde/ProjetData/resolve/main/src/module/Indicateurs_DQ.py
30 kB
| """ | |
| 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 ================ | |
| # ===================================================== | |
| # 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 {} | |
| 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"<h2 style='color: {color};'>{global_score:.2f}%</h2>", unsafe_allow_html=True) | |
| st.markdown(f"### {status}") | |
| st.progress(global_score / 100) | |
| st.markdown("#### 💡 Recommandations") | |
| recommendations = [] | |
| if metrics['patients']['anomalies'] > 0: recommendations.append(f"⚠️ **{metrics['patients']['anomalies']} anomalies patients** détectées") | |
| if metrics['sejours']['dates_inversees'] > 0: recommendations.append(f"🔴 **{metrics['sejours']['dates_inversees']} séjours avec dates inversées**") | |
| if metrics['ressources']['suroccupation'] > 0: recommendations.append(f"🚨 **{metrics['ressources']['suroccupation']} cas de suroccupation**") | |
| if metrics['stock']['alertes'] > 0: recommendations.append(f"📦 **{metrics['stock']['alertes']} alertes stock**") | |
| if global_score < 85: recommendations.append("📊 **Score global < 85%** - Plan Data Quality requis") | |
| if recommendations: | |
| for rec in recommendations: st.markdown(rec) | |
| else: st.success("✅ Qualité des données satisfaisante") | |
| st.divider() | |
| # ================================================== | |
| # ========== 6️⃣ EXPORT DES RÉSULTATS ============== | |
| # ================================================== | |
| st.subheader("6️⃣ Export et Documentation") | |
| report_data = {'Métrique': [], 'Valeur': []} | |
| for category, cat_metrics in analyzer.metrics.items(): | |
| for metric_name, value in cat_metrics.items(): | |
| report_data['Métrique'].append(f"{category}_{metric_name}") | |
| report_data['Valeur'].append(value) | |
| report_data['Métrique'].extend(['score_completeness', 'score_accuracy', 'score_global']) | |
| report_data['Valeur'].extend([scores['completeness'], scores['accuracy'], scores['global']]) | |
| report_df = pd.DataFrame(report_data) | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| csv = report_df.to_csv(index=False).encode('utf-8') | |
| 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') | |
| with col2: | |
| if metrics['patients']['anomalies'] > 0 and analyzer.df_pat is not None: | |
| anomalies_df = analyzer.df_pat[analyzer.df_pat['dq_is_anomaly'] == 1][['patient_id', 'nom', 'prenom', 'dq_anomaly_type']] | |
| anomalies_csv = anomalies_df.to_csv(index=False).encode('utf-8') | |
| 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') | |
| if __name__ == "__main__": | |
| show_dq_dashboard() |