diff --git "a/src/dashboard_app.py" "b/src/dashboard_app.py" --- "a/src/dashboard_app.py" +++ "b/src/dashboard_app.py" @@ -1,5 +1,6 @@ import os -os.environ['STREAMLIT_HOME'] = '/app/.streamlit' +# Cette ligne n'est généralement pas nécessaire sur HF Spaces, mais ne nuit pas. +# os.environ['STREAMLIT_HOME'] = '/app/.streamlit' import streamlit as st import pandas as pd import plotly.express as px @@ -7,7 +8,7 @@ import plotly.graph_objects as go from datetime import datetime from jinja2 import Environment, FileSystemLoader import io -import os +# import os # déjà importé from dotenv import load_dotenv import google.generativeai as genai from sklearn.decomposition import PCA @@ -29,20 +30,22 @@ api_key = os.getenv("GOOGLE_API_KEY") if not api_key: print("⚠️ Clé API Google Gemini non trouvée. Le chat AI sera désactivé.") + # Optionnel : Afficher un message dans l'UI si la clé est critique + # st.sidebar.warning("Clé API Google manquante. Chat AI désactivé.", icon="🔑") + # Charger le template HTML pour l'export +# --- CORRECTION CHEMIN --- +# On suppose que le template est dans le MÊME dossier que ce script +script_dir = os.path.dirname(os.path.abspath(__file__)) if "__file__" in locals() else "." +template_dir = script_dir # Charger depuis le dossier du script TEMPLATE_FILE = "report_template.html" try: - # S'assurer que le loader cherche dans le bon répertoire (le parent du script) - script_dir = os.path.dirname(os.path.abspath(__file__)) if "__file__" in locals() else "." - # Si votre template est dans le même dossier que le script, utilisez: template_dir = script_dir - # Si votre template est dans le dossier parent, utilisez: - template_dir = os.path.join(script_dir, '..') env = Environment(loader=FileSystemLoader(template_dir)) template = env.get_template(TEMPLATE_FILE) except Exception as e: - # Vous pouvez décommenter cette ligne pour le débogage si le template ne charge pas - # st.error(f"Erreur lors du chargement du template HTML '{TEMPLATE_FILE}' depuis '{template_dir}': {e}. L'export HTML sera indisponible.") + # Message d'erreur plus clair pour le débogage sur HF Spaces + st.error(f"Erreur chargement template '{TEMPLATE_FILE}' depuis '{template_dir}'. Vérifiez qu'il est bien DANS LE MÊME DOSSIER que dashboard_app.py. Détails: {e}. Export HTML indisponible.") template = None # Gérer l'absence de template # --- Fonctions Utilitaires --- @@ -53,13 +56,16 @@ def generate_html_report(data, num_submissions, columns, tables_html="", charts_ return "Erreur: Template HTML manquant ou non chargé." last_sync = datetime.now().strftime('%Y-%m-%d %H:%M:%S') try: - html_content = template.render( - last_sync=last_sync, - num_submissions=num_submissions, - columns=columns, - tables=tables_html, - charts=charts_html - ) + # Préparer le contexte pour Jinja + context = { + 'last_sync': last_sync, + 'num_submissions': num_submissions, + 'columns': columns, + 'tables': tables_html, # Devrait déjà être du HTML + 'charts': charts_html, # Devrait déjà être du HTML + 'data_preview': data.head().to_html(classes='table table-sm table-striped', index=False) if data is not None else "

Aperçu indisponible.

" + } + html_content = template.render(context) return html_content except Exception as e: st.error(f"Erreur lors du rendu du template HTML : {e}") @@ -68,7 +74,9 @@ def generate_html_report(data, num_submissions, columns, tables_html="", charts_ def get_safe_index(options, value, default_index=0): """Obtient l'index d'une valeur dans une liste d'options en toute sécurité.""" if not options or value is None: return default_index - try: return options.index(value) + # Convert options to list if it's not already (e.g., Index object from pandas) + options_list = list(options) + try: return options_list.index(value) except (ValueError, TypeError): return default_index def init_analysis_state(analysis_index, param_key, default_value): @@ -141,16 +149,17 @@ with app_tab: if uploaded_file is not None: current_data_id = f"{uploaded_file.name}-{uploaded_file.size}" + # Force reload if file changes OR header preference changes if st.session_state.data_loaded_id != current_data_id or st.session_state.last_header_preference != use_header: trigger_reload = True elif st.session_state.data_loaded_id != "local_default" or st.session_state.last_header_preference != use_header: - # Essayer de charger local si pas d'upload ou si l'en-tête a changé + # Try to load local if no upload OR if header preference changed while local was active current_data_id = "local_default" - trigger_reload = True # Essayer de charger/recharger le fichier local + trigger_reload = True # Try to load/reload local file else: - # Ni upload, ni changement d'en-tête, on garde les données en session si elles existent + # Neither upload nor header change, keep data in session if it exists current_data_id = st.session_state.data_loaded_id - data = st.session_state.dataframe_to_export + data = st.session_state.get('dataframe_to_export', None) # Get from session safely if data is not None: data_source_info = st.session_state.get("data_source_info", "Données en session") else: @@ -159,195 +168,316 @@ with app_tab: # Recharger/Charger si nécessaire if trigger_reload: + st.sidebar.info("🔄 Tentative de (re)chargement des données...") # Feedback # Réinitialiser l'état du rapport HTML préparé si les données changent st.session_state.html_report_content = None st.session_state.html_report_filename = "rapport.html" + st.session_state.analyses = [] # Reset analyses on ANY reload attempt if uploaded_file is not None: # Charger le fichier uploadé try: - st.info(f"Chargement de '{uploaded_file.name}' avec header={header_param}...") + st.info(f"Chargement de '{uploaded_file.name}' (header={header_param})...") if uploaded_file.name.endswith('.csv'): data = pd.read_csv(uploaded_file, header=header_param) elif uploaded_file.name.endswith('.xlsx'): + # Important: Needs 'openpyxl' installed (add to requirements.txt) data = pd.read_excel(uploaded_file, header=header_param) st.session_state.dataframe_to_export = data data_source_info = f"Fichier chargé : {uploaded_file.name}" st.session_state.data_loaded_id = current_data_id st.session_state.last_header_preference = use_header - st.session_state.analyses = [] # Reset analyses on new load/header change load_error = False - st.success(f"'{uploaded_file.name}' chargé.") + st.success(f"'{uploaded_file.name}' chargé avec succès.") # Trigger rerun AFTER successful load to ensure UI updates st.rerun() except Exception as e: - st.error(f"Erreur chargement fichier avec header={header_param}: {e}") + st.error(f"Erreur chargement fichier '{uploaded_file.name}' : {e}") + # --- AJOUT : Aide spécifique pour Excel --- + if uploaded_file.name.endswith('.xlsx'): + st.warning("Pour lire les fichiers Excel (.xlsx), assurez-vous que 'openpyxl' est inclus dans votre fichier `requirements.txt`.", icon="💡") data = None st.session_state.dataframe_to_export = None - st.session_state.analyses = [] - st.session_state.data_loaded_id = None - st.session_state.last_header_preference = use_header + # st.session_state.analyses = [] # Already reset above + st.session_state.data_loaded_id = None # Reset ID on error + # Keep last_header_preference as user set it data_source_info = "Erreur de chargement" load_error = True elif current_data_id == "local_default": - # Essayer de charger le fichier local + # Essayer de charger le fichier local par défaut try: - local_file_path = os.path.join(script_dir, "..", "sample_excel.xlsx") # Path adjusted - st.info(f"Chargement du fichier local par défaut ('sample_excel.xlsx') avec header={header_param}...") + # --- CORRECTION CHEMIN --- + # On suppose que le fichier est dans le MÊME dossier que ce script + default_filename = "sample_excel.xlsx" + local_file_path = os.path.join(script_dir, default_filename) + st.info(f"Chargement du fichier local par défaut ('{default_filename}') avec header={header_param}...") + # Important: Needs 'openpyxl' installed (add to requirements.txt) data = pd.read_excel(local_file_path, header=header_param) st.session_state.dataframe_to_export = data data_source_info = "Fichier local par défaut" st.session_state.data_loaded_id = current_data_id st.session_state.last_header_preference = use_header - st.session_state.analyses = [] # Reset analyses + # st.session_state.analyses = [] # Already reset above load_error = False - st.success("Fichier local chargé.") + st.success(f"Fichier local '{default_filename}' chargé.") st.rerun() # Rerun after successful load except FileNotFoundError: - st.warning("Fichier local par défaut 'sample_excel.xlsx' non trouvé dans le dossier parent. Veuillez charger un fichier.", icon="⚠️") + st.warning(f"Fichier local par défaut '{default_filename}' non trouvé dans le dossier du script ('{script_dir}'). Veuillez charger un fichier.", icon="⚠️") data = None st.session_state.dataframe_to_export = None - st.session_state.analyses = [] - st.session_state.data_loaded_id = None - st.session_state.last_header_preference = use_header + # st.session_state.analyses = [] # Already reset above + st.session_state.data_loaded_id = None # Reset ID + # Keep header pref data_source_info = "Fichier local non trouvé" load_error = True except Exception as e: - st.error(f"Erreur chargement fichier local avec header={header_param}: {e}") + st.error(f"Erreur chargement fichier local '{default_filename}' avec header={header_param}: {e}") + st.warning("Pour lire les fichiers Excel (.xlsx), assurez-vous que 'openpyxl' est inclus dans votre fichier `requirements.txt`.", icon="💡") data = None st.session_state.dataframe_to_export = None - st.session_state.analyses = [] - st.session_state.data_loaded_id = None - st.session_state.last_header_preference = use_header + # st.session_state.analyses = [] # Already reset above + st.session_state.data_loaded_id = None # Reset ID + # Keep header pref data_source_info = "Erreur fichier local" load_error = True # ELSE: Si pas de trigger_reload, on utilise les données déjà en session (data) - # Sauver la source info pour l'affichage + # Sauver la source info pour l'affichage (même en cas d'erreur) st.session_state.data_source_info = data_source_info - # --- Définition des colonnes --- + # Mettre à jour la variable 'data' avec ce qui est réellement en session_state + # (important si on n'a pas rechargé mais qu'il y avait déjà des données) + data = st.session_state.get('dataframe_to_export', None) + + # --- Définition des colonnes (uniquement si data est chargé) --- categorical_columns = [] numerical_columns = [] - datetime_columns = [] # Ajouter une liste pour les dates + datetime_columns = [] + all_columns = [] # Pour les sélecteurs généraux + if data is not None: + all_columns = data.columns.tolist() + # S'assurer que les conversions ne modifient pas le DataFrame original en session + data_processed = data.copy() + # Convertir types pour cohérence - for col in data.select_dtypes(include=['bool']).columns: - data[col] = data[col].astype(str) + for col in data_processed.select_dtypes(include=['bool']).columns: + try: # Ensure boolean conversion doesn't fail on mixed types + data_processed[col] = data_processed[col].astype(str) + except Exception: pass # Keep original if conversion fails # Boucle pour conversion object -> numérique ou datetime - for col in data.select_dtypes(include=['object']).columns: + for col in data_processed.select_dtypes(include=['object']).columns: try: - # Try numeric first - converted_num = pd.to_numeric(data[col], errors='raise') - if pd.api.types.is_numeric_dtype(converted_num): - data[col] = converted_num - continue - except (ValueError, TypeError): - try: - converted_date = pd.to_datetime(data[col], errors='coerce', infer_datetime_format=True) - if converted_date.notna().any(): - # Heuristique simple pour éviter de convertir des IDs en dates - try: - first_valid_val = data[col].dropna().iloc[0] if not data[col].dropna().empty else None - is_likely_int_interpreted_as_date = isinstance(first_valid_val, (int, float, np.number)) and first_valid_val > 10000 # Arbitrary threshold - except: - is_likely_int_interpreted_as_date = False # Assume not if check fails - - if not is_likely_int_interpreted_as_date: - if pd.api.types.is_datetime64_any_dtype(converted_date): - data[col] = converted_date - except (ValueError, TypeError): - pass # Leave as object if both conversions fail - - # Define lists AFTER conversions - categorical_columns = data.select_dtypes(exclude=['number', 'datetime', 'timedelta']).columns.tolist() - numerical_columns = data.select_dtypes(include=['number']).columns.tolist() - datetime_columns = data.select_dtypes(include=['datetime']).columns.tolist() + # Try numeric first, handle potential errors during conversion + converted_num = pd.to_numeric(data_processed[col], errors='coerce') + # Check if the *original* column looked like numbers before coercing errors to NaN + # This avoids converting string IDs that happen to be numbers into float + looks_like_number = data[col].astype(str).str.match(r'^-?\d+(\.\d+)?$').all() + + if looks_like_number and pd.api.types.is_numeric_dtype(converted_num) and converted_num.notna().any(): + data_processed[col] = converted_num + continue # Go to next column if successfully converted to numeric + except (ValueError, TypeError, AttributeError): + pass # Ignore errors if conversion fails + + # Try datetime conversion if numeric failed + try: + # Attempt conversion, coercing errors. Infer format for flexibility. + converted_date = pd.to_datetime(data_processed[col], errors='coerce', infer_datetime_format=True) + + # Check if *any* value was successfully converted to datetime + if converted_date.notna().any(): + # Heuristic: Avoid converting columns that look like numerical IDs/codes + # Check the *original* data before potential numeric conversion above + original_col_sample = data[col].dropna().unique() + is_likely_id = False + if len(original_col_sample) > 0: + # Check if a sample looks like integers often used for IDs + try: + sample_numeric = pd.to_numeric(original_col_sample[:min(len(original_col_sample), 50)], errors='coerce') + if np.all(np.mod(sample_numeric[~np.isnan(sample_numeric)], 1) == 0) and np.nanmax(sample_numeric) > 10000: # Looks like large integers + is_likely_id = True + except Exception: pass # Ignore errors in heuristic + + # Only convert if it has valid dates and doesn't look like an ID column + if not is_likely_id: + data_processed[col] = converted_date + + except (ValueError, TypeError, OverflowError): + pass # Leave as object if datetime conversion also fails or overflows + + # Define column lists AFTER potential conversions on the copy + numerical_columns = data_processed.select_dtypes(include=['number']).columns.tolist() + # Exclude timedelta as it often causes issues in plots/stats if not handled specifically + datetime_columns = data_processed.select_dtypes(include=['datetime', 'datetimetz']).columns.tolist() + # Categorical are everything else + categorical_columns = data_processed.select_dtypes(exclude=['number', 'datetime', 'datetimetz', 'timedelta']).columns.tolist() + + # IMPORTANT: Update the dataframe in session state ONLY IF conversions were successful + # and we actually want to keep them. For now, we primarily use these lists + # for selectors, but keep the original data for operations unless explicit transformation is done. + # If you WANT the conversions to persist: + # st.session_state.dataframe_to_export = data_processed + + else: + # If data is None, ensure lists are empty + all_columns = [] + categorical_columns = [] + numerical_columns = [] + datetime_columns = [] # --- Renommage des Colonnes --- st.subheader("2. Renommer Colonnes (Optionnel)") - if data is not None: + # Use all_columns derived from the currently loaded data (if any) + current_columns_for_rename = all_columns + + if data is not None and current_columns_for_rename: rename_key_suffix = st.session_state.data_loaded_id if st.session_state.data_loaded_id else "no_data" - current_columns = data.columns.tolist() col_to_rename = st.selectbox( "Colonne à renommer :", - current_columns, - key=f"rename_select_{rename_key_suffix}", - index=0 if current_columns else None, - disabled=not current_columns + current_columns_for_rename, + index=0, # Defaults to the first column + key=f"rename_select_{rename_key_suffix}" ) new_name = st.text_input( f"Nouveau nom pour '{col_to_rename}':", - key=f"rename_text_{rename_key_suffix}", - disabled=not current_columns + value=col_to_rename, # Default to current name + key=f"rename_text_{rename_key_suffix}" ) - if st.button("Appliquer Renommage", key=f"rename_button_{rename_key_suffix}", disabled=not current_columns): - if col_to_rename and new_name and col_to_rename in data.columns: - if new_name in data.columns and new_name != col_to_rename: + + if st.button("Appliquer Renommage", key=f"rename_button_{rename_key_suffix}"): + data_to_modify = st.session_state.dataframe_to_export # Get the dataframe from session state + if data_to_modify is not None and col_to_rename and new_name and col_to_rename in data_to_modify.columns: + if new_name in data_to_modify.columns and new_name != col_to_rename: st.error(f"Le nom '{new_name}' existe déjà.") elif new_name: - data.rename(columns={col_to_rename: new_name}, inplace=True) - st.session_state.dataframe_to_export = data # Mettre à jour session state - st.success(f"'{col_to_rename}' renommée en '{new_name}'.") - # Rerun pour que les changements soient visibles partout + # Perform rename on the DataFrame stored in session state + data_to_modify.rename(columns={col_to_rename: new_name}, inplace=True) + st.session_state.dataframe_to_export = data_to_modify # Update session state + st.success(f"'{col_to_rename}' renommée en '{new_name}'. Rafraîchissement...") + # Rerun to update column lists and selectors everywhere st.rerun() else: st.warning("Le nouveau nom ne peut pas être vide.") + elif data_to_modify is None: + st.error("Impossible de renommer: Aucune donnée chargée en mémoire.") elif not col_to_rename: st.warning("Veuillez sélectionner une colonne.") elif not new_name: st.warning("Veuillez entrer un nouveau nom.") + elif col_to_rename not in data_to_modify.columns: + st.error(f"Erreur interne: La colonne '{col_to_rename}' n'a pas été trouvée dans les données actuelles.") else: - st.info("Chargez des données pour renommer les colonnes.") + st.info("Chargez des données pour pouvoir renommer les colonnes.") + # --- Exportation --- st.subheader("3. Exporter") + # Always get the latest from session state for export df_to_export = st.session_state.get('dataframe_to_export', None) if df_to_export is not None: export_key_suffix = st.session_state.data_loaded_id if st.session_state.data_loaded_id else "no_data" - export_filename_base = f"export_{uploaded_file.name.split('.')[0]}" if uploaded_file else "export_local_default" + # Try to derive a filename base from the source info + source_for_filename = st.session_state.get('data_source_info', 'donnees') + if "Fichier chargé :" in source_for_filename: + base_name = source_for_filename.split(":")[-1].strip() + export_filename_base = f"export_{os.path.splitext(base_name)[0]}" + elif "local par défaut" in source_for_filename: + export_filename_base = "export_local_default" + else: + export_filename_base = "export_donnees" + # Clean the base name + export_filename_base = "".join(c if c.isalnum() or c in ('_', '-') else '_' for c in export_filename_base) + col_export1, col_export2, col_export3 = st.columns(3) with col_export1: - csv_data = df_to_export.to_csv(index=False).encode('utf-8') - st.download_button(label="Exporter CSV", data=csv_data, file_name=f"{export_filename_base}.csv", mime="text/csv", key=f"download_csv_{export_key_suffix}") + try: + csv_data = df_to_export.to_csv(index=False).encode('utf-8') + st.download_button(label="Exporter CSV", data=csv_data, file_name=f"{export_filename_base}.csv", mime="text/csv", key=f"download_csv_{export_key_suffix}") + except Exception as e: + st.error(f"Erreur Export CSV: {e}") with col_export2: - excel_buffer = io.BytesIO() - with pd.ExcelWriter(excel_buffer, engine='openpyxl') as writer: - df_to_export.to_excel(writer, index=False, sheet_name='Data') - excel_buffer.seek(0) - st.download_button(label="Exporter Excel", data=excel_buffer, file_name=f"{export_filename_base}.xlsx", mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", key=f"download_excel_{export_key_suffix}") + try: + excel_buffer = io.BytesIO() + # Use openpyxl engine explicitly + with pd.ExcelWriter(excel_buffer, engine='openpyxl') as writer: + df_to_export.to_excel(writer, index=False, sheet_name='Data') + # excel_buffer.seek(0) # seek(0) is done implicitly when exiting the 'with' block for BytesIO + st.download_button(label="Exporter Excel", data=excel_buffer.getvalue(), file_name=f"{export_filename_base}.xlsx", mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", key=f"download_excel_{export_key_suffix}") + except Exception as e: + st.error(f"Erreur Export Excel: {e}") + st.warning("Assurez-vous que 'openpyxl' est dans requirements.txt.", icon="💡") + with col_export3: if template: if st.button("Préparer Rapport HTML", key=f"prep_html_{export_key_suffix}"): - num_submissions_report = df_to_export['_index'].nunique() if '_index' in df_to_export.columns else len(df_to_export) - tables_html_list = [] - charts_html_list = [] - for analysis in st.session_state.get('analyses', []): - result = analysis.get('result') - analysis_type = analysis.get('type') - params = analysis.get('executed_params', analysis.get('params', {})) - if result is not None: - analysis_id_rep = analysis.get('id', -1) + 1 - title = f"Analyse {analysis_id_rep}: {analysis_type.replace('_', ' ').title()}" - param_details_list = [f"{k}={v}" for k,v in params.items() if v is not None and v != []] - param_details = ", ".join(param_details_list) - full_title = f"{title} ({param_details})" if param_details else title - - if analysis_type in ['aggregated_table', 'descriptive_stats'] and isinstance(result, pd.DataFrame): - tables_html_list.append(f"

{full_title}

{result.to_html(index=(analysis_type == 'descriptive_stats'), classes='table table-striped')}") - elif analysis_type == 'graph' and isinstance(result, go.Figure): - charts_html_list.append(f"

{full_title}

{result.to_html(full_html=False, include_plotlyjs='cdn')}") - tables_html = "\n
\n".join(tables_html_list) - charts_html = "\n
\n".join(charts_html_list) - html_content = generate_html_report(df_to_export, num_submissions_report, df_to_export.columns.tolist(), tables_html, charts_html) - st.session_state.html_report_content = html_content.encode('utf-8') - st.session_state.html_report_filename = f"rapport_{export_filename_base}.html" - st.success("Rapport HTML prêt à être téléchargé.") + with st.spinner("Génération du rapport HTML..."): + try: + # Use the actual dataframe for reporting + data_for_report = st.session_state.dataframe_to_export + if data_for_report is not None: + num_submissions_report = data_for_report['_index'].nunique() if '_index' in data_for_report.columns else len(data_for_report) + columns_for_report = data_for_report.columns.tolist() + tables_html_list = [] + charts_html_list = [] + + for analysis in st.session_state.get('analyses', []): + result = analysis.get('result') + analysis_type = analysis.get('type') + # Use executed params if available, otherwise current params + params = analysis.get('executed_params', analysis.get('params', {})) + analysis_id_rep = analysis.get('id', -1) + 1 # Use the persistent ID + + if result is not None: + title = f"Analyse {analysis_id_rep}: {analysis_type.replace('_', ' ').title()}" + # Create more readable param details + param_details_list = [] + for k,v in params.items(): + if v is not None and v != [] and v != 'None': + # Shorten long lists + if isinstance(v, list) and len(v) > 3: + v_repr = f"[{v[0]}, {v[1]}, ..., {v[-1]}] ({len(v)} items)" + else: + v_repr = str(v) + param_details_list.append(f"{k.replace('_', ' ').title()} = {v_repr}") + + param_details = "; ".join(param_details_list) + full_title = f"{title} ({param_details})" if param_details else title + + try: + if analysis_type in ['aggregated_table', 'descriptive_stats'] and isinstance(result, pd.DataFrame): + # Use .to_html for better table formatting + table_html = result.to_html(index=(analysis_type == 'descriptive_stats'), classes='table table-striped table-hover table-sm', border=0) + tables_html_list.append(f"

{full_title}

{table_html}") + elif analysis_type == 'graph' and isinstance(result, go.Figure): + # Export chart as HTML snippet + chart_html = result.to_html(full_html=False, include_plotlyjs='cdn') + charts_html_list.append(f"

{full_title}

{chart_html}") + except Exception as e_render: + st.warning(f"Erreur rendu résultat pour Analyse {analysis_id_rep}: {e_render}") + + + tables_html = "\n
\n".join(tables_html_list) if tables_html_list else "

Aucun tableau généré.

" + charts_html = "\n
\n".join(charts_html_list) if charts_html_list else "

Aucun graphique généré.

" + + html_content = generate_html_report(data_for_report, num_submissions_report, columns_for_report, tables_html, charts_html) + + if "Erreur:" not in html_content: + st.session_state.html_report_content = html_content.encode('utf-8') + st.session_state.html_report_filename = f"rapport_{export_filename_base}.html" + st.success("Rapport HTML prêt à être téléchargé.") + else: + st.error("Échec de la génération du contenu HTML du rapport.") + else: + st.error("Impossible de générer le rapport: Aucune donnée disponible.") + except Exception as e_report: + st.error(f"Erreur lors de la préparation du rapport HTML: {e_report}") + if 'html_report_content' in st.session_state and st.session_state.html_report_content: st.download_button( @@ -356,136 +486,199 @@ with app_tab: file_name=st.session_state.html_report_filename, mime="text/html", key=f"download_html_btn_{export_key_suffix}", + help="Télécharge le rapport généré par 'Préparer Rapport HTML'.", + # Clear content after download starts to avoid re-downloading same report on_click=lambda: st.session_state.update(html_report_content=None) ) else: - st.info("Export HTML indisponible.") + st.info("Template HTML non trouvé. Export HTML indisponible.") else: st.info("Chargez des données pour activer l'exportation.") # --- Zone Principale de l'Application --- - st.header(" Aperçu et Analyse des Données") + st.header("📊 Aperçu et Analyse des Données") # Utiliser les données les plus à jour de session_state data = st.session_state.get('dataframe_to_export', None) data_source_info = st.session_state.get('data_source_info', "Aucune donnée chargée") if data is not None: - # Redéfinir les listes de colonnes ici pour être sûr qu'elles sont basées sur le 'data' actuel - categorical_columns = data.select_dtypes(exclude=['number', 'datetime', 'timedelta']).columns.tolist() - numerical_columns = data.select_dtypes(include=['number']).columns.tolist() - datetime_columns = data.select_dtypes(include=['datetime']).columns.tolist() + # --- Utiliser les listes de colonnes dérivées plus haut --- + # Elles sont basées sur les types détectés/convertis (même si la conversion n'est pas persistée par défaut) + current_all_columns = all_columns # data.columns.tolist() + current_categorical_columns = categorical_columns + current_numerical_columns = numerical_columns + current_datetime_columns = datetime_columns # Afficher informations sur les données chargées st.info(f"**Source de données active :** {data_source_info}") - if '_index' in data.columns: - num_submissions = data['_index'].nunique() - display_text = f"Nombre de soumissions uniques (basé sur '_index') : **{num_submissions}**" - else: - num_submissions = len(data) - display_text = f"Nombre total d'enregistrements : **{num_submissions}**" - st.markdown(f"
{display_text}
", unsafe_allow_html=True) - st.write(f"Dimensions des données : **{data.shape[0]} lignes x {data.shape[1]} colonnes**") - - # Afficher les noms des colonnes et types - with st.expander("Afficher les détails des colonnes"): - cols_df = pd.DataFrame({ - 'Nom Colonne': data.columns, - 'Type Donnée': data.dtypes.astype(str), - 'Est Numérique': data.columns.isin(numerical_columns), - 'Est Catégoriel': data.columns.isin(categorical_columns), - 'Est Date/Heure': data.columns.isin(datetime_columns) - }) - st.dataframe(cols_df, use_container_width=True) + try: + if '_index' in data.columns: + num_submissions = data['_index'].nunique() + display_text = f"Nombre de soumissions uniques (basé sur '_index') : **{num_submissions}**" + else: + num_submissions = len(data) + display_text = f"Nombre total d'enregistrements : **{num_submissions}**" + st.markdown(f"
{display_text}
", unsafe_allow_html=True) + st.write(f"Dimensions des données : **{data.shape[0]} lignes x {data.shape[1]} colonnes**") + + # Afficher un aperçu des données + with st.expander("Afficher un aperçu des données (5 premières lignes)"): + st.dataframe(data.head(), use_container_width=True) + + # Afficher les noms des colonnes et types DÉTECTÉS + with st.expander("Afficher les détails des colonnes (Types détectés)"): + # Utiliser les listes déterminées après l'étape de conversion/détection + cols_df = pd.DataFrame({ + 'Nom Colonne': current_all_columns + }) + # Map types based on our categorized lists + col_types = [] + for col in current_all_columns: + if col in current_numerical_columns: + col_types.append(f"Numérique ({data[col].dtype})") + elif col in current_datetime_columns: + col_types.append(f"Date/Heure ({data[col].dtype})") + elif col in current_categorical_columns: + col_types.append(f"Catégoriel ({data[col].dtype})") + else: + col_types.append(f"Inconnu ({data[col].dtype})") # Fallback + cols_df['Type Détecté'] = col_types + cols_df['Est Numérique'] = cols_df['Nom Colonne'].isin(current_numerical_columns) + cols_df['Est Catégoriel'] = cols_df['Nom Colonne'].isin(current_categorical_columns) + cols_df['Est Date/Heure'] = cols_df['Nom Colonne'].isin(current_datetime_columns) + st.dataframe(cols_df.set_index('Nom Colonne'), use_container_width=True) + except Exception as e_display: + st.error(f"Erreur lors de l'affichage des informations sur les données: {e_display}") # --- Section d'Ajout d'Analyses --- st.subheader("🛠️ Construire les Analyses") st.write("Ajoutez des blocs d'analyse pour explorer vos données.") col_add1, col_add2, col_add3 = st.columns(3) - analysis_key_suffix = st.session_state.data_loaded_id if st.session_state.data_loaded_id else "no_data" + # Use a consistent key suffix based on whether data is loaded + analysis_key_suffix = "data_loaded" if data is not None else "no_data" with col_add1: if st.button("➕ Ajouter Tableau Agrégé", key=f"add_agg_{analysis_key_suffix}", help="Calculer des statistiques groupées (ex: moyenne par catégorie)."): new_id = max([a.get('id', -1) for a in st.session_state.analyses] + [-1]) + 1 - st.session_state.analyses.append({'type': 'aggregated_table', 'params': {}, 'result': None, 'id': new_id}) + st.session_state.analyses.append({'type': 'aggregated_table', 'params': {}, 'result': None, 'id': new_id, 'executed_params': None}) st.rerun() with col_add2: if st.button("➕ Ajouter Graphique", key=f"add_graph_{analysis_key_suffix}", help="Créer une visualisation interactive."): new_id = max([a.get('id', -1) for a in st.session_state.analyses] + [-1]) + 1 - st.session_state.analyses.append({'type': 'graph', 'params': {}, 'result': None, 'id': new_id}) + st.session_state.analyses.append({'type': 'graph', 'params': {}, 'result': None, 'id': new_id, 'executed_params': None}) st.rerun() with col_add3: if st.button("➕ Ajouter Stats Descriptives", key=f"add_desc_{analysis_key_suffix}", help="Obtenir résumé statistique (moyenne, médiane, etc.)."): new_id = max([a.get('id', -1) for a in st.session_state.analyses] + [-1]) + 1 - st.session_state.analyses.append({'type': 'descriptive_stats', 'params': {}, 'result': None, 'id': new_id}) + st.session_state.analyses.append({'type': 'descriptive_stats', 'params': {}, 'result': None, 'id': new_id, 'executed_params': None}) st.rerun() # --- Affichage et Configuration des Analyses --- st.subheader("🔍 Analyses Configurées") indices_to_remove = [] data_available = data is not None - # Utiliser les listes de colonnes à jour définies après la conversion - current_categorical_columns = categorical_columns - current_numerical_columns = numerical_columns - current_datetime_columns = datetime_columns - columns_defined = data_available and (bool(current_categorical_columns) or bool(current_numerical_columns) or bool(current_datetime_columns)) + # Utiliser les listes de colonnes DÉTECTÉES pour la configuration + # Renommées current_* pour éviter confusion avec celles de la sidebar + conf_categorical_columns = current_categorical_columns + conf_numerical_columns = current_numerical_columns + conf_datetime_columns = current_datetime_columns + conf_all_columns = current_all_columns + + columns_defined = data_available and bool(conf_all_columns) if not data_available: st.warning("Veuillez d'abord charger des données pour configurer des analyses.") - elif not columns_defined and data is not None: - st.warning("Les données chargées ne semblent pas avoir de colonnes numériques, catégorielles ou date/heure identifiables après conversion.") + elif not columns_defined: + st.warning("Les données chargées ne semblent pas avoir de colonnes identifiables.") elif not st.session_state.analyses: st.info("Cliquez sur l'un des boutons '➕ Ajouter...' ci-dessus pour commencer.") # Boucle principale pour afficher chaque bloc d'analyse if data_available and columns_defined: for i, analysis in enumerate(st.session_state.analyses): - analysis_id = analysis.get('id', i) + analysis_id = analysis.get('id', i) # Use persistent ID analysis_container = st.container(border=True) with analysis_container: cols_header = st.columns([0.95, 0.05]) with cols_header[0]: - st.subheader(f"Analyse {i+1}: {analysis['type'].replace('_', ' ').title()}") + analysis_title = analysis['type'].replace('_', ' ').title() + st.subheader(f"Analyse {i+1}: {analysis_title}") with cols_header[1]: if st.button("🗑️", key=f"remove_analysis_{analysis_id}", help="Supprimer cette analyse"): indices_to_remove.append(i) + # Need to rerun immediately to avoid errors from removed analysis configs + st.rerun() # =========================== # Bloc Tableau Agrégé # =========================== if analysis['type'] == 'aggregated_table': st.markdown(f"##### Configuration Tableau Agrégé") - if not current_categorical_columns: st.warning("Aucune colonne catégorielle disponible.") - elif not current_numerical_columns: st.warning("Aucune colonne numérique disponible.") + if not conf_categorical_columns: st.warning("Aucune colonne catégorielle disponible pour le regroupement.") + elif not conf_numerical_columns: st.warning("Aucune colonne numérique disponible pour l'agrégation.") else: + # Initialize state if missing for this analysis block + init_analysis_state(i, 'group_by_columns', []) + init_analysis_state(i, 'agg_column', conf_numerical_columns[0] if conf_numerical_columns else None) + init_analysis_state(i, 'agg_method', 'count') + col_agg1, col_agg2, col_agg3 = st.columns(3) with col_agg1: - group_by_columns = st.multiselect(f"Regrouper par :", current_categorical_columns, default=analysis['params'].get('group_by_columns', []), key=f"agg_table_groupby_{analysis_id}") + # Filter default value based on available columns + default_groupby = [col for col in analysis['params'].get('group_by_columns', []) if col in conf_categorical_columns] + st.session_state.analyses[i]['params']['group_by_columns'] = st.multiselect( + f"Regrouper par :", conf_categorical_columns, + default=default_groupby, key=f"agg_table_groupby_{analysis_id}" + ) with col_agg2: - agg_col_index = get_safe_index(current_numerical_columns, analysis['params'].get('agg_column')) - agg_column = st.selectbox(f"Calculer sur :", current_numerical_columns, index=agg_col_index, key=f"agg_table_agg_col_{analysis_id}") + agg_col_index = get_safe_index(conf_numerical_columns, analysis['params'].get('agg_column')) + st.session_state.analyses[i]['params']['agg_column'] = st.selectbox( + f"Calculer sur :", conf_numerical_columns, + index=agg_col_index, key=f"agg_table_agg_col_{analysis_id}" + ) with col_agg3: agg_method_options = ('count', 'mean', 'sum', 'median', 'min', 'max', 'std', 'nunique') agg_method_index = get_safe_index(agg_method_options, analysis['params'].get('agg_method', 'count')) - agg_method = st.selectbox(f"Avec fonction :", agg_method_options, index=agg_method_index, key=f"agg_table_agg_method_{analysis_id}") + st.session_state.analyses[i]['params']['agg_method'] = st.selectbox( + f"Avec fonction :", agg_method_options, + index=agg_method_index, key=f"agg_table_agg_method_{analysis_id}" + ) if st.button(f"Exécuter Tableau Agrégé {i+1}", key=f"run_agg_table_{analysis_id}"): - current_params = {'group_by_columns': group_by_columns, 'agg_column': agg_column, 'agg_method': agg_method} - if group_by_columns and agg_column is not None and agg_method is not None: + # Retrieve current params from widgets + current_params = st.session_state.analyses[i]['params'].copy() + group_by_cols = current_params['group_by_columns'] + agg_col = current_params['agg_column'] + agg_method = current_params['agg_method'] + + if group_by_cols and agg_col is not None and agg_method is not None: try: - if all(c in data.columns for c in group_by_columns) and agg_column in data.columns: - aggregated_data = data.groupby(group_by_columns, as_index=False)[agg_column].agg(agg_method) + # Validate columns against the actual data frame + if all(c in data.columns for c in group_by_cols) and agg_col in data.columns: + st.info(f"Exécution agrégation: {agg_method}({agg_col}) groupé par {group_by_cols}") + aggregated_data = data.groupby(group_by_cols, as_index=False)[agg_col].agg(agg_method) + # Rename aggregated column for clarity if needed (e.g., if method is count) + if agg_method == 'count' and agg_col in aggregated_data.columns: + aggregated_data = aggregated_data.rename(columns={agg_col: f'{agg_col}_{agg_method}'}) + elif agg_method != 'count' and agg_col in aggregated_data.columns and agg_method in ['mean', 'sum', 'median', 'min', 'max', 'std', 'nunique']: + aggregated_data = aggregated_data.rename(columns={agg_col: f'{agg_col}_{agg_method}'}) + + st.session_state.analyses[i]['result'] = aggregated_data - st.session_state.analyses[i]['params'] = current_params - st.session_state.analyses[i]['executed_params'] = current_params - st.rerun() - else: st.error("Colonnes sélectionnées invalides.") + st.session_state.analyses[i]['executed_params'] = current_params # Store executed params + st.rerun() # Rerun to display result + else: + st.error("Colonnes sélectionnées invalides ou non trouvées dans les données actuelles.") except Exception as e: st.error(f"Erreur Agrégation {i+1}: {e}") - st.session_state.analyses[i]['result'] = None - else: st.warning("Sélection invalide.") + st.session_state.analyses[i]['result'] = None # Clear result on error + st.session_state.analyses[i]['executed_params'] = current_params # Store params even if failed + else: + st.warning("Veuillez sélectionner des colonnes pour 'Regrouper par', 'Calculer sur' et une 'Fonction'.") + # =========================== # Bloc Graphique @@ -493,227 +686,441 @@ with app_tab: elif analysis['type'] == 'graph': st.markdown(f"##### Configuration Graphique") - graph_usable_columns = current_numerical_columns + current_categorical_columns + current_datetime_columns + # Base options for axes etc. are all columns + graph_usable_columns = conf_all_columns if not graph_usable_columns: - st.warning("Aucune colonne utilisable (Num/Cat/Date) pour les graphiques.") - # Le 'else' qui suit s'applique à tout le reste de la configuration du graphique + st.warning("Aucune colonne disponible pour les graphiques.") else: - if 0 <= i < len(st.session_state.analyses): # Vérification de sécurité index - # --- Initialisation de l'état --- + # --- Initialize state for this specific analysis block if needed --- + # Check if index is valid before accessing session state + if 0 <= i < len(st.session_state.analyses): init_analysis_state(i, 'chart_type', 'Bar Chart') + # Aggregation params (optional) init_analysis_state(i, 'group_by_columns_graph', []) - init_analysis_state(i, 'agg_column_graph', current_numerical_columns[0] if current_numerical_columns else None) + init_analysis_state(i, 'agg_column_graph', conf_numerical_columns[0] if conf_numerical_columns else None) init_analysis_state(i, 'agg_method_graph', 'count') - init_analysis_state(i, 'x_column', None) - init_analysis_state(i, 'y_column', None) + # Axis/Mapping params + init_analysis_state(i, 'x_column', conf_categorical_columns[0] if conf_categorical_columns else (conf_datetime_columns[0] if conf_datetime_columns else conf_all_columns[0] if conf_all_columns else None)) + init_analysis_state(i, 'y_column', conf_numerical_columns[0] if conf_numerical_columns else None) init_analysis_state(i, 'color_column', 'None') init_analysis_state(i, 'size_column', 'None') + init_analysis_state(i, 'facet_column', 'None') # New: Facetting + init_analysis_state(i, 'hover_data_cols', []) # New: Hover Data + # --- Configuration Principale --- - chart_type_options = ('Bar Chart', 'Line Chart', 'Scatter Plot', 'Histogram', 'Box Plot', 'Violin Plot', 'Heatmap', 'Timeline', '3D Scatter Plot', 'Pair Plot') + chart_type_options = ('Bar Chart', 'Line Chart', 'Scatter Plot', 'Histogram', 'Box Plot', 'Violin Plot', 'Heatmap', 'Density Contour', 'Area Chart', 'Funnel Chart', 'Timeline (Gantt)', 'Sunburst', 'Treemap', '3D Scatter Plot', 'Pair Plot (SPLOM)') chart_type_index = get_safe_index(chart_type_options, st.session_state.analyses[i]['params'].get('chart_type')) st.session_state.analyses[i]['params']['chart_type'] = st.selectbox(f"Type de graphique:", chart_type_options, index=chart_type_index, key=f"graph_type_{analysis_id}") graph_analysis_type = st.session_state.analyses[i]['params']['chart_type'] - # --- Options d'agrégation --- - st.markdown("###### Options d'agrégation (Optionnel)") - if not current_categorical_columns or not current_numerical_columns: st.caption("Colonnes catégorielles et numériques requises.") - else: - col_agg_graph1, col_agg_graph2, col_agg_graph3 = st.columns(3) - with col_agg_graph1: - valid_default_groupby = [col for col in st.session_state.analyses[i]['params'].get('group_by_columns_graph',[]) if col in current_categorical_columns] - st.session_state.analyses[i]['params']['group_by_columns_graph'] = st.multiselect(f"Agréger par :", current_categorical_columns, default=valid_default_groupby, key=f"graph_groupby_{analysis_id}") - group_by_columns_graph = st.session_state.analyses[i]['params']['group_by_columns_graph'] - with col_agg_graph2: - agg_col_index = get_safe_index(current_numerical_columns, st.session_state.analyses[i]['params'].get('agg_column_graph')) - st.session_state.analyses[i]['params']['agg_column_graph'] = st.selectbox(f"Calculer :", current_numerical_columns, index=agg_col_index, key=f"graph_agg_col_{analysis_id}", disabled=not group_by_columns_graph) - with col_agg_graph3: - agg_method_options = ('count', 'mean', 'sum', 'median', 'min', 'max', 'std', 'nunique') - agg_method_index = get_safe_index(agg_method_options, st.session_state.analyses[i]['params'].get('agg_method_graph','count')) - st.session_state.analyses[i]['params']['agg_method_graph'] = st.selectbox(f"Avec fonction :", agg_method_options, index=agg_method_index, key=f"graph_agg_method_{analysis_id}", disabled=not group_by_columns_graph) - - # --- Déterminer source de données --- - plot_data_source_df = data + + # --- Determine data source: Original or Aggregated --- + plot_data_source_df = data # Start with original data is_aggregated = False + agg_warning = None current_group_by = st.session_state.analyses[i]['params'].get('group_by_columns_graph', []) current_agg_col = st.session_state.analyses[i]['params'].get('agg_column_graph') current_agg_method = st.session_state.analyses[i]['params'].get('agg_method_graph') - if current_group_by and current_agg_col is not None and current_agg_method is not None: - if all(c in data.columns for c in current_group_by) and current_agg_col in data.columns: + + # Check if aggregation is configured + aggregation_enabled = bool(current_group_by) + + # If aggregation is selected, try to perform it + if aggregation_enabled: + if not current_group_by: + agg_warning = "Agréger par: Sélectionnez au moins une colonne." + elif not current_agg_col: + agg_warning = "Calculer: Sélectionnez une colonne numérique." + elif not current_agg_method: + agg_warning = "Avec fonction: Sélectionnez une méthode." + elif not conf_categorical_columns: + agg_warning = "Agréger par: Aucune colonne catégorielle disponible." + elif not conf_numerical_columns: + agg_warning = "Calculer: Aucune colonne numérique disponible." + elif not all(c in data.columns for c in current_group_by) or current_agg_col not in data.columns: + agg_warning = "Colonnes d'agrégation sélectionnées invalides." + else: try: + # Perform aggregation temp_aggregated_data_graph = data.groupby(current_group_by, as_index=False)[current_agg_col].agg(current_agg_method) + # Rename aggregated column for clarity + agg_col_name_new = f'{current_agg_col}_{current_agg_method}' + if current_agg_col in temp_aggregated_data_graph.columns: + temp_aggregated_data_graph = temp_aggregated_data_graph.rename(columns={current_agg_col: agg_col_name_new}) plot_data_source_df = temp_aggregated_data_graph is_aggregated = True - except Exception: pass + except Exception as agg_e: + agg_warning = f"Erreur agrégation: {agg_e}" + plot_data_source_df = data # Fallback to original data + is_aggregated = False + else: + plot_data_source_df = data # Use original data if no aggregation + is_aggregated = False + + # Determine columns available for plotting (from original or aggregated df) chart_columns = plot_data_source_df.columns.tolist() if plot_data_source_df is not None else [] - if not chart_columns: st.warning("Impossible de déterminer les colonnes pour le graphique.") + # --- Widgets for Axes and Mappings --- + if not chart_columns: + st.warning("Impossible de déterminer les colonnes pour le graphique (données sources indisponibles ou vides).") else: - # Initialiser X/Y - current_x = st.session_state.analyses[i]['params'].get('x_column') - current_y = st.session_state.analyses[i]['params'].get('y_column') - if current_x is None or current_x not in chart_columns: - default_x_options = [c for c in current_datetime_columns if c in chart_columns] + [c for c in chart_columns] - st.session_state.analyses[i]['params']['x_column'] = default_x_options[0] if default_x_options else None - if current_y is None or current_y not in chart_columns: - default_y_options = [c for c in current_numerical_columns if c in chart_columns and c != st.session_state.analyses[i]['params']['x_column']] - if default_y_options: st.session_state.analyses[i]['params']['y_column'] = default_y_options[0] - else: - fallback_y_options = [c for c in chart_columns if c != st.session_state.analyses[i]['params']['x_column']] - st.session_state.analyses[i]['params']['y_column'] = fallback_y_options[0] if fallback_y_options else chart_columns[0] + st.markdown("###### Axes & Mappages") + col1_axes, col2_axes, col3_axes = st.columns(3) - # --- Widgets Axes et Mappages --- - st.markdown("###### Axes et Mappages") - col1_axes, col2_axes = st.columns(2) - axis_options = chart_columns + # --- X Axis --- with col1_axes: - x_col_index = get_safe_index(axis_options, st.session_state.analyses[i]['params']['x_column']) - st.session_state.analyses[i]['params']['x_column'] = st.selectbox(f"Axe X:", axis_options, index=x_col_index, key=f"graph_x_{analysis_id}") - - y_column_disabled = (graph_analysis_type in ['Histogram', 'Timeline']) - y_help_text = "Non requis pour Histogramme" if graph_analysis_type == 'Histogram' else ("Utilisé pour regrouper les timelines (optionnel)" if graph_analysis_type == 'Timeline' else "") - y_options = axis_options if graph_analysis_type != 'Timeline' else ['None'] + current_categorical_columns - y_default_val = st.session_state.analyses[i]['params'].get('y_column', 'None' if graph_analysis_type == 'Timeline' else None) - y_col_index = get_safe_index(y_options, y_default_val) - y_val = st.selectbox(f"Axe Y{' / Groupe' if graph_analysis_type == 'Timeline' else ''}:", y_options, index=y_col_index, key=f"graph_y_{analysis_id}", disabled=y_column_disabled, help=y_help_text) - st.session_state.analyses[i]['params']['y_column'] = None if y_val == 'None' else y_val - + # Determine suitable default X based on chart type + default_x = analysis['params'].get('x_column') + if default_x not in chart_columns: # If previous selection is invalid + if graph_analysis_type in ['Histogram', 'Box Plot', 'Violin Plot', 'Density Contour'] and conf_numerical_columns: + default_x = conf_numerical_columns[0] if any(c in chart_columns for c in conf_numerical_columns) else chart_columns[0] + elif conf_categorical_columns: + default_x = conf_categorical_columns[0] if any(c in chart_columns for c in conf_categorical_columns) else chart_columns[0] + elif conf_datetime_columns: + default_x = conf_datetime_columns[0] if any(c in chart_columns for c in conf_datetime_columns) else chart_columns[0] + else: + default_x = chart_columns[0] # Fallback + + x_col_index = get_safe_index(chart_columns, default_x) + x_help = "Variable principale pour l'histogramme" if graph_analysis_type == 'Histogram' else "Axe horizontal" + st.session_state.analyses[i]['params']['x_column'] = st.selectbox( + f"Axe X:", chart_columns, index=x_col_index, key=f"graph_x_{analysis_id}", help=x_help + ) + + # --- Y Axis --- with col2_axes: - original_cols_options = current_categorical_columns + current_numerical_columns + current_datetime_columns - color_options = ['None'] + original_cols_options - color_col_index = get_safe_index(color_options, st.session_state.analyses[i]['params'].get('color_column')) - st.session_state.analyses[i]['params']['color_column'] = st.selectbox(f"Couleur (Optionnel):", color_options, index=color_col_index, key=f"graph_color_{analysis_id}") + y_options = chart_columns + y_disabled = graph_analysis_type in ['Histogram'] # Y not typically used for basic histo + y_label = "Axe Y" + default_y = analysis['params'].get('y_column') + + # Specific handling for Timeline/Gantt + if graph_analysis_type == 'Timeline (Gantt)': + y_options = conf_categorical_columns # Typically group by category + y_label = "Tâche / Groupe (Y)" + y_disabled = False + if default_y not in y_options: default_y = y_options[0] if y_options else None + elif y_disabled: + default_y = None # No Y for histo + elif default_y not in chart_columns: # Auto-select if invalid + # Prefer numerical Y for scatter, line, bar etc. + num_cols_in_chart = [c for c in conf_numerical_columns if c in chart_columns and c != st.session_state.analyses[i]['params']['x_column']] + if num_cols_in_chart: default_y = num_cols_in_chart[0] + else: # Fallback to any other column + fallback_y = [c for c in chart_columns if c!= st.session_state.analyses[i]['params']['x_column']] + default_y = fallback_y[0] if fallback_y else None + + y_col_index = get_safe_index(y_options, default_y) if default_y else 0 + y_help = "Non requis pour Histogramme." if y_disabled else ("Regroupement vertical des tâches." if graph_analysis_type == 'Timeline (Gantt)' else "Axe vertical") + st.session_state.analyses[i]['params']['y_column'] = st.selectbox( + y_label, y_options, index=y_col_index, key=f"graph_y_{analysis_id}", disabled=y_disabled, help=y_help + ) + + # --- Color, Size, Facet Mappings (Use Original Data Columns for these) --- + # Mapping options should generally come from the *original* dataframe columns + mapping_options_cat = ['None'] + conf_categorical_columns + mapping_options_num = ['None'] + conf_numerical_columns + mapping_options_all = ['None'] + conf_all_columns # For hover data + + with col3_axes: + # Color + default_color = analysis['params'].get('color_column', 'None') + if default_color not in mapping_options_cat + mapping_options_num: default_color = 'None' + color_col_index = get_safe_index(mapping_options_cat + mapping_options_num, default_color) + st.session_state.analyses[i]['params']['color_column'] = st.selectbox( + f"Couleur (Optionnel):", mapping_options_cat + mapping_options_num, index=color_col_index, key=f"graph_color_{analysis_id}" + ) + + # Size (typically numerical) + default_size = analysis['params'].get('size_column', 'None') + if default_size not in mapping_options_num: default_size = 'None' + size_col_index = get_safe_index(mapping_options_num, default_size) + size_disabled = graph_analysis_type not in ['Scatter Plot', '3D Scatter Plot'] # Size mainly for scatter + st.session_state.analyses[i]['params']['size_column'] = st.selectbox( + f"Taille (Optionnel, Num.):", mapping_options_num, index=size_col_index, key=f"graph_size_{analysis_id}", disabled=size_disabled + ) + + # --- Facet & Hover --- + col1_extra, col2_extra = st.columns(2) + with col1_extra: + # Facet (Categorical) + default_facet = analysis['params'].get('facet_column', 'None') + if default_facet not in mapping_options_cat: default_facet = 'None' + facet_col_index = get_safe_index(mapping_options_cat, default_facet) + facet_disabled = graph_analysis_type in ['Heatmap', 'Density Contour', 'Pair Plot (SPLOM)', 'Sunburst', 'Treemap'] # Facetting makes less sense here + st.session_state.analyses[i]['params']['facet_column'] = st.selectbox( + f"Diviser par (Facet, Opt.):", mapping_options_cat, index=facet_col_index, key=f"graph_facet_{analysis_id}", disabled=facet_disabled, help="Crée des sous-graphiques pour chaque catégorie." + ) + with col2_extra: + # Hover Data (Any original columns) + default_hover = analysis['params'].get('hover_data_cols', []) + valid_default_hover = [c for c in default_hover if c in conf_all_columns] + st.session_state.analyses[i]['params']['hover_data_cols'] = st.multiselect( + "Infos au survol (Hover):", conf_all_columns, + default=valid_default_hover, key=f"graph_hover_{analysis_id}", help="Ajoute des colonnes aux infobulles." + ) + + # --- Options d'agrégation (Collapsible) --- + with st.expander("Options d'agrégation (si besoin d'agréger avant de tracer)", expanded=aggregation_enabled): + if not conf_categorical_columns or not conf_numerical_columns: + st.caption("Agrégation requiert des colonnes catégorielles ET numériques dans les données originales.") + else: + col_agg_graph1, col_agg_graph2, col_agg_graph3 = st.columns(3) + with col_agg_graph1: + # Use original categorical columns for grouping selection + valid_default_groupby = [col for col in st.session_state.analyses[i]['params'].get('group_by_columns_graph',[]) if col in conf_categorical_columns] + st.session_state.analyses[i]['params']['group_by_columns_graph'] = st.multiselect( + f"Agréger par :", conf_categorical_columns, default=valid_default_groupby, key=f"graph_groupby_{analysis_id}" + ) + group_by_cols_selected = st.session_state.analyses[i]['params']['group_by_columns_graph'] + with col_agg_graph2: + # Use original numerical columns for aggregation value selection + agg_col_options = conf_numerical_columns + agg_col_index = get_safe_index(agg_col_options, st.session_state.analyses[i]['params'].get('agg_column_graph')) + st.session_state.analyses[i]['params']['agg_column_graph'] = st.selectbox( + f"Calculer :", agg_col_options, index=agg_col_index, key=f"graph_agg_col_{analysis_id}", disabled=not group_by_cols_selected + ) + with col_agg_graph3: + agg_method_options = ('count', 'mean', 'sum', 'median', 'min', 'max', 'std', 'nunique') + agg_method_index = get_safe_index(agg_method_options, st.session_state.analyses[i]['params'].get('agg_method_graph','count')) + st.session_state.analyses[i]['params']['agg_method_graph'] = st.selectbox( + f"Avec fonction :", agg_method_options, index=agg_method_index, key=f"graph_agg_method_{analysis_id}", disabled=not group_by_cols_selected + ) + # Display warning if aggregation is attempted but failed + if aggregation_enabled and agg_warning: + st.warning(f"Avertissement Agrégation: {agg_warning}", icon="⚠️") + elif is_aggregated: + st.caption(f"Utilisation des données agrégées ({plot_data_source_df.shape[0]} lignes). Colonnes disponibles: {', '.join(chart_columns)}") + else: + st.caption("Utilisation des données originales.") - size_options = ['None'] + current_numerical_columns - size_col_index = get_safe_index(size_options, st.session_state.analyses[i]['params'].get('size_column')) - st.session_state.analyses[i]['params']['size_column'] = st.selectbox(f"Taille (Optionnel, Num.):", size_options, index=size_col_index, key=f"graph_size_{analysis_id}") # --- Bouton Exécuter Graphique --- if st.button(f"Exécuter Graphique {i+1}", key=f"run_graph_{analysis_id}"): - current_params = st.session_state.analyses[i]['params'].copy() - final_x = current_params['x_column'] - final_y = current_params['y_column'] if graph_analysis_type not in ['Histogram', 'Timeline'] else None - final_y_group = current_params['y_column'] if graph_analysis_type == 'Timeline' else None - final_color = current_params['color_column'] if current_params['color_column'] != 'None' else None - final_size = current_params['size_column'] if current_params['size_column'] != 'None' else None - - required_cols_present = bool(final_x) - if graph_analysis_type not in ['Histogram']: - required_cols_present = required_cols_present and bool(final_y or final_y_group) - - if required_cols_present and plot_data_source_df is not None: - plot_cols_needed = [c for c in [final_x, final_y, final_y_group] if c] - map_cols_needed = [c for c in [final_color, final_size] if c] - base_cols_exist = all(c in plot_data_source_df.columns for c in plot_cols_needed) - map_cols_exist_orig = all(c in data.columns for c in map_cols_needed) - - if not base_cols_exist: st.error(f"Colonnes Axes/Groupe ({plot_cols_needed}) manquantes.") - elif not map_cols_exist_orig and map_cols_needed: st.warning(f"Colonnes Mapping ({map_cols_needed}) manquantes.", icon="⚠️") - - if base_cols_exist: + with st.spinner(f"Génération du graphique '{graph_analysis_type}'..."): + # Retrieve final parameters from session state for execution + current_params = st.session_state.analyses[i]['params'].copy() + final_x = current_params['x_column'] + final_y = current_params['y_column'] if graph_analysis_type != 'Histogram' else None + final_color = current_params['color_column'] if current_params['color_column'] != 'None' else None + final_size = current_params['size_column'] if current_params['size_column'] != 'None' else None + final_facet = current_params['facet_column'] if current_params['facet_column'] != 'None' else None + final_hover = current_params['hover_data_cols'] if current_params['hover_data_cols'] else None + + # Validate required columns exist in the *plotting* dataframe + required_plot_cols = [final_x] + if final_y and graph_analysis_type != 'Histogram': required_plot_cols.append(final_y) + # Timeline specific columns (Start, End, Task/Y) + if graph_analysis_type == 'Timeline (Gantt)': + # Requires Start, End, and Y (Task) + # Let's assume X is Start, we need to ask for End + # This requires adding 'end_column' selection + st.error("Configuration Timeline/Gantt non complète - nécessite colonnes Début et Fin.") + required_plot_cols = [] # Mark as invalid for now + + # Validate required columns exist in the *original* dataframe for mapping + required_map_cols = [c for c in [final_color, final_size, final_facet] if c] + required_map_cols.extend(final_hover or []) + + plot_cols_exist = all(c in plot_data_source_df.columns for c in required_plot_cols if c) + map_cols_exist = all(c in data.columns for c in required_map_cols if c) + + if not final_x: + st.error("Axe X est requis.") + elif not plot_cols_exist: + st.error(f"Colonnes requises pour les axes ({required_plot_cols}) non trouvées dans les données source du graphique ({'agrégées' if is_aggregated else 'originales'}).") + elif not map_cols_exist: + st.warning(f"Certaines colonnes de mappage ({required_map_cols}) n'ont pas été trouvées dans les données originales. Elles seront ignorées.", icon="⚠️") + else: try: fig = None + # Prepare arguments for Plotly Express + # Use the determined plot_data_source_df px_args = {'data_frame': plot_data_source_df, 'x': final_x} - if final_y: px_args['y'] = final_y + if final_y and graph_analysis_type != 'Histogram': px_args['y'] = final_y + + # Add mappings - referencing the *original* data columns if needed + # Plotly generally handles joining/aligning if the index matches or columns exist in the plotting df if final_color and final_color in data.columns: px_args['color'] = final_color - if final_size and final_size in data.columns: px_args['size'] = final_size - title = f"{graph_analysis_type}: {final_y or final_y_group or ''} vs {final_x}" + if final_size and final_size in data.columns and graph_analysis_type in ['Scatter Plot', '3D Scatter Plot']: px_args['size'] = final_size + if final_facet and final_facet in data.columns: px_args['facet_col'] = final_facet + if final_hover and all(c in data.columns for c in final_hover): px_args['hover_data'] = final_hover + + + title = f"{graph_analysis_type}: {final_y or ''} vs {final_x}" + if final_color: title += f" (coloré par {final_color})" + if final_facet: title += f" (divisé par {final_facet})" px_args['title'] = title - # Plotting logic + # Plotting logic based on chart type if graph_analysis_type == 'Bar Chart': fig = px.bar(**px_args) elif graph_analysis_type == 'Line Chart': fig = px.line(**px_args) elif graph_analysis_type == 'Scatter Plot': fig = px.scatter(**px_args) elif graph_analysis_type == 'Histogram': - if final_x in data.columns and final_x in current_numerical_columns: - fig = px.histogram(data, x=final_x, color=final_color if final_color in data.columns else None, title=f"Histogramme de {final_x}") - else: st.warning("Histogramme requiert colonne X numérique valide.") + # Histogram uses original data source df and only X typically + hist_args = {'data_frame': data, 'x': final_x} # Use original data usually + if final_color and final_color in data.columns: hist_args['color'] = final_color + if final_facet and final_facet in data.columns: hist_args['facet_col'] = final_facet + hist_args['title'] = f"Histogramme de {final_x}" + (f" (divisé par {final_facet})" if final_facet else "") + fig = px.histogram(**hist_args) elif graph_analysis_type == 'Box Plot': fig = px.box(**px_args) elif graph_analysis_type == 'Violin Plot': fig = px.violin(**px_args) elif graph_analysis_type == 'Heatmap': fig = px.density_heatmap(**px_args) - elif graph_analysis_type == 'Timeline': - st.error("Graphique Timeline non implémenté.") - pass + elif graph_analysis_type == 'Density Contour': fig = px.density_contour(**px_args) + elif graph_analysis_type == 'Area Chart': fig = px.area(**px_args) + elif graph_analysis_type == 'Funnel Chart': fig = px.funnel(**px_args) # Often requires specific data structure + elif graph_analysis_type == 'Timeline (Gantt)': + # Needs specific 'start', 'end', 'task' columns + # Example: px.timeline(df, x_start="Start", x_end="Finish", y="Task", color="Resource") + st.warning("Timeline/Gantt nécessite des colonnes spécifiques (Début, Fin, Tâche/Y). Implémentation basique, ajustez les sélections.") + # Let's assume X is start, Y is task, needs an 'end' column selector added + # For now, placeholder: + # fig = px.timeline(plot_data_source_df, x_start=final_x, x_end=??? , y=final_y, color=final_color) + st.error("Implémentation Timeline incomplète.") + elif graph_analysis_type == 'Sunburst': + # Needs 'path' or 'names'/'parents' + st.warning("Sunburst requiert une configuration spécifique (colonne 'path' ou 'names'/'parents').") + # Example: px.sunburst(df, path=['Sector', 'Subsector', 'Company'], values='Revenue', color='Sector') + # fig = px.sunburst(plot_data_source_df, ...) + st.error("Implémentation Sunburst incomplète.") + elif graph_analysis_type == 'Treemap': + # Similar to Sunburst + st.warning("Treemap requiert une configuration spécifique (colonne 'path' ou 'names'/'parents').") + st.error("Implémentation Treemap incomplète.") elif graph_analysis_type == '3D Scatter Plot': - z_col = final_size if final_size in plot_data_source_df.columns else None - if z_col and final_y: - px_args_3d = {k:v for k,v in px_args.items() if k!= 'size'} - fig = px.scatter_3d(**px_args_3d, z=z_col, size=final_size if final_size in data.columns else None) - else: st.warning("3D Scatter requiert Y et Taille (pour Z) valides.") - elif graph_analysis_type == 'Pair Plot': - dims = [c for c in [final_x, final_y, final_size] if c is not None and c in data.columns and c in current_numerical_columns] - color_for_pairplot = final_color if final_color in data.columns else None - if len(dims) >= 2: - st.info("Calcul du Pair Plot (peut être long)...") - fig = px.scatter_matrix(data, dimensions=dims, color=color_for_pairplot, title=f'Pair Plot ({", ".join(dims)})') - else: st.warning("Pair Plot requiert >= 2 dimensions numériques valides.") + # Needs a Z column, often reuse 'size' or 'color' if numerical + z_col_options = [c for c in conf_numerical_columns if c in plot_data_source_df.columns and c not in [final_x, final_y]] + z_col = None + if final_size and final_size in plot_data_source_df.columns and final_size not in [final_x, final_y]: z_col = final_size + elif final_color and final_color in plot_data_source_df.columns and final_color in conf_numerical_columns and final_color not in [final_x, final_y]: z_col = final_color + elif z_col_options: z_col = z_col_options[0] + + if final_y and z_col: + px_args_3d = px_args.copy() + # Size might be used for Z or separately + if 'size' in px_args_3d and px_args_3d['size'] == z_col: del px_args_3d['size'] + fig = px.scatter_3d(**px_args_3d, z=z_col) + else: st.warning("3D Scatter requiert X, Y et une variable Z numérique (automatiquement choisie parmi Taille/Couleur/autres numériques). Vérifiez les sélections.", icon="⚠️") + elif graph_analysis_type == 'Pair Plot (SPLOM)': + # Select numerical dimensions from the original data + splom_dims = [c for c in data.columns if c in conf_numerical_columns] + if len(splom_dims) >= 2: + st.info(f"Calcul du Pair Plot pour {len(splom_dims)} dimensions numériques...") + splom_args = {'data_frame': data, 'dimensions': splom_dims} + if final_color and final_color in data.columns and final_color in conf_categorical_columns: # Color typically categorical here + splom_args['color'] = final_color + splom_args['title'] = 'Pair Plot (Matrice de Scatter)' + fig = px.scatter_matrix(**splom_args) + else: st.warning("Pair Plot requiert au moins 2 colonnes numériques dans les données originales.") + if fig is not None: + # Update layout for better readability if needed + fig.update_layout(title_x=0.5) # Center title st.session_state.analyses[i]['result'] = fig - st.session_state.analyses[i]['params'] = current_params - st.session_state.analyses[i]['executed_params'] = current_params - st.rerun() + st.session_state.analyses[i]['executed_params'] = current_params # Store executed params + st.rerun() # Rerun to display the plot except Exception as e: - st.error(f"Erreur génération graphique {i+1}: {e}") - st.session_state.analyses[i]['result'] = None - else: - if not required_cols_present: st.warning("Sélectionnez axes/groupes requis.") - if plot_data_source_df is None: st.warning("Données source indisponibles.") - else: - st.error(f"Erreur interne: Index d'analyse invalide {i}") - # Fin du bloc 'else' pour la configuration du graphique + st.error(f"Erreur génération graphique {i+1} ({graph_analysis_type}): {e}") + st.session_state.analyses[i]['result'] = None # Clear result on error + st.session_state.analyses[i]['executed_params'] = current_params # Store params even if failed + # Fin du bloc 'else' pour la configuration du graphique si des colonnes sont disponibles + # =========================== # Bloc Stats Descriptives # =========================== elif analysis['type'] == 'descriptive_stats': st.markdown(f"##### Configuration Statistiques Descriptives") - desc_col_options = current_numerical_columns + current_datetime_columns - if not desc_col_options: st.warning("Aucune colonne numérique ou date/heure disponible.") + # Can run describe on almost any column, but numerical/datetime are most common + desc_col_options = conf_all_columns + if not desc_col_options: + st.warning("Aucune colonne disponible pour les statistiques descriptives.") else: - default_desc_cols = analysis['params'].get('selected_columns', []) + # Initialize state if missing + init_analysis_state(i, 'selected_columns_desc', []) + + default_desc_cols = analysis['params'].get('selected_columns_desc', []) + # Filter default list to only include currently available columns valid_default_desc = [col for col in default_desc_cols if col in desc_col_options] - selected_columns_desc = st.multiselect(f"Analyser colonnes :", desc_col_options, default=valid_default_desc, key=f"desc_stats_columns_{analysis_id}") + # If no valid default, suggest numerical/datetime, else all + if not valid_default_desc: + valid_default_desc = [c for c in conf_numerical_columns + conf_datetime_columns if c in desc_col_options] + if not valid_default_desc: valid_default_desc = desc_col_options[:min(len(desc_col_options), 5)] # Fallback to first 5 + + st.session_state.analyses[i]['params']['selected_columns_desc'] = st.multiselect( + f"Analyser colonnes :", desc_col_options, + default=valid_default_desc, key=f"desc_stats_columns_{analysis_id}", + help="Sélectionnez les colonnes pour lesquelles calculer les statistiques descriptives." + ) if st.button(f"Exécuter Stats Descriptives {i+1}", key=f"run_desc_stats_{analysis_id}"): - current_params = {'selected_columns': selected_columns_desc} - if selected_columns_desc: + current_params = st.session_state.analyses[i]['params'].copy() + selected_cols = current_params['selected_columns_desc'] + + if selected_cols: try: - valid_cols = [col for col in selected_columns_desc if col in data.columns] - if not valid_cols: st.warning("Colonnes sélectionnées non trouvées.") + # Ensure selected columns actually exist in the dataframe + valid_cols = [col for col in selected_cols if col in data.columns] + if not valid_cols: + st.warning("Colonnes sélectionnées non trouvées dans les données actuelles.") else: - descriptive_stats = data[valid_cols].describe(include='all') + st.info(f"Calcul des statistiques descriptives pour: {', '.join(valid_cols)}") + # Use include='all' to get stats for both numerical and non-numerical + descriptive_stats = data[valid_cols].describe(include='all', datetime_is_numeric=True) st.session_state.analyses[i]['result'] = descriptive_stats - st.session_state.analyses[i]['params'] = current_params st.session_state.analyses[i]['executed_params'] = current_params st.rerun() except Exception as e: st.error(f"Erreur Stats Descriptives {i+1}: {e}") st.session_state.analyses[i]['result'] = None - else: st.warning("Veuillez sélectionner au moins une colonne.") + st.session_state.analyses[i]['executed_params'] = current_params + else: + st.warning("Veuillez sélectionner au moins une colonne.") # Afficher le résultat (dans le container, après la config) result_data = st.session_state.analyses[i].get('result') + executed_params_display = st.session_state.analyses[i].get('executed_params') + if result_data is not None: st.markdown("---") st.write(f"**Résultat Analyse {i+1}:**") + # Optionally display the parameters used to generate this result + if executed_params_display: + params_str = ", ".join([f"{k}={v}" for k,v in executed_params_display.items() if v is not None and v != []]) + st.caption(f"Paramètres exécutés: {params_str}") + analysis_type = st.session_state.analyses[i]['type'] # Get type again - if analysis_type in ['aggregated_table', 'descriptive_stats']: - if analysis_type == 'descriptive_stats': - st.dataframe(result_data.T, use_container_width=True) + try: + if analysis_type in ['aggregated_table', 'descriptive_stats'] and isinstance(result_data, pd.DataFrame): + # Transpose descriptive stats for better readability usually + if analysis_type == 'descriptive_stats': + st.dataframe(result_data.T, use_container_width=True) + else: + st.dataframe(result_data, use_container_width=True) + elif analysis_type == 'graph' and isinstance(result_data, go.Figure): + st.plotly_chart(result_data, use_container_width=True) else: - st.dataframe(result_data, use_container_width=True) - elif analysis_type == 'graph' and isinstance(result_data, go.Figure): - st.plotly_chart(result_data, use_container_width=True) - # Ne pas ajouter de séparateur après, le container s'en charge visuellement + # Fallback for unexpected result types + st.write("Type de résultat non standard:") + st.write(result_data) + except Exception as e_display_result: + st.error(f"Erreur affichage résultat Analyse {i+1}: {e_display_result}") + elif executed_params_display is not None: + # If params were executed but result is None (likely error) + st.warning(f"L'exécution précédente de l'Analyse {i+1} a échoué ou n'a produit aucun résultat.", icon="⚠️") + - # Supprimer les analyses marquées après la fin de la boucle + # Supprimer les analyses marquées APRES la fin de la boucle d'affichage if indices_to_remove: + # Remove items from the list by index, going from highest to lowest index for index in sorted(indices_to_remove, reverse=True): if 0 <= index < len(st.session_state.analyses): del st.session_state.analyses[index] + # Rerun is needed here to reflect the removal in the UI cleanly st.rerun() # --- Section Analyses Statistiques Avancées (Toggleable) --- @@ -723,242 +1130,541 @@ with app_tab: st.session_state.show_advanced_analysis = show_advanced if show_advanced: - current_adv_numerical_columns = numerical_columns - current_adv_categorical_columns = categorical_columns + # Use the detected column types for advanced analysis config + adv_numerical_columns = conf_numerical_columns + adv_categorical_columns = conf_categorical_columns + adv_all_columns = conf_all_columns - if not data_available or not (current_adv_numerical_columns or current_adv_categorical_columns): - st.warning("Chargez des données avec des colonnes numériques/catégorielles pour les analyses avancées.") + + if not data_available: + st.warning("Chargez des données pour utiliser les analyses avancées.") + elif not (adv_numerical_columns or adv_categorical_columns): + st.warning("Analyses avancées nécessitent des colonnes numériques ou catégorielles détectées.") else: - adv_analysis_key_suffix = st.session_state.data_loaded_id if st.session_state.data_loaded_id else "no_data" + # Consistent key suffix + adv_analysis_key_suffix = "adv_data_loaded" if data_available else "adv_no_data" + advanced_analysis_type = st.selectbox( "Sélectionnez le type d'analyse :", - ('Test T', 'ANOVA', 'Chi-Square Test', 'Corrélation', 'Régression Linéaire', 'ACP', 'Clustering K-Means', 'Détection d\'Anomalies (Z-score)'), + ('Test T', 'ANOVA', 'Chi-Square Test', 'Corrélation', 'Régression Linéaire', 'ACP (PCA)', 'Clustering K-Means', 'Détection d\'Anomalies (Z-score)'), key=f"advanced_type_{adv_analysis_key_suffix}" ) - st.markdown("---") - - def get_valid_data(df, col): return df[col].dropna() + st.markdown("---") # Separator before the specific analysis config + + # Helper function to get non-null data safely + def get_valid_data(df, col): + if df is None or col not in df.columns: return pd.Series(dtype='float64') # Return empty series + return df[col].dropna() + + # --- Blocs d'analyse avancée --- + # (Keeping the structure similar to the original for brevity, ensure column lists are correct) + container_advanced = st.container(border=True) # Put each analysis in a container + with container_advanced: + + if advanced_analysis_type == 'Test T': + st.markdown("###### Test T (Comparaison de 2 moyennes)") + # Find categorical columns with exactly 2 unique values + cols_valid_t = [c for c in adv_categorical_columns if data[c].nunique() == 2] + + if not adv_numerical_columns: + st.warning("Le Test T requiert au moins une variable numérique.") + elif not cols_valid_t: + st.warning("Le Test T requiert une variable catégorielle avec exactement 2 groupes (valeurs uniques).") + else: + col_t1, col_t2, col_t3 = st.columns([2, 2, 1]) + with col_t1: + group_col_t = st.selectbox("Variable Catégorielle (2 groupes):", cols_valid_t, key=f"t_group_{adv_analysis_key_suffix}") + with col_t2: + numeric_var_t = st.selectbox("Variable Numérique à comparer:", adv_numerical_columns, key=f"t_numeric_{adv_analysis_key_suffix}") + with col_t3: + st.write("") # Spacer + st.write("") # Spacer + if st.button("Effectuer Test T", key=f"run_t_{adv_analysis_key_suffix}", use_container_width=True): + if group_col_t and numeric_var_t: + try: + groups = data[group_col_t].unique() + group1_data = get_valid_data(data[data[group_col_t] == groups[0]], numeric_var_t) + group2_data = get_valid_data(data[data[group_col_t] == groups[1]], numeric_var_t) + if len(group1_data) < 3 or len(group2_data) < 3: # Min sample size often suggested + st.error(f"Pas assez de données valides (min 3 requis par groupe) pour '{numeric_var_t}' dans les groupes de '{group_col_t}'.") + else: + # Welch's t-test (doesn't assume equal variance) is generally safer + t_stat, p_value = stats.ttest_ind(group1_data, group2_data, equal_var=False, nan_policy='omit') + st.metric(label="T-Statistic", value=f"{t_stat:.4f}") + st.metric(label="P-Value", value=f"{p_value:.4g}") # Use g for scientific notation if small + alpha = 0.05 + if p_value < alpha: + st.success(f"Différence statistiquement significative (p < {alpha}) entre les groupes.") + else: + st.info(f"Pas de différence statistiquement significative (p >= {alpha}) entre les groupes.") + st.caption(f"Comparaison de '{numeric_var_t}' entre les groupes '{groups[0]}' et '{groups[1]}' de la colonne '{group_col_t}'. Test de Welch utilisé.") + except Exception as e: st.error(f"Erreur Test T: {e}") + else: st.warning("Veuillez sélectionner les deux variables.") + + + elif advanced_analysis_type == 'ANOVA': + st.markdown("###### ANOVA (Comparaison de >2 moyennes)") + # Find categorical columns with more than 2 unique values + cols_valid_a = [c for c in adv_categorical_columns if data[c].nunique() > 2] + + if not adv_numerical_columns: + st.warning("ANOVA requiert au moins une variable numérique.") + elif not cols_valid_a: + st.warning("ANOVA requiert une variable catégorielle avec plus de 2 groupes (valeurs uniques).") + else: + col_a1, col_a2, col_a3 = st.columns([2, 2, 1]) + with col_a1: + group_col_a = st.selectbox("Variable Catégorielle (>2 groupes):", cols_valid_a, key=f"a_group_{adv_analysis_key_suffix}") + with col_a2: + anova_numeric_var = st.selectbox("Variable Numérique à comparer:", adv_numerical_columns, key=f"a_numeric_{adv_analysis_key_suffix}") + with col_a3: + st.write("") # Spacer + st.write("") # Spacer + if st.button("Effectuer ANOVA", key=f"run_a_{adv_analysis_key_suffix}", use_container_width=True): + if group_col_a and anova_numeric_var: + try: + group_values = data[group_col_a].unique() + groups_data = [get_valid_data(data[data[group_col_a] == value], anova_numeric_var) for value in group_values] + # Filter out groups with too few data points for ANOVA + groups_data_filtered = [g for g in groups_data if len(g) >= 3] + num_groups_valid = len(groups_data_filtered) + + if num_groups_valid < 2: # Need at least 2 groups for comparison + st.error(f"Pas assez de groupes (min 2 requis) avec suffisamment de données valides (min 3 par groupe) pour '{anova_numeric_var}' dans '{group_col_a}'.") + else: + f_stat, p_value = stats.f_oneway(*groups_data_filtered) + st.metric(label="F-Statistic", value=f"{f_stat:.4f}") + st.metric(label="P-Value", value=f"{p_value:.4g}") + alpha = 0.05 + if p_value < alpha: + st.success(f"Au moins une différence statistiquement significative (p < {alpha}) existe entre les moyennes des groupes.") + else: + st.info(f"Pas de différence statistiquement significative (p >= {alpha}) détectée entre les moyennes des groupes.") + st.caption(f"Comparaison de '{anova_numeric_var}' entre {num_groups_valid} groupes de '{group_col_a}'.") + # Add note about post-hoc tests + if p_value < alpha: + st.markdown("_Note: Un test post-hoc (ex: Tukey HSD) serait nécessaire pour identifier quelles paires de groupes sont significativement différentes._") + + except Exception as e: st.error(f"Erreur ANOVA: {e}") + else: st.warning("Veuillez sélectionner les deux variables.") + + + elif advanced_analysis_type == 'Chi-Square Test': + st.markdown("###### Test Chi-carré (Indépendance de 2 variables catégorielles)") + if len(adv_categorical_columns) < 2: + st.warning("Le Test Chi-carré requiert au moins 2 variables catégorielles.") + else: + col_c1, col_c2, col_c3 = st.columns([2, 2, 1]) + with col_c1: + chi2_var1 = st.selectbox("Variable Catégorielle 1:", adv_categorical_columns, key=f"c1_var_{adv_analysis_key_suffix}", index=0) + # Ensure var2 options don't include var1 + options_var2 = [c for c in adv_categorical_columns if c != chi2_var1] + with col_c2: + chi2_var2 = st.selectbox("Variable Catégorielle 2:", options_var2, key=f"c2_var_{adv_analysis_key_suffix}", index=0 if options_var2 else None, disabled=not options_var2) + with col_c3: + st.write("") # Spacer + st.write("") # Spacer + if st.button("Effectuer Test Chi²", key=f"run_c_{adv_analysis_key_suffix}", disabled=not chi2_var2, use_container_width=True): + if chi2_var1 and chi2_var2: + try: + # Create contingency table + contingency_table = pd.crosstab(data[chi2_var1], data[chi2_var2]) + + if contingency_table.size == 0 or contingency_table.shape[0] < 2 or contingency_table.shape[1] < 2: + st.error("Tableau de contingence non valide (dimensions < 2x2 ou vide). Vérifiez les croisements entre les catégories.") + else: + chi2_stat, p_value, dof, expected = stats.chi2_contingency(contingency_table) + st.metric(label="Chi² Statistic", value=f"{chi2_stat:.4f}") + st.metric(label="P-Value", value=f"{p_value:.4g}") + st.metric(label="Degrés de Liberté", value=dof) + alpha = 0.05 + if p_value < alpha: + st.success(f"Association statistiquement significative (p < {alpha}) détectée entre '{chi2_var1}' et '{chi2_var2}'.") + else: + st.info(f"Pas d'association statistiquement significative (p >= {alpha}) détectée entre les variables.") + st.caption(f"Test d'indépendance entre '{chi2_var1}' et '{chi2_var2}'.") + + # Display contingency table + with st.expander("Afficher Tableau de Contingence"): + st.dataframe(contingency_table) + # Check expected frequencies (rule of thumb: >5) + if (expected < 5).any(): + st.warning("Avertissement: Certaines fréquences attendues sont < 5. Le résultat du test Chi² pourrait être moins fiable.", icon="⚠️") + + except Exception as e: st.error(f"Erreur Test Chi-carré: {e}") + else: st.warning("Veuillez sélectionner deux variables catégorielles distinctes.") + + elif advanced_analysis_type == 'Corrélation': + st.markdown("###### Matrice de Corrélation (Variables Numériques)") + if len(adv_numerical_columns) < 2: + st.warning("La Corrélation requiert au moins 2 variables numériques.") + else: + # Suggest first 5 numerical columns by default, or all if fewer than 5 + default_corr_cols = adv_numerical_columns[:min(len(adv_numerical_columns), 5)] + corr_features = st.multiselect("Sélectionnez 2+ variables numériques:", adv_numerical_columns, default=default_corr_cols, key=f"corr_vars_{adv_analysis_key_suffix}") - # --- Blocs d'analyse avancée (Identiques à la version précédente) --- - if advanced_analysis_type == 'Test T': - st.markdown("###### Test T") - cols_valid_t = [c for c in current_adv_categorical_columns if data[c].nunique() == 2] - if not cols_valid_t: st.warning("Nécessite variable catégorielle avec 2 groupes.") - elif not current_adv_numerical_columns: st.warning("Nécessite variable numérique.") - else: - col_t1, col_t2, col_t3 = st.columns(3) - with col_t1: group_col_t = st.selectbox("Variable Catégorielle (2 groupes):", cols_valid_t, key=f"t_group_{adv_analysis_key_suffix}") - with col_t2: numeric_var_t = st.selectbox("Variable Numérique:", current_adv_numerical_columns, key=f"t_numeric_{adv_analysis_key_suffix}") - with col_t3: - st.write("") - if st.button("Effectuer Test T", key=f"run_t_{adv_analysis_key_suffix}"): - if group_col_t and numeric_var_t: + if st.button("Calculer Matrice de Corrélation", key=f"run_corr_{adv_analysis_key_suffix}", use_container_width=True): + if len(corr_features) >= 2: try: - groups = data[group_col_t].unique() - group1_data = get_valid_data(data[data[group_col_t] == groups[0]], numeric_var_t) - group2_data = get_valid_data(data[data[group_col_t] == groups[1]], numeric_var_t) - if len(group1_data) < 2 or len(group2_data) < 2: st.error("Pas assez de données valides (<2) par groupe.") + # Ensure columns exist + valid_corr_features = [f for f in corr_features if f in data.columns] + if len(valid_corr_features) >= 2: + corr_matrix = data[valid_corr_features].corr() + # Create heatmap using Plotly + fig_corr = px.imshow(corr_matrix, + text_auto=".2f", # Display values on heatmap + aspect="auto", + labels=dict(color="Coefficient de Corrélation"), + x=corr_matrix.columns, + y=corr_matrix.columns, + title="Matrice de Corrélation", + color_continuous_scale='RdBu_r', # Red-Blue diverging scale + zmin=-1, zmax=1) # Ensure scale covers full range [-1, 1] + fig_corr.update_xaxes(side="bottom") + st.plotly_chart(fig_corr, use_container_width=True) else: - t_stat, p_value = stats.ttest_ind(group1_data, group2_data, equal_var=False) - st.metric(label="T-Statistic", value=f"{t_stat:.3f}", delta=None) - st.metric(label="P-Value", value=f"{p_value:.4g}", delta=None) - st.caption(f"Comparaison de '{numeric_var_t}' par '{group_col_t}'") - except Exception as e: st.error(f"Erreur Test T: {e}") - - elif advanced_analysis_type == 'ANOVA': - st.markdown("###### ANOVA") - cols_valid_a = [c for c in current_adv_categorical_columns if data[c].nunique() > 2] - if not cols_valid_a: st.warning("Nécessite variable catégorielle avec >2 groupes.") - elif not current_adv_numerical_columns: st.warning("Nécessite variable numérique.") - else: - col_a1, col_a2, col_a3 = st.columns(3) - with col_a1: group_col_a = st.selectbox("Variable Catégorielle (>2 groupes):", cols_valid_a, key=f"a_group_{adv_analysis_key_suffix}") - with col_a2: anova_numeric_var = st.selectbox("Variable Numérique:", current_adv_numerical_columns, key=f"a_numeric_{adv_analysis_key_suffix}") - with col_a3: - st.write("") - if st.button("Effectuer ANOVA", key=f"run_a_{adv_analysis_key_suffix}"): - if group_col_a and anova_numeric_var: - try: - groups_data = [get_valid_data(data[data[group_col_a] == value], anova_numeric_var) for value in data[group_col_a].unique()] - groups_data_filtered = [g for g in groups_data if len(g) >= 2] - if len(groups_data_filtered) < 2: st.error("Pas assez de groupes (min 2) avec suffisamment de données valides (min 2).") - else: - f_stat, p_value = stats.f_oneway(*groups_data_filtered) - st.metric(label="F-Statistic", value=f"{f_stat:.3f}", delta=None) - st.metric(label="P-Value", value=f"{p_value:.4g}", delta=None) - st.caption(f"Comparaison de '{anova_numeric_var}' par '{group_col_a}'") - except Exception as e: st.error(f"Erreur ANOVA: {e}") - - elif advanced_analysis_type == 'Chi-Square Test': - st.markdown("###### Test Chi-carré") - if len(current_adv_categorical_columns) < 2: st.warning("Nécessite au moins 2 variables catégorielles.") - else: - col_c1, col_c2, col_c3 = st.columns(3) - with col_c1: chi2_var1 = st.selectbox("Variable Catégorielle 1:", current_adv_categorical_columns, key=f"c1_var_{adv_analysis_key_suffix}") - options_var2 = [c for c in current_adv_categorical_columns if c != chi2_var1] - with col_c2: chi2_var2 = st.selectbox("Variable Catégorielle 2:", options_var2, key=f"c2_var_{adv_analysis_key_suffix}", disabled=not options_var2) - with col_c3: - st.write("") - if st.button("Effectuer Test Chi²", key=f"run_c_{adv_analysis_key_suffix}", disabled=not chi2_var2): - if chi2_var1 and chi2_var2: - try: - contingency_table = pd.crosstab(data[chi2_var1], data[chi2_var2]) - if contingency_table.shape[0] < 2 or contingency_table.shape[1] < 2: - st.error("Tableau de contingence trop petit (dimensions doivent être >= 2x2). Vérifiez les variables.") - else: - chi2_stat, p_value, dof, expected = stats.chi2_contingency(contingency_table) - st.metric(label="Chi² Statistic", value=f"{chi2_stat:.3f}") - st.metric(label="P-Value", value=f"{p_value:.4g}") - st.metric(label="Degrés de Liberté", value=dof) - st.caption(f"Test d'indépendance: '{chi2_var1}' vs '{chi2_var2}'") - with st.expander("Tableau de Contingence"): st.dataframe(contingency_table) - except Exception as e: st.error(f"Erreur Chi-carré: {e}") - - elif advanced_analysis_type == 'Corrélation': - st.markdown("###### Corrélation") - if len(current_adv_numerical_columns) < 2: st.warning("Nécessite au moins 2 variables numériques.") - else: - corr_features = st.multiselect("Sélectionnez 2+ variables numériques:", current_adv_numerical_columns, default=current_adv_numerical_columns[:min(len(current_adv_numerical_columns), 5)], key=f"corr_vars_{adv_analysis_key_suffix}") - if st.button("Calculer Matrice de Corrélation", key=f"run_corr_{adv_analysis_key_suffix}"): - if len(corr_features) >= 2: - try: - valid_corr_features = [f for f in corr_features if f in data.columns] - if len(valid_corr_features) >= 2: - corr_matrix = data[valid_corr_features].corr() - fig_corr = px.imshow(corr_matrix, text_auto=".2f", aspect="auto", title="Matrice de Corrélation", color_continuous_scale='RdBu_r', zmin=-1, zmax=1) - st.plotly_chart(fig_corr, use_container_width=True) - else: st.warning("Pas assez de variables valides sélectionnées.") - except Exception as e: st.error(f"Erreur Corrélation: {e}") - else: st.warning("Sélectionnez au moins 2 variables.") - - elif advanced_analysis_type == 'Régression Linéaire': - st.markdown("###### Régression Linéaire Simple") - if len(current_adv_numerical_columns) < 2: st.warning("Nécessite au moins 2 variables numériques.") - else: - col_r1, col_r2, col_r3 = st.columns(3) - with col_r1: reg_target = st.selectbox("Variable Cible (Y):", current_adv_numerical_columns, key=f"reg_target_{adv_analysis_key_suffix}") - options_reg_feature = [f for f in current_adv_numerical_columns if f != reg_target] - with col_r2: reg_feature = st.selectbox("Variable Explicative (X):", options_reg_feature, key=f"reg_feature_{adv_analysis_key_suffix}", disabled=not options_reg_feature) - with col_r3: - st.write("") - if st.button("Effectuer Régression", key=f"run_reg_{adv_analysis_key_suffix}", disabled=not reg_feature): - if reg_target and reg_feature: - try: - df_reg = data[[reg_feature, reg_target]].dropna() - if len(df_reg) < 10: st.error("Pas assez de données valides (<10 lignes).") - else: - X = df_reg[[reg_feature]] - y = df_reg[reg_target] - X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) - model = LinearRegression() - model.fit(X_train, y_train) - y_pred = model.predict(X_test) - mse = mean_squared_error(y_test, y_pred) - r2 = r2_score(y_test, y_pred) - st.metric(label="R²", value=f"{r2:.3f}") - st.metric(label="MSE", value=f"{mse:.3f}") - st.write(f"Coefficient ({reg_feature}): {model.coef_[0]:.3f}") - st.write(f"Intercept: {model.intercept_:.3f}") - fig_reg = px.scatter(df_reg, x=reg_feature, y=reg_target, trendline="ols", title=f"Régression: {reg_target} vs {reg_feature}", labels={reg_feature:reg_feature, reg_target:reg_target}) - st.plotly_chart(fig_reg, use_container_width=True) - except Exception as e: st.error(f"Erreur Régression: {e}") - - elif advanced_analysis_type == 'ACP': - st.markdown("###### ACP") - if len(current_adv_numerical_columns) < 2: st.warning("Nécessite au moins 2 variables numériques.") - else: - pca_features = st.multiselect("Sélectionnez 2+ variables numériques:", current_adv_numerical_columns, default=current_adv_numerical_columns[:min(len(current_adv_numerical_columns), 5)], key=f"pca_vars_{adv_analysis_key_suffix}") - if st.button("Effectuer ACP", key=f"run_pca_{adv_analysis_key_suffix}"): - if len(pca_features) >= 2: - try: - valid_pca_features = [f for f in pca_features if f in data.columns] - if len(valid_pca_features) >= 2: - df_pca_raw = data[valid_pca_features].dropna() - if len(df_pca_raw) < len(valid_pca_features): st.error("Pas assez de données valides (lignes < colonnes).") - elif len(df_pca_raw) < 2: st.error("Pas assez de données valides (<2 lignes).") - else: - pca = PCA(n_components=2) - pca_result = pca.fit_transform(df_pca_raw) - pca_df = pd.DataFrame(data=pca_result, columns=['PC1', 'PC2'], index=df_pca_raw.index) - explained_variance = pca.explained_variance_ratio_ - st.write(f"Variance expliquée PC1: {explained_variance[0]:.2%}, PC2: {explained_variance[1]:.2%}, Total: {(explained_variance[0] + explained_variance[1]):.2%}") - fig_pca = px.scatter(pca_df, x='PC1', y='PC2', title="Résultat ACP (2 Composantes)") - st.plotly_chart(fig_pca, use_container_width=True) - with st.expander("Détails ACP"): - st.write("Scores PC:"); st.dataframe(pca_df) - st.write("Loadings:"); loadings = pd.DataFrame(pca.components_.T, columns=['PC1', 'PC2'], index=valid_pca_features); st.dataframe(loadings) - else: st.warning("Pas assez de variables valides sélectionnées.") - except Exception as e: st.error(f"Erreur ACP: {e}") - else: st.warning("Sélectionnez au moins 2 variables.") - - elif advanced_analysis_type == 'Clustering K-Means': - st.markdown("###### Clustering K-Means") - if len(current_adv_numerical_columns) < 2: st.warning("Nécessite au moins 2 variables numériques.") - else: - col_cl1, col_cl2, col_cl3 = st.columns(3) - with col_cl1: cluster_features = st.multiselect("Variables Numériques:", current_adv_numerical_columns, default=current_adv_numerical_columns[:min(len(current_adv_numerical_columns), 2)], key=f"clust_vars_{adv_analysis_key_suffix}") - with col_cl2: num_clusters = st.number_input("Nombre de clusters (K):", min_value=2, max_value=15, value=3, key=f"clust_k_{adv_analysis_key_suffix}") - with col_cl3: - st.write("") - if st.button("Effectuer Clustering", key=f"run_clust_{adv_analysis_key_suffix}"): - if len(cluster_features) >= 2 and num_clusters: - try: - valid_cluster_features = [f for f in cluster_features if f in data.columns] - if len(valid_cluster_features) >= 2: - df_clust_raw = data[valid_cluster_features].dropna() - if len(df_clust_raw) < num_clusters: st.error("Pas assez de données valides.") + st.warning("Pas assez de variables numériques valides sélectionnées ou trouvées.") + except Exception as e: + st.error(f"Erreur calcul/affichage Corrélation: {e}") + else: + st.warning("Veuillez sélectionner au moins 2 variables numériques.") + + + elif advanced_analysis_type == 'Régression Linéaire': + st.markdown("###### Régression Linéaire Simple (Y ~ X)") + if len(adv_numerical_columns) < 2: + st.warning("La Régression Linéaire requiert au moins 2 variables numériques.") + else: + col_r1, col_r2, col_r3 = st.columns([2, 2, 1]) + with col_r1: + # Ensure target options are only numerical + reg_target = st.selectbox("Variable Cible (Y, dépendante):", adv_numerical_columns, key=f"reg_target_{adv_analysis_key_suffix}", index=0) + # Ensure feature options are numerical and different from target + options_reg_feature = [f for f in adv_numerical_columns if f != reg_target] + with col_r2: + reg_feature = st.selectbox("Variable Explicative (X, indépendante):", options_reg_feature, key=f"reg_feature_{adv_analysis_key_suffix}", index=0 if options_reg_feature else None, disabled=not options_reg_feature) + with col_r3: + st.write("") # Spacer + st.write("") # Spacer + if st.button("Effectuer Régression", key=f"run_reg_{adv_analysis_key_suffix}", disabled=not reg_feature, use_container_width=True): + if reg_target and reg_feature: + try: + # Prepare data: use only selected columns and drop rows with NAs in either + df_reg = data[[reg_feature, reg_target]].dropna() + if len(df_reg) < 10: # Need sufficient data points + st.error(f"Pas assez de données valides (<10 lignes) après suppression des NAs pour les colonnes '{reg_feature}' et '{reg_target}'.") else: - kmeans = KMeans(n_clusters=num_clusters, n_init=10, random_state=42) - clusters = kmeans.fit_predict(df_clust_raw) - clust_result_df = df_clust_raw.copy() - clust_result_df['Cluster'] = clusters.astype(str) - st.write(f"Résultats clustering (K={num_clusters}):") - if len(valid_cluster_features) == 2: # Visualisation directe - fig_clust = px.scatter(clust_result_df, x=valid_cluster_features[0], y=valid_cluster_features[1], color='Cluster', title=f'Clusters K-Means (K={num_clusters})') - st.plotly_chart(fig_clust, use_container_width=True) - else: # Visualisation via ACP - pca_clust = PCA(n_components=2) - pca_clust_result = pca_clust.fit_transform(df_clust_raw) - pca_clust_df = pd.DataFrame(data=pca_clust_result, columns=['PC1', 'PC2']) - pca_clust_df['Cluster'] = clusters.astype(str) - fig_clust_pca = px.scatter(pca_clust_df, x='PC1', y='PC2', color='Cluster', title=f'Clusters K-Means via ACP (K={num_clusters})') - st.plotly_chart(fig_clust_pca, use_container_width=True) - with st.expander("Données avec Clusters"): st.dataframe(clust_result_df) - else: st.warning("Pas assez de variables valides sélectionnées.") - except Exception as e: st.error(f"Erreur Clustering: {e}") - else: st.warning("Sélectionnez >= 2 variables et K.") - - - elif advanced_analysis_type == 'Détection d\'Anomalies (Z-score)': - st.markdown("###### Détection d'Anomalies (Z-score)") - if not current_adv_numerical_columns: st.warning("Nécessite au moins 1 variable numérique.") - else: - anomaly_features = st.multiselect("Sélectionnez 1+ variables numériques:", current_adv_numerical_columns, default=current_adv_numerical_columns[:min(len(current_adv_numerical_columns), 1)], key=f"anomaly_vars_{adv_analysis_key_suffix}") - z_threshold = st.number_input("Seuil Z-score:", min_value=1.0, max_value=5.0, value=3.0, step=0.1, key=f"anomaly_z_{adv_analysis_key_suffix}") - if st.button("Détecter les Anomalies", key=f"run_anomaly_{adv_analysis_key_suffix}"): - if anomaly_features: - try: - valid_anomaly_features = [f for f in anomaly_features if f in data.columns] - if valid_anomaly_features: - df_anomaly_raw = data[valid_anomaly_features].dropna() - if not df_anomaly_raw.empty: - z_scores = np.abs(stats.zscore(df_anomaly_raw)) - anomalies_mask = (z_scores > z_threshold).any(axis=1) - anomaly_indices = df_anomaly_raw.index[anomalies_mask] - st.write(f"{len(anomaly_indices)} anomalies détectées (Z-score > {z_threshold}).") - if not anomaly_indices.empty: - st.dataframe(data.loc[anomaly_indices]) - else: st.info("Aucune anomalie détectée.") - else: st.warning("Pas de données valides après filtrage NaN.") - else: st.warning("Aucune variable valide sélectionnée.") - except Exception as e: st.error(f"Erreur Détection Anomalies: {e}") - else: st.warning("Sélectionnez au moins 1 variable.") - - # else: # Si show_advanced est False, ne rien afficher ici + X = df_reg[[reg_feature]] # Feature needs to be 2D array/DataFrame + y = df_reg[reg_target] # Target is 1D array/Series + + # Split data for basic evaluation (optional but good practice) + X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42) + + if len(X_train) < 2 or len(X_test) < 1: + st.error("Pas assez de données pour la division train/test.") + else: + # Fit the model + model = LinearRegression() + model.fit(X_train, y_train) + + # Make predictions on test set + y_pred = model.predict(X_test) + + # Evaluate + mse = mean_squared_error(y_test, y_pred) + r2 = r2_score(y_test, y_pred) # R-squared on test data + + # Display results + st.metric(label=f"R² (Coefficient de détermination)", value=f"{r2:.3f}", help="Proportion de la variance de Y expliquée par X. Plus proche de 1, mieux c'est.") + st.metric(label="MSE (Erreur Quadratique Moyenne)", value=f"{mse:.3f}", help="Erreur moyenne des prédictions. Plus bas, mieux c'est.") + st.write(f"**Coefficient (Pente pour '{reg_feature}')**: {model.coef_[0]:.4f}") + st.write(f"**Intercept (Ordonnée à l'origine)**: {model.intercept_:.4f}") + st.markdown(f"**Équation**: `{reg_target} ≈ {model.coef_[0]:.3f} * {reg_feature} + {model.intercept_:.3f}`") + + # Plot using Plotly with trendline + fig_reg = px.scatter(df_reg, x=reg_feature, y=reg_target, + trendline="ols", # Ordinary Least Squares trendline + trendline_color_override="red", + title=f"Régression Linéaire: {reg_target} vs {reg_feature}", + labels={reg_feature: f"{reg_feature} (X)", reg_target: f"{reg_target} (Y)"} + ) + # Add R-squared to the plot title or annotation if possible (requires statsmodels usually for direct display) + # fig_reg.update_layout(title=f"Régression: {reg_target} vs {reg_feature} (R²={r2:.3f})") # Simple title update + st.plotly_chart(fig_reg, use_container_width=True) + st.caption("La ligne rouge représente la droite de régression linéaire ajustée.") + + except Exception as e: st.error(f"Erreur Régression Linéaire: {e}") + else: st.warning("Veuillez sélectionner une variable cible (Y) et une variable explicative (X).") + + + elif advanced_analysis_type == 'ACP (PCA)': + st.markdown("###### ACP (Analyse en Composantes Principales)") + if len(adv_numerical_columns) < 2: + st.warning("L'ACP requiert au moins 2 variables numériques.") + else: + default_pca_cols = adv_numerical_columns[:min(len(adv_numerical_columns), 5)] + pca_features = st.multiselect("Sélectionnez 2+ variables numériques:", adv_numerical_columns, default=default_pca_cols, key=f"pca_vars_{adv_analysis_key_suffix}") + + if st.button("Effectuer ACP", key=f"run_pca_{adv_analysis_key_suffix}", use_container_width=True): + if len(pca_features) >= 2: + try: + valid_pca_features = [f for f in pca_features if f in data.columns] + if len(valid_pca_features) >= 2: + # Prepare data: select features and drop rows with any NAs in those columns + df_pca_raw = data[valid_pca_features].dropna() + + if len(df_pca_raw) < len(valid_pca_features): + st.error(f"Pas assez de lignes ({len(df_pca_raw)}) après suppression des NAs par rapport au nombre de variables ({len(valid_pca_features)}) pour effectuer l'ACP.") + elif len(df_pca_raw) < 2: + st.error("Pas assez de données valides (<2 lignes) après suppression des NAs.") + else: + # --- ACP Logic --- + from sklearn.preprocessing import StandardScaler + + # 1. Scale the data (important for PCA) + scaler = StandardScaler() + scaled_data = scaler.fit_transform(df_pca_raw) + + # 2. Apply PCA (reducing to 2 components for visualization) + n_components_pca = 2 + pca = PCA(n_components=n_components_pca) + pca_result = pca.fit_transform(scaled_data) + + # 3. Create DataFrame for results + pca_df = pd.DataFrame(data=pca_result, + columns=[f'PC{i+1}' for i in range(n_components_pca)], + index=df_pca_raw.index) # Keep original index if needed + + # 4. Explained Variance + explained_variance = pca.explained_variance_ratio_ + total_variance_explained = sum(explained_variance) + + st.write(f"**Variance Expliquée par les {n_components_pca} Composantes Principales:**") + for i, variance in enumerate(explained_variance): + st.write(f"- PC{i+1}: {variance:.2%}") + st.write(f"**Total:** {total_variance_explained:.2%}") + + # 5. Visualization (Scatter plot of PC1 vs PC2) + fig_pca = px.scatter(pca_df, x='PC1', y='PC2', + title=f"Résultat ACP ({n_components_pca} Composantes)", + labels={'PC1': f'Composante Principale 1 ({explained_variance[0]:.1%})', + 'PC2': f'Composante Principale 2 ({explained_variance[1]:.1%})'} + ) + st.plotly_chart(fig_pca, use_container_width=True) + + # 6. Optional: Loadings (correlation between original vars and PCs) + with st.expander("Afficher les 'Loadings' (Contribution des variables aux PCs)"): + loadings = pd.DataFrame(pca.components_.T, # Transpose components + columns=[f'PC{i+1}' for i in range(n_components_pca)], + index=valid_pca_features) + st.dataframe(loadings) + st.caption("Les loadings indiquent la corrélation entre les variables originales et les composantes principales.") + + # 7. Optional: Scree Plot (Elbow method for choosing # components) + with st.expander("Afficher le 'Scree Plot' (Variance expliquée par PC)"): + try: + pca_full = PCA().fit(scaled_data) # Fit PCA with all components + explained_variance_full = pca_full.explained_variance_ratio_ + fig_scree = px.bar(x=range(1, len(explained_variance_full) + 1), y=explained_variance_full, + title="Scree Plot - Variance Expliquée par Composante", + labels={'x': 'Composante Principale', 'y': 'Proportion de Variance Expliquée'}) + fig_scree.update_layout(showlegend=False) + st.plotly_chart(fig_scree, use_container_width=True) + st.caption("Le 'coude' dans ce graphique peut aider à choisir le nombre optimal de composantes à retenir.") + except Exception as e_scree: + st.warning(f"Impossible de générer le Scree Plot: {e_scree}") + + + else: st.warning("Pas assez de variables numériques valides sélectionnées ou trouvées.") + except Exception as e: st.error(f"Erreur ACP: {e}") + else: st.warning("Veuillez sélectionner au moins 2 variables numériques.") + + + elif advanced_analysis_type == 'Clustering K-Means': + st.markdown("###### Clustering K-Means (Regroupement non supervisé)") + if len(adv_numerical_columns) < 2: + st.warning("Le Clustering K-Means requiert au moins 2 variables numériques.") + else: + col_cl1, col_cl2, col_cl3 = st.columns([2, 1, 1]) + with col_cl1: + # Suggest first 2 numerical columns by default + default_clust_cols = adv_numerical_columns[:min(len(adv_numerical_columns), 2)] + cluster_features = st.multiselect( + "Variables Numériques pour le clustering:", adv_numerical_columns, + default=default_clust_cols, key=f"clust_vars_{adv_analysis_key_suffix}" + ) + with col_cl2: + # Suggest K based on data size, but capped + default_k = min(max(2, int(np.sqrt(len(data)//2)) if len(data)>8 else 3), 10) + num_clusters = st.number_input("Nombre de clusters (K):", min_value=2, max_value=20, value=default_k, key=f"clust_k_{adv_analysis_key_suffix}") + with col_cl3: + st.write("") # Spacer + st.write("") # Spacer + if st.button("Effectuer Clustering", key=f"run_clust_{adv_analysis_key_suffix}", use_container_width=True): + if len(cluster_features) >= 1 and num_clusters: # Allow 1D clustering too + try: + valid_cluster_features = [f for f in cluster_features if f in data.columns] + if len(valid_cluster_features) >= 1: + # Prepare data: select features and drop NAs + df_clust_raw = data[valid_cluster_features].dropna() + + if len(df_clust_raw) < num_clusters: + st.error(f"Pas assez de lignes de données valides ({len(df_clust_raw)}) pour former {num_clusters} clusters.") + else: + # --- K-Means Logic --- + from sklearn.preprocessing import StandardScaler + + # 1. Scale data (important for K-Means) + scaler_clust = StandardScaler() + scaled_clust_data = scaler_clust.fit_transform(df_clust_raw) + + # 2. Apply K-Means + kmeans = KMeans(n_clusters=num_clusters, + n_init=10, # Run multiple times with different seeds + random_state=42) + clusters = kmeans.fit_predict(scaled_clust_data) + + # 3. Add cluster assignment back to the (unscaled) data subset + clust_result_df = df_clust_raw.copy() + clust_result_df['Cluster'] = clusters.astype(str) # Convert to string for categorical coloring + clust_result_df['Cluster'] = 'Cluster ' + clust_result_df['Cluster'] # Add prefix + + st.write(f"**Résultats du Clustering K-Means (K={num_clusters}):**") + + # 4. Visualization + if len(valid_cluster_features) == 1: + # Histogram colored by cluster + fig_clust = px.histogram(clust_result_df, x=valid_cluster_features[0], color='Cluster', + title=f'Distribution par Cluster (K={num_clusters}) sur {valid_cluster_features[0]}', + marginal="rug") # Add rug plot + st.plotly_chart(fig_clust, use_container_width=True) + elif len(valid_cluster_features) == 2: + # Direct 2D Scatter plot + fig_clust = px.scatter(clust_result_df, + x=valid_cluster_features[0], y=valid_cluster_features[1], + color='Cluster', + title=f'Clusters K-Means (K={num_clusters})', + labels={col: col for col in valid_cluster_features}) + st.plotly_chart(fig_clust, use_container_width=True) + else: # >= 3 features, use PCA for visualization + st.info("Plus de 2 variables sélectionnées, visualisation via ACP (2 premières composantes)...") + pca_clust = PCA(n_components=2) + # Use the SCALED data for PCA visualization here + pca_clust_result = pca_clust.fit_transform(scaled_clust_data) + pca_clust_df = pd.DataFrame(data=pca_clust_result, columns=['PC1', 'PC2'], index=df_clust_raw.index) + # Add cluster labels (already computed) + pca_clust_df['Cluster'] = clust_result_df['Cluster'] # Use the same cluster assignments + + variance_ratio = pca_clust.explained_variance_ratio_ + fig_clust_pca = px.scatter(pca_clust_df, x='PC1', y='PC2', color='Cluster', + title=f'Clusters K-Means (K={num_clusters}) visualisés via ACP', + labels={'PC1': f'PC1 ({variance_ratio[0]:.1%})', 'PC2': f'PC2 ({variance_ratio[1]:.1%})'}) + st.plotly_chart(fig_clust_pca, use_container_width=True) + st.caption(f"Visualisation basée sur les 2 premières composantes principales expliquant {sum(variance_ratio):.1%} de la variance des données sélectionnées.") + + + # 5. Show data with clusters + with st.expander(f"Afficher les {len(clust_result_df)} lignes avec leurs clusters assignés"): + st.dataframe(clust_result_df) + + # 6. Optional: Elbow method plot to help choose K + with st.expander("Aide au choix de K (Méthode du Coude)"): + try: + st.info("Calcul de l'inertie pour différents K (peut prendre du temps)...") + inertia = [] + k_range = range(1, min(11, len(df_clust_raw))) # Test K from 1 to 10 or num_samples + for k in k_range: + kmeans_elbow = KMeans(n_clusters=k, n_init=10, random_state=42) + kmeans_elbow.fit(scaled_clust_data) + inertia.append(kmeans_elbow.inertia_) # Inertia: Sum of squared distances to closest centroid + + fig_elbow = px.line(x=list(k_range), y=inertia, + title="Méthode du Coude pour choisir K", + labels={'x': 'Nombre de Clusters (K)', 'y': 'Inertie (WSS)'}, + markers=True) + st.plotly_chart(fig_elbow, use_container_width=True) + st.caption("Cherchez le 'coude' où la diminution de l'inertie ralentit significativement. Cela suggère un bon K.") + except Exception as e_elbow: + st.warning(f"Impossible de générer le graphique du coude: {e_elbow}") + + + else: st.warning("Pas assez de variables numériques valides sélectionnées ou trouvées.") + except Exception as e: st.error(f"Erreur Clustering K-Means: {e}") + else: st.warning("Veuillez sélectionner au moins une variable numérique et un nombre de clusters (K).") + + + elif advanced_analysis_type == 'Détection d\'Anomalies (Z-score)': + st.markdown("###### Détection d'Anomalies (Basée sur le Z-score)") + if not adv_numerical_columns: + st.warning("La détection d'anomalies Z-score requiert au moins 1 variable numérique.") + else: + col_anom1, col_anom2, col_anom3 = st.columns([2, 1, 1]) + with col_anom1: + # Default to first numerical column if available + default_anomaly_cols = adv_numerical_columns[:1] + anomaly_features = st.multiselect( + "Sélectionnez 1+ variables numériques:", adv_numerical_columns, + default=default_anomaly_cols, key=f"anomaly_vars_{adv_analysis_key_suffix}" + ) + with col_anom2: + z_threshold = st.number_input("Seuil Z-score:", min_value=1.0, max_value=5.0, value=3.0, step=0.1, key=f"anomaly_z_{adv_analysis_key_suffix}", help="Nombre d'écarts-types par rapport à la moyenne pour considérer un point comme une anomalie.") + with col_anom3: + st.write("") # Spacer + st.write("") # Spacer + if st.button("Détecter les Anomalies", key=f"run_anomaly_{adv_analysis_key_suffix}", use_container_width=True): + if anomaly_features: + try: + valid_anomaly_features = [f for f in anomaly_features if f in data.columns] + if valid_anomaly_features: + # Prepare data: select features and drop NAs for calculation + df_anomaly_raw = data[valid_anomaly_features].dropna() - else: - # Message si aucune donnée n'est chargée - st.warning("Veuillez charger un fichier de données (CSV ou Excel) via la barre latérale pour commencer.", icon="📁") + if df_anomaly_raw.empty: + st.warning("Pas de données valides pour la détection d'anomalies après suppression des NAs.") + else: + # Calculate Z-scores for each selected column + z_scores = np.abs(stats.zscore(df_anomaly_raw)) + + # Create a boolean mask: True if ANY z-score in a row exceeds threshold + anomalies_mask = (z_scores > z_threshold).any(axis=1) + + # Get the original indices of the anomalies + anomaly_indices = df_anomaly_raw.index[anomalies_mask] + num_anomalies = len(anomaly_indices) + + st.metric(label="Anomalies Détectées", value=num_anomalies) + st.caption(f"Basé sur un seuil Z-score > {z_threshold} sur au moins une des variables sélectionnées.") + + if num_anomalies > 0: + st.write(f"**{num_anomalies} ligne(s) identifiée(s) comme anomalies potentielles :**") + # Show the original data rows identified as anomalies + st.dataframe(data.loc[anomaly_indices]) + else: + st.success("Aucune anomalie détectée avec le seuil Z-score spécifié.") + + # Optional: Visualize distribution with threshold lines (if 1 variable) + if len(valid_anomaly_features) == 1: + col_name = valid_anomaly_features[0] + mean_val = data[col_name].mean() + std_val = data[col_name].std() + lower_bound = mean_val - z_threshold * std_val + upper_bound = mean_val + z_threshold * std_val + + fig_dist = px.histogram(data, x=col_name, title=f'Distribution de {col_name} avec seuils Z={z_threshold}', marginal="box") + fig_dist.add_vline(x=lower_bound, line_dash="dash", line_color="red", annotation_text=f"Seuil Inf (Z=-{z_threshold})") + fig_dist.add_vline(x=upper_bound, line_dash="dash", line_color="red", annotation_text=f"Seuil Sup (Z=+{z_threshold})") + st.plotly_chart(fig_dist, use_container_width=True) + + + else: st.warning("Aucune variable numérique valide sélectionnée ou trouvée.") + except Exception as e: st.error(f"Erreur Détection Anomalies Z-score: {e}") + else: st.warning("Veuillez sélectionner au moins 1 variable numérique.") + + # --- Fin de la section 'if show_advanced:' --- + + else: # data is None + # Message si aucune donnée n'est chargée au début + st.warning("👋 Bienvenue ! Veuillez charger un fichier de données (CSV ou Excel) via la barre latérale pour commencer.", icon="📁") # ============================================================================== @@ -973,51 +1679,55 @@ with manual_tab: ### 1. Chargement des Données (Barre Latérale ⚙️) - **Uploader un Fichier** : Cliquez sur "Déposez un fichier..." ou glissez votre fichier CSV/Excel dans la zone. L'application le chargera automatiquement. - **Utiliser l'en-tête** : Cochez/décochez la case "Utiliser la première ligne comme en-tête" AVANT ou APRES avoir déposé le fichier pour indiquer si la première ligne contient les noms de colonnes. Le fichier sera (re)chargé avec votre préférence. - - **Fichier par Défaut** : Si aucun fichier n'est chargé, l'application essaie d'utiliser `sample_excel.xlsx` situé dans le dossier **parent** du dossier du script. - - **Indicateur** : La section "Aperçu et Analyse" indique la source des données actives. + - **Fichier par Défaut** : Si aucun fichier n'est chargé, l'application essaie d'utiliser `sample_excel.xlsx`. **Important :** Ce fichier doit se trouver **dans le même dossier** que le script de l'application (`dashboard_app.py`) dans le dépôt Hugging Face. + - **Indicateur** : La section "Aperçu et Analyse" indique la source des données actives (`Fichier chargé: ...`, `Fichier local par défaut`, etc.). + - **Types de Colonnes** : L'application essaie de détecter automatiquement les types de colonnes (Numérique, Catégoriel, Date/Heure) après le chargement. Vous pouvez vérifier les types détectés dans l'aperçu des données. --- ### 2. Configuration (Barre Latérale ⚙️) - **Renommer Colonnes** : - 1. Sélectionnez la colonne à modifier. - 2. Entrez le nouveau nom. - 3. Cliquez sur "Appliquer Renommage". Les noms seront mis à jour partout dans l'application (peut nécessiter un court instant). + 1. Sélectionnez la colonne à modifier dans la liste déroulante. + 2. Entrez le nouveau nom souhaité dans le champ de texte. + 3. Cliquez sur "Appliquer Renommage". Les noms seront mis à jour dans toute l'application (cela rafraîchira la page). - **Exporter** : - - **CSV / Excel** : Télécharge les données *actuellement affichées* (avec renommages) dans le format choisi. - - **Préparer Rapport HTML** : Génère le contenu du rapport en mémoire. Cliquez ensuite sur "Télécharger Rapport HTML" pour l'enregistrer. Le rapport contient : - - Les informations générales (source, nb lignes...). - - Tous les **résultats** des analyses (tableaux, graphiques) que vous avez **exécutés** dans la section "Analyses Configurées". + - **Exporter CSV / Exporter Excel** : Télécharge l'ensemble des données *actuellement en mémoire* (avec les éventuels renommages appliqués) dans le format choisi. Assurez-vous que `openpyxl` est dans `requirements.txt` pour l'export Excel. + - **Préparer Rapport HTML** : Génère le contenu d'un rapport HTML basé sur les analyses que vous avez **exécutées avec succès** dans la section "Analyses Configurées". + - **Télécharger Rapport HTML** : Ce bouton apparaît *après* avoir cliqué sur "Préparer...". Cliquez dessus pour enregistrer le fichier HTML généré. Le rapport contient : + - Informations générales (source, nb lignes...). + - Un aperçu des premières lignes des données. + - Tous les **résultats** (tableaux, graphiques) des analyses configurées qui ont été exécutées et ont produit un résultat. --- ### 3. Analyses (Zone Principale 📊) #### 3.1 Construire les Analyses - - Cliquez sur les boutons `➕ Ajouter...` pour insérer des blocs d'analyse de différents types (Tableau Agrégé, Graphique, Stats Descriptives). - - #### 3.2 Configurer et Exécuter - - Chaque analyse ajoutée apparaît dans la section "🔍 Analyses Configurées" dans un bloc avec une bordure. - - **Configuration** : Les options de configuration (sélection de colonnes, type de graphique, etc.) sont directement visibles dans chaque bloc. - - **Supprimer** : Cliquez sur l'icône `🗑️` en haut à droite du bloc pour l'enlever. - - **Configurer** : - - **Tableau Agrégé** : Choisissez comment regrouper (`Regrouper par`), quelle colonne calculer (`Calculer sur`), et avec quelle fonction (`Avec fonction`). - - **Graphique** : - - Sélectionnez le `Type de graphique`. - - **(Optionnel)** Configurez les "Options d'agrégation" si vous voulez agréger les données *avant* de tracer le graphique (ex: moyenne par catégorie pour un bar chart). - - Configurez les `Axes et Mappages` (X, Y, Couleur, Taille). Les options dépendent des données (originales ou agrégées) et du type de graphique. - - **Stats Descriptives** : Sélectionnez les colonnes numériques ou date/heure (`Analyser colonnes`). - - **Exécuter** : Cliquez sur le bouton "Exécuter..." propre à chaque bloc. Le résultat s'affichera juste en dessous du bloc une fois calculé (l'application se rafraîchira). - - #### 3.3 Analyses Avancées - - Cochez la case "Afficher les analyses avancées" pour accéder à des tests statistiques et des modèles (Test T, ANOVA, Chi², Corrélation, Régression, ACP, Clustering, Anomalies). - - Sélectionnez le type d'analyse, configurez les paramètres spécifiques, et cliquez sur le bouton d'exécution correspondant. Les résultats (métriques, graphiques) s'afficheront directement. + - Cliquez sur les boutons `➕ Ajouter...` pour insérer des blocs d'analyse de différents types : + - **Tableau Agrégé** : Calcule des statistiques (moyenne, somme, compte...) pour une colonne numérique, groupées par une ou plusieurs colonnes catégorielles. + - **Graphique** : Permet de créer diverses visualisations interactives. + - **Stats Descriptives** : Fournit un résumé statistique (moyenne, médiane, min, max, écart-type...) pour les colonnes sélectionnées. + + #### 3.2 Configurer et Exécuter (Blocs d'Analyse) + - Chaque analyse ajoutée apparaît dans un bloc encadré dans la section "🔍 Analyses Configurées". + - **Configuration** : Définissez les paramètres de chaque analyse directement dans son bloc (sélection de colonnes, type de graphique, fonction d'agrégation, etc.). + - Les listes de colonnes proposées sont basées sur les types détectés (Numérique, Catégoriel, Date/Heure). + - Pour les **Graphiques**, vous pouvez choisir d'agréger les données avant de tracer (via la section "Options d'agrégation") ou d'utiliser les données originales. Les axes et mappages (Couleur, Taille, Facet) sont configurés ensuite. + - **Exécuter** : Cliquez sur le bouton "Exécuter..." propre à chaque bloc. Un indicateur de chargement peut apparaître. + - **Résultat** : Si l'exécution réussit, le résultat (tableau ou graphique) s'affichera sous la configuration, à l'intérieur du même bloc. Les paramètres utilisés pour générer ce résultat sont rappelés. + - **Supprimer** : Cliquez sur l'icône `🗑️` en haut à droite du bloc pour le supprimer. + + #### 3.3 Analyses Avancées (Optionnel) + - Cochez la case "Afficher les analyses avancées" pour accéder à des méthodes statistiques plus spécifiques. + - Sélectionnez le type d'analyse dans la liste déroulante (Test T, ANOVA, Chi², Corrélation, Régression, ACP, Clustering, Détection d'Anomalies). + - Configurez les paramètres requis (variables, options). + - Cliquez sur le bouton "Effectuer..." correspondant. + - Les résultats (métriques, graphiques, tableaux) s'afficheront directement sous la configuration. --- - ### 💡 Conseils - - Assurez-vous que l'option d'en-tête est correcte lors du chargement. - - Commencez par explorer les `Stats Descriptives` pour avoir une idée générale. - - Utilisez les `Tableaux Agrégés` pour des résumés chiffrés par catégorie. - - Expérimentez avec différents `Types de graphiques` pour visualiser les relations. - - Renommez les colonnes pour plus de clarté avant de configurer des analyses complexes. - - L'export HTML est utile pour sauvegarder et partager un résumé de vos découvertes. + ### 💡 Conseils & Dépannage + - **Chargement Excel échoue ?** Vérifiez que `openpyxl` est bien listé dans votre fichier `requirements.txt` sur Hugging Face. + - **Fichier par défaut non trouvé ?** Assurez-vous que `sample_excel.xlsx` est dans le même dossier que `dashboard_app.py`. + - **Colonnes mal détectées ?** Vérifiez le formatage de vos données. Des valeurs mixtes (texte et nombres) dans une colonne peuvent perturber la détection. La fonction de renommage peut être utile. + - **Lenteur ?** Les analyses complexes (ACP, Clustering, Pair Plot) sur de grands jeux de données peuvent prendre du temps. Soyez patient. + - **Erreurs ?** Lisez attentivement les messages d'erreur. Ils donnent souvent des indices sur le problème (colonne manquante, type de données incorrect, etc.). Consultez les logs de l'application sur Hugging Face pour plus de détails si l'erreur n'est pas claire. --- **👨‍💻 Concepteur : Sidoine YEBADOKPO** @@ -1034,61 +1744,89 @@ with chat_tab: st.markdown("## 💬 Chat IA (Assisté par Google Gemini)") if not api_key: - st.warning("Le Chat IA est désactivé car la clé API Google Gemini n'est pas configurée correctement.", icon="⚠️") + st.warning("Le Chat IA est désactivé car la clé API Google Gemini ('GOOGLE_API_KEY') n'est pas configurée dans les Secrets de cet espace Hugging Face.", icon="⚠️") else: - st.info("Posez des questions générales sur l'analyse de données, l'interprétation des graphiques, ou demandez de l'aide pour choisir une analyse. **Note :** L'IA n'a pas accès direct à vos données spécifiques chargées dans l'application.", icon="💡") - - # Afficher l'historique du chat - for message in st.session_state.chat_history: - with st.chat_message(message["role"]): - st.markdown(message["content"]) - - # Zone de saisie utilisateur - user_question = st.chat_input("Votre question à l'IA...") - - if user_question: - # Afficher la question de l'utilisateur immédiatement - st.chat_message("user").markdown(user_question) - # Ajouter à l'historique - st.session_state.chat_history.append({"role": "user", "content": user_question}) - - try: - # Préparer le contexte pour l'IA (optionnel mais utile) - # S'assurer d'utiliser les listes définies après chargement/conversion - data_context = st.session_state.get('dataframe_to_export', None) - num_cols_context = data_context.select_dtypes(include=['number']).columns.tolist() if data_context is not None else [] - cat_cols_context = data_context.select_dtypes(exclude=['number', 'datetime', 'timedelta']).columns.tolist() if data_context is not None else [] - date_cols_context = data_context.select_dtypes(include=['datetime']).columns.tolist() if data_context is not None else [] - analyses_context = set(a['type'] for a in st.session_state.analyses) if st.session_state.analyses else set() + st.info("Posez des questions générales sur l'analyse de données, l'interprétation des graphiques, ou demandez de l'aide pour choisir une analyse appropriée. **Note :** L'IA n'a pas accès direct à vos données spécifiques chargées dans l'application, mais elle connaît les types de colonnes disponibles et les analyses que vous avez ajoutées.", icon="💡") + + # Configure Gemini API (do this only once or check if already configured) + try: + genai.configure(api_key=api_key) + model_chat = genai.GenerativeModel('gemini-1.5-flash-latest') # Use a suitable model + except Exception as e: + st.error(f"Erreur configuration API Gemini: {e}") + model_chat = None # Disable chat if config fails + + if model_chat: + # Initialize chat history in session state if it doesn't exist + if "gemini_chat_history" not in st.session_state: + st.session_state.gemini_chat_history = [] # Use a different key to avoid conflict + + # Display chat messages from history + for message in st.session_state.gemini_chat_history: + with st.chat_message(message["role"]): + st.markdown(message["content"]) + + # Accept user input + if user_question := st.chat_input("Votre question à l'IA..."): + # Add user message to chat history + st.session_state.gemini_chat_history.append({"role": "user", "content": user_question}) + # Display user message in chat message container + with st.chat_message("user"): + st.markdown(user_question) + + # Prepare context for the AI + data_context_chat = st.session_state.get('dataframe_to_export', None) + # Use the detected columns from the main app state + num_cols_context = numerical_columns if data_context_chat is not None else [] + cat_cols_context = categorical_columns if data_context_chat is not None else [] + date_cols_context = datetime_columns if data_context_chat is not None else [] + analyses_context = list(set(a['type'].replace('_', ' ').title() for a in st.session_state.get('analyses', []))) source_info_context = st.session_state.get('data_source_info', 'Inconnue') - context = f""" - Contexte de l'utilisateur: Utilise une application Streamlit ('Suite d'Analyse de Données Interactive'). - Données actives: {source_info_context}. - Colonnes numériques disponibles: {', '.join(num_cols_context) if num_cols_context else 'N/A'}. - Colonnes catégorielles disponibles: {', '.join(cat_cols_context) if cat_cols_context else 'N/A'}. - Colonnes Date/Heure disponibles: {', '.join(date_cols_context) if date_cols_context else 'N/A'}. - Analyses déjà ajoutées (types): {', '.join(analyses_context) if analyses_context else 'Aucune'}. + # Construct a prompt with context + context_prompt = f""" + Tu es un assistant IA aidant un utilisateur dans une application Streamlit d'analyse de données. + L'utilisateur a chargé des données dont la source est : "{source_info_context}". + Voici les types de colonnes détectés dans ses données : + - Numériques : {', '.join(num_cols_context) if num_cols_context else 'Aucune'} + - Catégorielles : {', '.join(cat_cols_context) if cat_cols_context else 'Aucune'} + - Date/Heure : {', '.join(date_cols_context) if date_cols_context else 'Aucune'} + L'utilisateur a déjà configuré les types d'analyses suivants : {', '.join(analyses_context) if analyses_context else 'Aucune'}. + + IMPORTANT : Tu n'as PAS accès aux valeurs réelles des données. Base tes réponses uniquement sur les noms/types de colonnes et les analyses configurées. Sois concis et utile. - Question de l'utilisateur: {user_question} + Question de l'utilisateur : "{user_question}" - Réponse attendue: Fournir une aide générale ou une explication en tant qu'assistant IA, sans prétendre connaître les valeurs exactes des données de l'utilisateur. + Ta réponse : """ - # Appeler l'API Gemini - model = genai.GenerativeModel('gemini-1.5-flash-latest') - response = model.generate_content(context) + # Generate response + try: + with st.spinner("L'IA réfléchit..."): + # Start chat if history exists (more conversational) + # chat_session = model_chat.start_chat(history=[...]) # Convert history format if needed + # response = chat_session.send_message(context_prompt) # If using start_chat + + # Simpler approach: send context with each message + response = model_chat.generate_content(context_prompt) - # Afficher la réponse de l'IA - with st.chat_message("assistant"): - st.markdown(response.text) - # Ajouter à l'historique - st.session_state.chat_history.append({"role": "assistant", "content": response.text}) + # Display assistant response in chat message container + with st.chat_message("assistant"): + st.markdown(response.text) + # Add assistant response to chat history + st.session_state.gemini_chat_history.append({"role": "assistant", "content": response.text}) + + # Trigger a rerun to ensure the message list updates visually smoothly + # st.rerun() # Usually not needed as chat_input handles updates well + + except Exception as e: + error_message = f"Désolé, une erreur est survenue lors de la communication avec l'IA Gemini : {e}" + st.error(error_message) + # Add error to history for context, but display user-friendly message + st.session_state.gemini_chat_history.append({"role": "assistant", "content": f"(Erreur système lors de la génération de la réponse : {e})"}) + # st.rerun() # Rerun to show the error message appended - except Exception as e: - error_message = f"Désolé, une erreur est survenue lors de la communication avec l'IA : {e}" - st.error(error_message) - # Ajouter l'erreur à l'historique pour contexte - st.session_state.chat_history.append({"role": "assistant", "content": f"Erreur système: {e}"}) + else: # model_chat is None + st.error("Le modèle de Chat IA n'a pas pu être initialisé.") - # Pas besoin de rerun, Streamlit gère l'ajout des messages de chat +# --- Fin du Script --- \ No newline at end of file